this string has no description
pkce-backend-guide
275 lines 7.6 kB view raw
1# Quickslice OAuth PKCE Flow Guide (Server-Side) 2 3This guide covers implementing OAuth 2.0 with PKCE for server-side applications like Astro, Express, or other Node.js backends. 4 5## Overview 6 7PKCE (Proof Key for Code Exchange) protects the authorization code exchange from interception attacks. Quickslice requires the `S256` method (SHA-256 hash). 8 9--- 10 11## Astro Backend Integration 12 13### Project Structure 14 15``` 16src/ 17├── pages/ 18│ └── api/ 19│ └── auth/ 20│ ├── login.ts # Initiates OAuth flow 21│ ├── callback.ts # Handles redirect 22│ └── refresh.ts # Token refresh 23└── lib/ 24 └── pkce.ts # PKCE utilities 25``` 26 27### PKCE Utilities (`src/lib/pkce.ts`) 28 29```typescript 30import { createHash, randomBytes } from 'crypto'; 31 32export function generateCodeVerifier(): string { 33 return randomBytes(32) 34 .toString('base64') 35 .replace(/\+/g, '-') 36 .replace(/\//g, '_') 37 .replace(/=+$/, ''); 38} 39 40export function generateCodeChallenge(verifier: string): string { 41 return createHash('sha256') 42 .update(verifier) 43 .digest('base64') 44 .replace(/\+/g, '-') 45 .replace(/\//g, '_') 46 .replace(/=+$/, ''); 47} 48 49export function generateState(): string { 50 return randomBytes(16).toString('base64url'); 51} 52``` 53 54### Login Endpoint (`src/pages/api/auth/login.ts`) 55 56```typescript 57import type { APIRoute } from 'astro'; 58import { generateCodeVerifier, generateCodeChallenge, generateState } from '../../../lib/pkce'; 59 60const QUICKSLICE_SERVER = import.meta.env.QUICKSLICE_SERVER; 61const CLIENT_ID = import.meta.env.QUICKSLICE_CLIENT_ID; 62const REDIRECT_URI = import.meta.env.QUICKSLICE_REDIRECT_URI; 63 64export const GET: APIRoute = async ({ cookies, redirect }) => { 65 const codeVerifier = generateCodeVerifier(); 66 const codeChallenge = generateCodeChallenge(codeVerifier); 67 const state = generateState(); 68 69 // Store in secure httpOnly cookies 70 cookies.set('pkce_verifier', codeVerifier, { 71 httpOnly: true, 72 secure: true, 73 sameSite: 'lax', 74 path: '/', 75 maxAge: 600, // 10 minutes 76 }); 77 78 cookies.set('oauth_state', state, { 79 httpOnly: true, 80 secure: true, 81 sameSite: 'lax', 82 path: '/', 83 maxAge: 600, 84 }); 85 86 const authUrl = new URL(`${QUICKSLICE_SERVER}/oauth/authorize`); 87 authUrl.searchParams.set('client_id', CLIENT_ID); 88 authUrl.searchParams.set('redirect_uri', REDIRECT_URI); 89 authUrl.searchParams.set('response_type', 'code'); 90 authUrl.searchParams.set('code_challenge', codeChallenge); 91 authUrl.searchParams.set('code_challenge_method', 'S256'); 92 authUrl.searchParams.set('state', state); 93 authUrl.searchParams.set('scope', 'atproto'); 94 95 return redirect(authUrl.toString(), 302); 96}; 97``` 98 99### Callback Endpoint (`src/pages/api/auth/callback.ts`) 100 101```typescript 102import type { APIRoute } from 'astro'; 103 104const QUICKSLICE_SERVER = import.meta.env.QUICKSLICE_SERVER; 105const CLIENT_ID = import.meta.env.QUICKSLICE_CLIENT_ID; 106const REDIRECT_URI = import.meta.env.QUICKSLICE_REDIRECT_URI; 107 108export const GET: APIRoute = async ({ url, cookies, redirect }) => { 109 const code = url.searchParams.get('code'); 110 const state = url.searchParams.get('state'); 111 const error = url.searchParams.get('error'); 112 113 if (error) { 114 const errorDescription = url.searchParams.get('error_description') || 'Unknown error'; 115 return new Response(`Authentication failed: ${errorDescription}`, { status: 400 }); 116 } 117 118 if (!code || !state) { 119 return new Response('Missing code or state', { status: 400 }); 120 } 121 122 // Validate state to prevent CSRF 123 const savedState = cookies.get('oauth_state')?.value; 124 if (state !== savedState) { 125 return new Response('State mismatch', { status: 400 }); 126 } 127 128 // Get stored verifier 129 const codeVerifier = cookies.get('pkce_verifier')?.value; 130 if (!codeVerifier) { 131 return new Response('Missing PKCE verifier', { status: 400 }); 132 } 133 134 // Exchange code for tokens 135 const tokenResponse = await fetch(`${QUICKSLICE_SERVER}/oauth/token`, { 136 method: 'POST', 137 headers: { 138 'Content-Type': 'application/x-www-form-urlencoded', 139 }, 140 body: new URLSearchParams({ 141 grant_type: 'authorization_code', 142 code, 143 redirect_uri: REDIRECT_URI, 144 client_id: CLIENT_ID, 145 code_verifier: codeVerifier, 146 }), 147 }); 148 149 if (!tokenResponse.ok) { 150 const error = await tokenResponse.text(); 151 return new Response(`Token exchange failed: ${error}`, { status: 400 }); 152 } 153 154 const tokens = await tokenResponse.json(); 155 156 // Clear PKCE cookies 157 cookies.delete('pkce_verifier', { path: '/' }); 158 cookies.delete('oauth_state', { path: '/' }); 159 160 // Store tokens in secure httpOnly cookies 161 cookies.set('access_token', tokens.access_token, { 162 httpOnly: true, 163 secure: true, 164 sameSite: 'strict', 165 path: '/', 166 maxAge: tokens.expires_in, 167 }); 168 169 cookies.set('refresh_token', tokens.refresh_token, { 170 httpOnly: true, 171 secure: true, 172 sameSite: 'strict', 173 path: '/', 174 maxAge: 60 * 60 * 24 * 30, // 30 days 175 }); 176 177 cookies.set('user_did', tokens.sub, { 178 httpOnly: true, 179 secure: true, 180 sameSite: 'strict', 181 path: '/', 182 maxAge: 60 * 60 * 24 * 30, 183 }); 184 185 return redirect('/', 302); 186}; 187``` 188 189### Refresh Endpoint (`src/pages/api/auth/refresh.ts`) 190 191```typescript 192import type { APIRoute } from 'astro'; 193 194const QUICKSLICE_SERVER = import.meta.env.QUICKSLICE_SERVER; 195const CLIENT_ID = import.meta.env.QUICKSLICE_CLIENT_ID; 196 197export const POST: APIRoute = async ({ cookies }) => { 198 const refreshToken = cookies.get('refresh_token')?.value; 199 200 if (!refreshToken) { 201 return new Response(JSON.stringify({ error: 'No refresh token' }), { 202 status: 401, 203 headers: { 'Content-Type': 'application/json' }, 204 }); 205 } 206 207 const tokenResponse = await fetch(`${QUICKSLICE_SERVER}/oauth/token`, { 208 method: 'POST', 209 headers: { 210 'Content-Type': 'application/x-www-form-urlencoded', 211 }, 212 body: new URLSearchParams({ 213 grant_type: 'refresh_token', 214 refresh_token: refreshToken, 215 client_id: CLIENT_ID, 216 }), 217 }); 218 219 if (!tokenResponse.ok) { 220 cookies.delete('access_token', { path: '/' }); 221 cookies.delete('refresh_token', { path: '/' }); 222 return new Response(JSON.stringify({ error: 'Refresh failed' }), { 223 status: 401, 224 headers: { 'Content-Type': 'application/json' }, 225 }); 226 } 227 228 const tokens = await tokenResponse.json(); 229 230 cookies.set('access_token', tokens.access_token, { 231 httpOnly: true, 232 secure: true, 233 sameSite: 'strict', 234 path: '/', 235 maxAge: tokens.expires_in, 236 }); 237 238 cookies.set('refresh_token', tokens.refresh_token, { 239 httpOnly: true, 240 secure: true, 241 sameSite: 'strict', 242 path: '/', 243 maxAge: 60 * 60 * 24 * 30, 244 }); 245 246 return new Response(JSON.stringify({ success: true }), { 247 status: 200, 248 headers: { 'Content-Type': 'application/json' }, 249 }); 250}; 251``` 252 253### Environment Variables 254 255```bash 256# .env 257QUICKSLICE_SERVER=https://your-quickslice-server.com 258QUICKSLICE_CLIENT_ID=your-client-id 259QUICKSLICE_REDIRECT_URI=https://your-app.com/api/auth/callback 260``` 261 262--- 263 264## Security Notes 265 266- **HTTPS required** in production (HTTP only allowed for localhost) 267- **httpOnly cookies** prevent XSS token theft 268- **State parameter** prevents CSRF attacks 269- **Short PKCE cookie lifetime** (10 min) limits exposure window 270- **Secure + SameSite cookies** provide additional protection 271 272## Related Resources 273 274- [RFC 7636 - PKCE](https://datatracker.ietf.org/doc/html/rfc7636) 275- [OAuth 2.0 Authorization Code Flow](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1)