this string has no description
pkce-backend-guide
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)