Auto-indexing service and GraphQL API for AT Protocol Records
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

docs: add authentication guide for app developers

Covers OAuth 2.0 with PKCE flow, bearer token usage, and the viewer
query for fetching authenticated user info.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

+319
+319
docs/authentication.md
··· 1 + # Authentication 2 + 3 + Queries are public and require no authentication. Mutations require a valid access token. The `viewer` query returns the authenticated user's information, or `null` if not authenticated. 4 + 5 + ## OAuth Flow 6 + 7 + Quickslice uses OAuth 2.0 with PKCE for authentication. Users authenticate via their AT Protocol identity (Bluesky account). 8 + 9 + ### Prerequisites 10 + 11 + You need an OAuth client ID. Create one in the quickslice admin UI at `/settings` or via the GraphQL API. 12 + 13 + ### 1. Generate PKCE Values 14 + 15 + Generate a code verifier and challenge for the PKCE flow: 16 + 17 + ```javascript 18 + function base64UrlEncode(buffer) { 19 + const bytes = new Uint8Array(buffer); 20 + let binary = ''; 21 + for (let i = 0; i < bytes.length; i++) { 22 + binary += String.fromCharCode(bytes[i]); 23 + } 24 + return btoa(binary) 25 + .replace(/\+/g, '-') 26 + .replace(/\//g, '_') 27 + .replace(/=+$/, ''); 28 + } 29 + 30 + async function generateCodeVerifier() { 31 + const randomBytes = new Uint8Array(32); 32 + crypto.getRandomValues(randomBytes); 33 + return base64UrlEncode(randomBytes); 34 + } 35 + 36 + async function generateCodeChallenge(verifier) { 37 + const encoder = new TextEncoder(); 38 + const data = encoder.encode(verifier); 39 + const hash = await crypto.subtle.digest('SHA-256', data); 40 + return base64UrlEncode(hash); 41 + } 42 + 43 + function generateState() { 44 + const randomBytes = new Uint8Array(16); 45 + crypto.getRandomValues(randomBytes); 46 + return base64UrlEncode(randomBytes); 47 + } 48 + ``` 49 + 50 + ### 2. Redirect to Authorization 51 + 52 + Build the authorization URL and redirect the user: 53 + 54 + ```javascript 55 + const codeVerifier = await generateCodeVerifier(); 56 + const codeChallenge = await generateCodeChallenge(codeVerifier); 57 + const state = generateState(); 58 + 59 + // Store these for the callback 60 + sessionStorage.setItem('code_verifier', codeVerifier); 61 + sessionStorage.setItem('oauth_state', state); 62 + 63 + const params = new URLSearchParams({ 64 + client_id: 'your-client-id', 65 + redirect_uri: 'http://localhost:3000/callback', 66 + response_type: 'code', 67 + code_challenge: codeChallenge, 68 + code_challenge_method: 'S256', 69 + state: state, 70 + login_hint: 'alice.bsky.social' // User's handle 71 + }); 72 + 73 + window.location.href = `http://localhost:8080/oauth/authorize?${params}`; 74 + ``` 75 + 76 + ### 3. Handle the Callback 77 + 78 + After the user authenticates, they're redirected back with a `code` parameter: 79 + 80 + ```javascript 81 + const params = new URLSearchParams(window.location.search); 82 + const code = params.get('code'); 83 + const state = params.get('state'); 84 + 85 + // Verify state matches 86 + if (state !== sessionStorage.getItem('oauth_state')) { 87 + throw new Error('State mismatch - possible CSRF attack'); 88 + } 89 + 90 + const codeVerifier = sessionStorage.getItem('code_verifier'); 91 + ``` 92 + 93 + ### 4. Exchange Code for Tokens 94 + 95 + Exchange the authorization code for access and refresh tokens: 96 + 97 + ```javascript 98 + const response = await fetch('http://localhost:8080/oauth/token', { 99 + method: 'POST', 100 + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 101 + body: new URLSearchParams({ 102 + grant_type: 'authorization_code', 103 + code: code, 104 + redirect_uri: 'http://localhost:3000/callback', 105 + client_id: 'your-client-id', 106 + code_verifier: codeVerifier 107 + }) 108 + }); 109 + 110 + const tokens = await response.json(); 111 + // tokens.access_token - Use this for authenticated requests 112 + // tokens.refresh_token - Use to get new access tokens 113 + // tokens.sub - The user's DID 114 + ``` 115 + 116 + ## Using Bearer Tokens 117 + 118 + Include the access token in the `Authorization` header: 119 + 120 + ```javascript 121 + const response = await fetch('http://localhost:8080/graphql', { 122 + method: 'POST', 123 + headers: { 124 + 'Content-Type': 'application/json', 125 + 'Authorization': `Bearer ${accessToken}` 126 + }, 127 + body: JSON.stringify({ 128 + query: '{ viewer { did handle } }' 129 + }) 130 + }); 131 + ``` 132 + 133 + Or with curl: 134 + 135 + ```bash 136 + curl -X POST http://localhost:8080/graphql \ 137 + -H "Content-Type: application/json" \ 138 + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ 139 + -d '{"query": "{ viewer { did handle } }"}' 140 + ``` 141 + 142 + ## Viewer Query 143 + 144 + The `viewer` query returns information about the authenticated user: 145 + 146 + ```graphql 147 + query { 148 + viewer { 149 + did 150 + handle 151 + appBskyActorProfileByDid { 152 + displayName 153 + description 154 + avatar { url } 155 + } 156 + } 157 + } 158 + ``` 159 + 160 + ### Fields 161 + 162 + - `did` - The user's decentralized identifier (e.g., `did:plc:abc123...`) 163 + - `handle` - The user's handle (e.g., `alice.bsky.social`) 164 + - `appBskyActorProfileByDid` - The user's profile record, joined by DID 165 + 166 + ### Behavior 167 + 168 + - Returns the user object when authenticated 169 + - Returns `null` when not authenticated (no error) 170 + - Useful for confirming authentication and fetching user info in one request 171 + 172 + ### Example Response 173 + 174 + ```json 175 + { 176 + "data": { 177 + "viewer": { 178 + "did": "did:plc:abc123xyz", 179 + "handle": "alice.bsky.social", 180 + "appBskyActorProfileByDid": { 181 + "displayName": "Alice", 182 + "description": "Hello world!", 183 + "avatar": { 184 + "url": "https://cdn.bsky.app/..." 185 + } 186 + } 187 + } 188 + } 189 + } 190 + ``` 191 + 192 + ## Complete Example 193 + 194 + Here's an end-to-end example showing login, fetching the viewer, and creating a record: 195 + 196 + ```javascript 197 + // === Configuration === 198 + const GRAPHQL_URL = 'http://localhost:8080/graphql'; 199 + const OAUTH_AUTHORIZE_URL = 'http://localhost:8080/oauth/authorize'; 200 + const OAUTH_TOKEN_URL = 'http://localhost:8080/oauth/token'; 201 + const CLIENT_ID = 'your-client-id'; 202 + const REDIRECT_URI = window.location.origin + '/callback'; 203 + 204 + // === GraphQL helper === 205 + async function graphql(query, variables = {}, token = null) { 206 + const headers = { 'Content-Type': 'application/json' }; 207 + if (token) { 208 + headers['Authorization'] = `Bearer ${token}`; 209 + } 210 + 211 + const response = await fetch(GRAPHQL_URL, { 212 + method: 'POST', 213 + headers, 214 + body: JSON.stringify({ query, variables }) 215 + }); 216 + 217 + const result = await response.json(); 218 + if (result.errors?.length) { 219 + throw new Error(result.errors[0].message); 220 + } 221 + return result.data; 222 + } 223 + 224 + // === Login === 225 + async function login(handle) { 226 + const codeVerifier = await generateCodeVerifier(); 227 + const codeChallenge = await generateCodeChallenge(codeVerifier); 228 + const state = generateState(); 229 + 230 + sessionStorage.setItem('code_verifier', codeVerifier); 231 + sessionStorage.setItem('oauth_state', state); 232 + 233 + const params = new URLSearchParams({ 234 + client_id: CLIENT_ID, 235 + redirect_uri: REDIRECT_URI, 236 + response_type: 'code', 237 + code_challenge: codeChallenge, 238 + code_challenge_method: 'S256', 239 + state: state, 240 + login_hint: handle 241 + }); 242 + 243 + window.location.href = `${OAUTH_AUTHORIZE_URL}?${params}`; 244 + } 245 + 246 + // === Handle OAuth callback === 247 + async function handleCallback() { 248 + const params = new URLSearchParams(window.location.search); 249 + const code = params.get('code'); 250 + const state = params.get('state'); 251 + 252 + if (!code) return null; 253 + 254 + if (state !== sessionStorage.getItem('oauth_state')) { 255 + throw new Error('State mismatch'); 256 + } 257 + 258 + const response = await fetch(OAUTH_TOKEN_URL, { 259 + method: 'POST', 260 + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 261 + body: new URLSearchParams({ 262 + grant_type: 'authorization_code', 263 + code, 264 + redirect_uri: REDIRECT_URI, 265 + client_id: CLIENT_ID, 266 + code_verifier: sessionStorage.getItem('code_verifier') 267 + }) 268 + }); 269 + 270 + const tokens = await response.json(); 271 + sessionStorage.setItem('access_token', tokens.access_token); 272 + 273 + // Clean up URL 274 + window.history.replaceState({}, '', window.location.pathname); 275 + 276 + return tokens.access_token; 277 + } 278 + 279 + // === Fetch current user === 280 + async function getViewer(token) { 281 + const data = await graphql(` 282 + query { 283 + viewer { 284 + did 285 + handle 286 + appBskyActorProfileByDid { 287 + displayName 288 + avatar { url } 289 + } 290 + } 291 + } 292 + `, {}, token); 293 + 294 + return data.viewer; 295 + } 296 + 297 + // === Create a record (example: status) === 298 + async function createStatus(token, emoji) { 299 + const data = await graphql(` 300 + mutation CreateStatus($status: String!, $createdAt: DateTime!) { 301 + createXyzStatusphereStatus(input: { status: $status, createdAt: $createdAt }) { 302 + uri 303 + status 304 + } 305 + } 306 + `, { 307 + status: emoji, 308 + createdAt: new Date().toISOString() 309 + }, token); 310 + 311 + return data.createXyzStatusphereStatus; 312 + } 313 + ``` 314 + 315 + ## See Also 316 + 317 + - [examples/01-statusphere-html/](../examples/01-statusphere-html/) - Complete working example 318 + - [Mutations](./mutations.md) - Creating, updating, and deleting records 319 + - [Queries](./queries.md) - Fetching data without authentication