This string has no description
Something went wrong. Try again.
pkce-backend-guide
· Created 8mo ago ·7.6 kB · 275 lines
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276# Quickslice OAuth PKCE Flow Guide (Server-Side)This guide covers implementing OAuth 2.0 with PKCE for server-side applications like Astro, Express, or other Node.js backends.## OverviewPKCE (Proof Key for Code Exchange) protects the authorization code exchange from interception attacks. Quickslice requires the `S256` method (SHA-256 hash).---## Astro Backend Integration### Project Structure```src/├── pages/│ └── api/│ └── auth/│ ├── login.ts # Initiates OAuth flow│ ├── callback.ts # Handles redirect│ └── refresh.ts # Token refresh└── lib/ └── pkce.ts # PKCE utilities```### PKCE Utilities (`src/lib/pkce.ts`)```typescriptimport { createHash, randomBytes } from 'crypto';export function generateCodeVerifier(): string { return randomBytes(32) .toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, '');}export function generateCodeChallenge(verifier: string): string { return createHash('sha256') .update(verifier) .digest('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, '');}export function generateState(): string { return randomBytes(16).toString('base64url');}```### Login Endpoint (`src/pages/api/auth/login.ts`)```typescriptimport type { APIRoute } from 'astro';import { generateCodeVerifier, generateCodeChallenge, generateState } from '../../../lib/pkce';const QUICKSLICE_SERVER = import.meta.env.QUICKSLICE_SERVER;const CLIENT_ID = import.meta.env.QUICKSLICE_CLIENT_ID;const REDIRECT_URI = import.meta.env.QUICKSLICE_REDIRECT_URI;export const GET: APIRoute = async ({ cookies, redirect }) => { const codeVerifier = generateCodeVerifier(); const codeChallenge = generateCodeChallenge(codeVerifier); const state = generateState(); // Store in secure httpOnly cookies cookies.set('pkce_verifier', codeVerifier, { httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 600, // 10 minutes }); cookies.set('oauth_state', state, { httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 600, }); const authUrl = new URL(`${QUICKSLICE_SERVER}/oauth/authorize`); authUrl.searchParams.set('client_id', CLIENT_ID); authUrl.searchParams.set('redirect_uri', REDIRECT_URI); authUrl.searchParams.set('response_type', 'code'); authUrl.searchParams.set('code_challenge', codeChallenge); authUrl.searchParams.set('code_challenge_method', 'S256'); authUrl.searchParams.set('state', state); authUrl.searchParams.set('scope', 'atproto'); return redirect(authUrl.toString(), 302);};```### Callback Endpoint (`src/pages/api/auth/callback.ts`)```typescriptimport type { APIRoute } from 'astro';const QUICKSLICE_SERVER = import.meta.env.QUICKSLICE_SERVER;const CLIENT_ID = import.meta.env.QUICKSLICE_CLIENT_ID;const REDIRECT_URI = import.meta.env.QUICKSLICE_REDIRECT_URI;export const GET: APIRoute = async ({ url, cookies, redirect }) => { const code = url.searchParams.get('code'); const state = url.searchParams.get('state'); const error = url.searchParams.get('error'); if (error) { const errorDescription = url.searchParams.get('error_description') || 'Unknown error'; return new Response(`Authentication failed: ${errorDescription}`, { status: 400 }); } if (!code || !state) { return new Response('Missing code or state', { status: 400 }); } // Validate state to prevent CSRF const savedState = cookies.get('oauth_state')?.value; if (state !== savedState) { return new Response('State mismatch', { status: 400 }); } // Get stored verifier const codeVerifier = cookies.get('pkce_verifier')?.value; if (!codeVerifier) { return new Response('Missing PKCE verifier', { status: 400 }); } // Exchange code for tokens const tokenResponse = await fetch(`${QUICKSLICE_SERVER}/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, code_verifier: codeVerifier, }), }); if (!tokenResponse.ok) { const error = await tokenResponse.text(); return new Response(`Token exchange failed: ${error}`, { status: 400 }); } const tokens = await tokenResponse.json(); // Clear PKCE cookies cookies.delete('pkce_verifier', { path: '/' }); cookies.delete('oauth_state', { path: '/' }); // Store tokens in secure httpOnly cookies cookies.set('access_token', tokens.access_token, { httpOnly: true, secure: true, sameSite: 'strict', path: '/', maxAge: tokens.expires_in, }); cookies.set('refresh_token', tokens.refresh_token, { httpOnly: true, secure: true, sameSite: 'strict', path: '/', maxAge: 60 * 60 * 24 * 30, // 30 days }); cookies.set('user_did', tokens.sub, { httpOnly: true, secure: true, sameSite: 'strict', path: '/', maxAge: 60 * 60 * 24 * 30, }); return redirect('/', 302);};```### Refresh Endpoint (`src/pages/api/auth/refresh.ts`)```typescriptimport type { APIRoute } from 'astro';const QUICKSLICE_SERVER = import.meta.env.QUICKSLICE_SERVER;const CLIENT_ID = import.meta.env.QUICKSLICE_CLIENT_ID;export const POST: APIRoute = async ({ cookies }) => { const refreshToken = cookies.get('refresh_token')?.value; if (!refreshToken) { return new Response(JSON.stringify({ error: 'No refresh token' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } const tokenResponse = await fetch(`${QUICKSLICE_SERVER}/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: CLIENT_ID, }), }); if (!tokenResponse.ok) { cookies.delete('access_token', { path: '/' }); cookies.delete('refresh_token', { path: '/' }); return new Response(JSON.stringify({ error: 'Refresh failed' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } const tokens = await tokenResponse.json(); cookies.set('access_token', tokens.access_token, { httpOnly: true, secure: true, sameSite: 'strict', path: '/', maxAge: tokens.expires_in, }); cookies.set('refresh_token', tokens.refresh_token, { httpOnly: true, secure: true, sameSite: 'strict', path: '/', maxAge: 60 * 60 * 24 * 30, }); return new Response(JSON.stringify({ success: true }), { status: 200, headers: { 'Content-Type': 'application/json' }, });};```### Environment Variables```bash# .envQUICKSLICE_SERVER=https://your-quickslice-server.comQUICKSLICE_CLIENT_ID=your-client-idQUICKSLICE_REDIRECT_URI=https://your-app.com/api/auth/callback```---## Security Notes- **HTTPS required** in production (HTTP only allowed for localhost)- **httpOnly cookies** prevent XSS token theft- **State parameter** prevents CSRF attacks- **Short PKCE cookie lifetime** (10 min) limits exposure window- **Secure + SameSite cookies** provide additional protection## Related Resources- [RFC 7636 - PKCE](https://datatracker.ietf.org/doc/html/rfc7636)- [OAuth 2.0 Authorization Code Flow](https://datatracker.ietf.org/doc/html/rfc6749#section-4.1)