@alteran/astro 0.7.3 → 0.7.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -4
- package/package.json +1 -1
- package/src/lib/oauth/clients.ts +114 -39
- package/src/lib/oauth/observability.ts +99 -0
- package/src/middleware.ts +10 -2
- package/src/pages/oauth/par.ts +61 -11
- package/src/pages/oauth/revoke.ts +3 -4
- package/src/pages/oauth/token.ts +7 -12
package/README.md
CHANGED
|
@@ -392,12 +392,11 @@ USER_PASSWORD=your-password
|
|
|
392
392
|
REFRESH_TOKEN=your-access-secret
|
|
393
393
|
REFRESH_TOKEN_SECRET=your-refresh-secret
|
|
394
394
|
PDS_SEQ_WINDOW=512
|
|
395
|
-
PDS_OAUTH_CLIENT_HOSTS=client.example,another-client.example
|
|
396
395
|
```
|
|
397
396
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
397
|
+
OAuth client metadata and JWKS documents are fetched dynamically from public
|
|
398
|
+
HTTPS URLs using hardened fetch checks: no redirects, public DNS only, size
|
|
399
|
+
limits, and timeouts.
|
|
401
400
|
|
|
402
401
|
### 3. Run Database Migration
|
|
403
402
|
```bash
|
package/package.json
CHANGED
package/src/lib/oauth/clients.ts
CHANGED
|
@@ -8,6 +8,7 @@ const MAX_CLIENT_JSON_BYTES = 128 * 1024;
|
|
|
8
8
|
const CLIENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
|
|
9
9
|
|
|
10
10
|
export type ClientAuthMethod = 'none' | 'private_key_jwt';
|
|
11
|
+
export type OAuthApplicationType = 'web' | 'native';
|
|
11
12
|
|
|
12
13
|
export type OAuthClientMetadata = {
|
|
13
14
|
client_id: string;
|
|
@@ -19,7 +20,7 @@ export type OAuthClientMetadata = {
|
|
|
19
20
|
dpop_bound_access_tokens: true;
|
|
20
21
|
jwks?: { keys: JsonWebKey[] };
|
|
21
22
|
jwks_uri?: string;
|
|
22
|
-
application_type
|
|
23
|
+
application_type: OAuthApplicationType;
|
|
23
24
|
};
|
|
24
25
|
|
|
25
26
|
export type VerifiedClientAuth = {
|
|
@@ -27,26 +28,6 @@ export type VerifiedClientAuth = {
|
|
|
27
28
|
keyId: string | null;
|
|
28
29
|
};
|
|
29
30
|
|
|
30
|
-
function configuredClientHosts(env: Env): Set<string> {
|
|
31
|
-
return new Set(
|
|
32
|
-
String(env.PDS_OAUTH_CLIENT_HOSTS || '')
|
|
33
|
-
.split(',')
|
|
34
|
-
.map((host) => host.trim().toLowerCase())
|
|
35
|
-
.filter(Boolean),
|
|
36
|
-
);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function assertClientHostAllowed(env: Env, url: URL, label: string): void {
|
|
40
|
-
const allowed = configuredClientHosts(env);
|
|
41
|
-
if (allowed.size === 0) {
|
|
42
|
-
throw new Error(`${label} host is not allowlisted`);
|
|
43
|
-
}
|
|
44
|
-
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
45
|
-
if (!allowed.has(host)) {
|
|
46
|
-
throw new Error(`${label} host is not allowlisted`);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
31
|
function isIpLiteral(hostname: string): boolean {
|
|
51
32
|
const host = hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
52
33
|
if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) return true;
|
|
@@ -153,16 +134,52 @@ function isLoopbackHostname(hostname: string): boolean {
|
|
|
153
134
|
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
|
154
135
|
}
|
|
155
136
|
|
|
156
|
-
|
|
137
|
+
function reverseDomain(hostname: string): string {
|
|
138
|
+
return hostname
|
|
139
|
+
.toLowerCase()
|
|
140
|
+
.split('.')
|
|
141
|
+
.filter(Boolean)
|
|
142
|
+
.reverse()
|
|
143
|
+
.join('.');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function isNativePrivateUseRedirect(url: URL, uri: string, clientId: string | undefined): boolean {
|
|
147
|
+
if (!clientId) return false;
|
|
148
|
+
let clientUrl: URL;
|
|
149
|
+
try {
|
|
150
|
+
clientUrl = new URL(clientId);
|
|
151
|
+
} catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
if (clientUrl.protocol !== 'https:') return false;
|
|
155
|
+
|
|
156
|
+
const scheme = url.protocol.slice(0, -1);
|
|
157
|
+
if (!scheme || scheme !== reverseDomain(clientUrl.hostname)) return false;
|
|
158
|
+
if (!uri.startsWith(`${scheme}:/`) || uri.startsWith(`${scheme}://`)) return false;
|
|
159
|
+
if (url.username || url.password || url.hash || url.host) return false;
|
|
160
|
+
return url.pathname.startsWith('/');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function isAllowedRedirectUri(
|
|
164
|
+
uri: string,
|
|
165
|
+
opts: { applicationType?: OAuthApplicationType; clientId?: string } = {},
|
|
166
|
+
): boolean {
|
|
157
167
|
try {
|
|
158
168
|
const url = new URL(uri);
|
|
159
169
|
if (url.username || url.password || url.hash) return false;
|
|
160
170
|
if (url.protocol === 'https:') {
|
|
171
|
+
if (opts.applicationType === 'native' && opts.clientId) {
|
|
172
|
+
const clientUrl = new URL(opts.clientId);
|
|
173
|
+
if (url.origin !== clientUrl.origin) return false;
|
|
174
|
+
}
|
|
161
175
|
return !isBlockedHost(url.hostname);
|
|
162
176
|
}
|
|
163
177
|
if (url.protocol === 'http:') {
|
|
164
178
|
return isLoopbackHostname(url.hostname);
|
|
165
179
|
}
|
|
180
|
+
if (opts.applicationType === 'native') {
|
|
181
|
+
return isNativePrivateUseRedirect(url, uri, opts.clientId);
|
|
182
|
+
}
|
|
166
183
|
return false;
|
|
167
184
|
} catch {
|
|
168
185
|
return false;
|
|
@@ -190,21 +207,51 @@ export async function safeFetchJson(env: Env, url: string, label: string): Promi
|
|
|
190
207
|
}
|
|
191
208
|
|
|
192
209
|
const parsed = new URL(url);
|
|
193
|
-
assertClientHostAllowed(env, parsed, label);
|
|
194
210
|
await assertHostnameResolvesPublic(parsed, label);
|
|
195
211
|
|
|
196
212
|
const ctl = new AbortController();
|
|
197
213
|
const t = setTimeout(() => ctl.abort(), 3000);
|
|
198
214
|
try {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
215
|
+
let response: Response;
|
|
216
|
+
try {
|
|
217
|
+
response = await fetch(url, {
|
|
218
|
+
signal: ctl.signal,
|
|
219
|
+
// Cloudflare Workers' fetch rejects redirect: 'error' at the edge.
|
|
220
|
+
// Use 'manual' and treat any 3xx as a refused redirect.
|
|
221
|
+
redirect: 'manual',
|
|
222
|
+
headers: { accept: 'application/json' },
|
|
223
|
+
});
|
|
224
|
+
} catch (e) {
|
|
225
|
+
const err = new Error(`${label} fetch error: ${e instanceof Error ? e.message : String(e)}`);
|
|
226
|
+
throw err;
|
|
227
|
+
}
|
|
228
|
+
if ((response.status >= 300 && response.status < 400) || response.type === 'opaqueredirect') {
|
|
229
|
+
const err = new Error(`${label} fetch failed: redirected (status ${response.status})`);
|
|
230
|
+
Object.assign(err, {
|
|
231
|
+
metadataStatus: response.status,
|
|
232
|
+
metadataContentType: response.headers.get('content-type'),
|
|
233
|
+
metadataRedirected: true,
|
|
234
|
+
});
|
|
235
|
+
throw err;
|
|
236
|
+
}
|
|
237
|
+
if (!response.ok) {
|
|
238
|
+
const err = new Error(`${label} fetch failed: ${response.status}`);
|
|
239
|
+
Object.assign(err, {
|
|
240
|
+
metadataStatus: response.status,
|
|
241
|
+
metadataContentType: response.headers.get('content-type'),
|
|
242
|
+
metadataRedirected: response.redirected,
|
|
243
|
+
});
|
|
244
|
+
throw err;
|
|
245
|
+
}
|
|
205
246
|
const ctype = response.headers.get('content-type') || '';
|
|
206
247
|
if (!ctype.includes('application/json') && !ctype.includes('json')) {
|
|
207
|
-
|
|
248
|
+
const err = new Error(`${label} must be JSON`);
|
|
249
|
+
Object.assign(err, {
|
|
250
|
+
metadataStatus: response.status,
|
|
251
|
+
metadataContentType: ctype,
|
|
252
|
+
metadataRedirected: response.redirected,
|
|
253
|
+
});
|
|
254
|
+
throw err;
|
|
208
255
|
}
|
|
209
256
|
const text = await readResponseTextBounded(response, label);
|
|
210
257
|
return JSON.parse(text || '{}');
|
|
@@ -260,7 +307,7 @@ async function resolveHostAddresses(hostname: string): Promise<string[]> {
|
|
|
260
307
|
url.searchParams.set('type', type);
|
|
261
308
|
const response = await fetch(url.toString(), {
|
|
262
309
|
headers: { accept: 'application/dns-json' },
|
|
263
|
-
redirect: '
|
|
310
|
+
redirect: 'manual',
|
|
264
311
|
});
|
|
265
312
|
if (!response.ok) continue;
|
|
266
313
|
const body = await response.json().catch(() => null) as any;
|
|
@@ -290,14 +337,21 @@ export function validateClientMetadataShape(metadata: any, clientId: string): OA
|
|
|
290
337
|
throw new Error('redirect_uris required');
|
|
291
338
|
}
|
|
292
339
|
const redirect_uris = metadata.redirect_uris;
|
|
293
|
-
|
|
340
|
+
const application_type = metadata.application_type ?? 'web';
|
|
341
|
+
if (application_type !== 'web' && application_type !== 'native') {
|
|
342
|
+
throw new Error('unsupported application_type');
|
|
343
|
+
}
|
|
344
|
+
if (!redirect_uris.every((uri: unknown) => (
|
|
345
|
+
typeof uri === 'string' &&
|
|
346
|
+
isAllowedRedirectUri(uri, { applicationType: application_type, clientId })
|
|
347
|
+
))) {
|
|
294
348
|
throw new Error('redirect_uris contains unsupported URI');
|
|
295
349
|
}
|
|
296
350
|
if (metadata.dpop_bound_access_tokens !== true) {
|
|
297
351
|
throw new Error('client must require DPoP');
|
|
298
352
|
}
|
|
299
353
|
|
|
300
|
-
const method = metadata.token_endpoint_auth_method;
|
|
354
|
+
const method = metadata.token_endpoint_auth_method ?? 'none';
|
|
301
355
|
if (method !== 'none' && method !== 'private_key_jwt') {
|
|
302
356
|
throw new Error('unsupported token_endpoint_auth_method');
|
|
303
357
|
}
|
|
@@ -337,7 +391,7 @@ export function validateClientMetadataShape(metadata: any, clientId: string): OA
|
|
|
337
391
|
dpop_bound_access_tokens: true,
|
|
338
392
|
jwks: metadata.jwks,
|
|
339
393
|
jwks_uri: metadata.jwks_uri,
|
|
340
|
-
application_type
|
|
394
|
+
application_type,
|
|
341
395
|
};
|
|
342
396
|
}
|
|
343
397
|
|
|
@@ -355,7 +409,10 @@ export function validateParRequest(metadata: OAuthClientMetadata, request: {
|
|
|
355
409
|
if (request.response_type !== 'code') {
|
|
356
410
|
throw new Error('unsupported response_type');
|
|
357
411
|
}
|
|
358
|
-
if (!request.redirect_uri || !isAllowedRedirectUri(request.redirect_uri
|
|
412
|
+
if (!request.redirect_uri || !isAllowedRedirectUri(request.redirect_uri, {
|
|
413
|
+
applicationType: metadata.application_type,
|
|
414
|
+
clientId: metadata.client_id,
|
|
415
|
+
})) {
|
|
359
416
|
throw new Error('unsupported redirect_uri');
|
|
360
417
|
}
|
|
361
418
|
if (!metadata.redirect_uris.some((uri) => redirectUriMatches(uri, request.redirect_uri))) {
|
|
@@ -402,10 +459,7 @@ export async function verifyClientAuthentication(
|
|
|
402
459
|
form: URLSearchParams,
|
|
403
460
|
): Promise<VerifiedClientAuth> {
|
|
404
461
|
if (metadata.token_endpoint_auth_method === 'none') {
|
|
405
|
-
|
|
406
|
-
throw new Error('public client must not send client_assertion');
|
|
407
|
-
}
|
|
408
|
-
return { method: 'none', keyId: null };
|
|
462
|
+
return verifyPublicClientAuthentication(form);
|
|
409
463
|
}
|
|
410
464
|
|
|
411
465
|
const client_assertion_type = form.get('client_assertion_type') || '';
|
|
@@ -421,6 +475,27 @@ export async function verifyClientAuthentication(
|
|
|
421
475
|
return { method: 'private_key_jwt', keyId: result.keyId };
|
|
422
476
|
}
|
|
423
477
|
|
|
478
|
+
export function verifyPublicClientAuthentication(form: URLSearchParams): VerifiedClientAuth {
|
|
479
|
+
if (form.get('client_assertion') || form.get('client_assertion_type')) {
|
|
480
|
+
throw new Error('public client must not send client_assertion');
|
|
481
|
+
}
|
|
482
|
+
return { method: 'none', keyId: null };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export async function requireStoredClientAuthentication(
|
|
486
|
+
env: Env,
|
|
487
|
+
clientId: string,
|
|
488
|
+
issuerOrigin: string,
|
|
489
|
+
form: URLSearchParams,
|
|
490
|
+
expected: { method: string | null; keyId?: string | null },
|
|
491
|
+
): Promise<VerifiedClientAuth> {
|
|
492
|
+
if (expected.method === 'none') {
|
|
493
|
+
return verifyPublicClientAuthentication(form);
|
|
494
|
+
}
|
|
495
|
+
const metadata = await fetchClientMetadata(env, clientId);
|
|
496
|
+
return requireSameClientAuth(env, clientId, issuerOrigin, metadata, form, expected);
|
|
497
|
+
}
|
|
498
|
+
|
|
424
499
|
async function consumeClientAssertionJti(env: Env, clientId: string, jti: string, exp: number): Promise<boolean> {
|
|
425
500
|
const key = `oauth:client-assertion:jti:${clientId}:${jti}`;
|
|
426
501
|
await cleanupExpiredOAuthReplaySecrets(env, Math.floor(Date.now() / 1000));
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { errorMessage } from '../errors';
|
|
2
|
+
|
|
3
|
+
export type OauthParStage =
|
|
4
|
+
| 'metadata_fetch'
|
|
5
|
+
| 'metadata_shape'
|
|
6
|
+
| 'par_validate'
|
|
7
|
+
| 'client_auth'
|
|
8
|
+
| 'dpop'
|
|
9
|
+
| 'outer'
|
|
10
|
+
| 'success';
|
|
11
|
+
|
|
12
|
+
export type OauthParFormSummary = {
|
|
13
|
+
redirectUri: string;
|
|
14
|
+
responseType: string;
|
|
15
|
+
grantType: string | null;
|
|
16
|
+
scope: string;
|
|
17
|
+
codeChallengeMethod: string;
|
|
18
|
+
hasState: boolean;
|
|
19
|
+
hasCodeChallenge: boolean;
|
|
20
|
+
hasClientAssertion: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type OauthParLogDetails = {
|
|
24
|
+
outcome: 'ok' | 'error';
|
|
25
|
+
requestId?: string | null;
|
|
26
|
+
error?: unknown;
|
|
27
|
+
clientId?: string | null;
|
|
28
|
+
form?: OauthParFormSummary | null;
|
|
29
|
+
metadataStatus?: number | null;
|
|
30
|
+
metadataContentType?: string | null;
|
|
31
|
+
metadataRedirected?: boolean | null;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Read context off an Error attached by safeFetchJson when the metadata HTTP
|
|
35
|
+
// roundtrip itself failed; absent for shape/validation errors that never made
|
|
36
|
+
// a request.
|
|
37
|
+
export type FetchAugmentedError = Error & {
|
|
38
|
+
metadataStatus?: number;
|
|
39
|
+
metadataContentType?: string | null;
|
|
40
|
+
metadataRedirected?: boolean;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function readFetchContext(error: unknown): {
|
|
44
|
+
metadataStatus: number | null;
|
|
45
|
+
metadataContentType: string | null;
|
|
46
|
+
metadataRedirected: boolean | null;
|
|
47
|
+
} {
|
|
48
|
+
if (!(error instanceof Error)) {
|
|
49
|
+
return { metadataStatus: null, metadataContentType: null, metadataRedirected: null };
|
|
50
|
+
}
|
|
51
|
+
const augmented = error as FetchAugmentedError;
|
|
52
|
+
return {
|
|
53
|
+
metadataStatus: typeof augmented.metadataStatus === 'number' ? augmented.metadataStatus : null,
|
|
54
|
+
metadataContentType: augmented.metadataContentType ?? null,
|
|
55
|
+
metadataRedirected: typeof augmented.metadataRedirected === 'boolean' ? augmented.metadataRedirected : null,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function summarizeParForm(form: URLSearchParams): OauthParFormSummary {
|
|
60
|
+
return {
|
|
61
|
+
redirectUri: form.get('redirect_uri') ?? '',
|
|
62
|
+
responseType: form.get('response_type') ?? '',
|
|
63
|
+
grantType: form.get('grant_type'),
|
|
64
|
+
scope: form.get('scope') ?? '',
|
|
65
|
+
codeChallengeMethod: form.get('code_challenge_method') ?? '',
|
|
66
|
+
hasState: !!form.get('state'),
|
|
67
|
+
hasCodeChallenge: !!form.get('code_challenge'),
|
|
68
|
+
hasClientAssertion: !!form.get('client_assertion'),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function logOauthPar(
|
|
73
|
+
stage: OauthParStage,
|
|
74
|
+
request: Request,
|
|
75
|
+
details: OauthParLogDetails,
|
|
76
|
+
): void {
|
|
77
|
+
const url = new URL(request.url);
|
|
78
|
+
const record = {
|
|
79
|
+
level: details.outcome === 'ok' ? 'info' : 'error',
|
|
80
|
+
type: 'oauth_par',
|
|
81
|
+
stage,
|
|
82
|
+
outcome: details.outcome,
|
|
83
|
+
requestId: details.requestId ?? null,
|
|
84
|
+
method: request.method,
|
|
85
|
+
path: url.pathname,
|
|
86
|
+
timestamp: new Date().toISOString(),
|
|
87
|
+
clientId: details.clientId ?? null,
|
|
88
|
+
errorMessage: details.error !== undefined ? errorMessage(details.error) : null,
|
|
89
|
+
form: details.form ?? null,
|
|
90
|
+
metadataStatus: details.metadataStatus ?? null,
|
|
91
|
+
metadataContentType: details.metadataContentType ?? null,
|
|
92
|
+
metadataRedirected: details.metadataRedirected ?? null,
|
|
93
|
+
};
|
|
94
|
+
if (details.outcome === 'ok') {
|
|
95
|
+
console.log(JSON.stringify(record));
|
|
96
|
+
} else {
|
|
97
|
+
console.error(JSON.stringify(record));
|
|
98
|
+
}
|
|
99
|
+
}
|
package/src/middleware.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { defineMiddleware, sequence } from 'astro/middleware';
|
|
2
2
|
|
|
3
|
+
// Response.redirect() (and a few other constructors) returns a Response whose
|
|
4
|
+
// headers are immutable. Re-wrap into a fresh Response so downstream middleware
|
|
5
|
+
// can attach CORS / X-Request-ID without throwing "Can't modify immutable
|
|
6
|
+
// headers" at the Workers runtime.
|
|
7
|
+
function ensureMutableResponse(response: Response): Response {
|
|
8
|
+
return new Response(response.body, response);
|
|
9
|
+
}
|
|
10
|
+
|
|
3
11
|
const cors = defineMiddleware(async ({ locals, request }, next) => {
|
|
4
12
|
// Match atproto CORS implementation: use wildcard for public endpoints
|
|
5
13
|
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin
|
|
@@ -20,7 +28,7 @@ const cors = defineMiddleware(async ({ locals, request }, next) => {
|
|
|
20
28
|
return new Response(null, { status: 204, headers });
|
|
21
29
|
}
|
|
22
30
|
|
|
23
|
-
const response = await next();
|
|
31
|
+
const response = ensureMutableResponse(await next());
|
|
24
32
|
|
|
25
33
|
// Set CORS headers on all responses (atproto standard)
|
|
26
34
|
response.headers.set('Access-Control-Allow-Origin', '*');
|
|
@@ -43,7 +51,7 @@ const logger = defineMiddleware(async ({ request, locals }, next) => {
|
|
|
43
51
|
const url = new URL(request.url);
|
|
44
52
|
|
|
45
53
|
try {
|
|
46
|
-
const response = await next();
|
|
54
|
+
const response = ensureMutableResponse(await next());
|
|
47
55
|
const dur = Date.now() - start;
|
|
48
56
|
|
|
49
57
|
// Structured logging
|
package/src/pages/oauth/par.ts
CHANGED
|
@@ -5,25 +5,51 @@ import { DpopNonceError } from '../../lib/oauth/dpop-errors';
|
|
|
5
5
|
import { publicPdsOrigin } from '../../lib/oauth/consent';
|
|
6
6
|
import { savePar } from '../../lib/oauth/store';
|
|
7
7
|
import {
|
|
8
|
-
|
|
8
|
+
safeFetchJson,
|
|
9
9
|
isSafeFetchUrl,
|
|
10
|
+
validateClientMetadataShape,
|
|
10
11
|
validateParRequest,
|
|
11
12
|
verifyClientAuthentication,
|
|
13
|
+
type OAuthClientMetadata,
|
|
12
14
|
} from '../../lib/oauth/clients';
|
|
15
|
+
import {
|
|
16
|
+
logOauthPar,
|
|
17
|
+
readFetchContext,
|
|
18
|
+
summarizeParForm,
|
|
19
|
+
type OauthParFormSummary,
|
|
20
|
+
type OauthParStage,
|
|
21
|
+
} from '../../lib/oauth/observability';
|
|
13
22
|
|
|
14
23
|
export const prerender = false;
|
|
15
24
|
|
|
16
25
|
export async function POST({ locals, request }: APIContext) {
|
|
17
26
|
const { env } = locals.runtime;
|
|
27
|
+
const requestId = (locals as { requestId?: string }).requestId ?? null;
|
|
28
|
+
|
|
29
|
+
let client_id = '';
|
|
30
|
+
let formSummary: OauthParFormSummary | null = null;
|
|
31
|
+
|
|
32
|
+
const log = (
|
|
33
|
+
stage: OauthParStage,
|
|
34
|
+
extra: { error?: unknown; outcome?: 'ok' | 'error'; metadataStatus?: number | null; metadataContentType?: string | null; metadataRedirected?: boolean | null } = {},
|
|
35
|
+
) =>
|
|
36
|
+
logOauthPar(stage, request, {
|
|
37
|
+
outcome: extra.outcome ?? (extra.error !== undefined ? 'error' : 'ok'),
|
|
38
|
+
requestId,
|
|
39
|
+
error: extra.error,
|
|
40
|
+
clientId: client_id || null,
|
|
41
|
+
form: formSummary,
|
|
42
|
+
metadataStatus: extra.metadataStatus,
|
|
43
|
+
metadataContentType: extra.metadataContentType,
|
|
44
|
+
metadataRedirected: extra.metadataRedirected,
|
|
45
|
+
});
|
|
18
46
|
|
|
19
|
-
// Enforce DPoP binding, but do not require a nonce on the initial PAR request.
|
|
20
47
|
try {
|
|
21
48
|
const ver = await verifyDpop(env, request, { consumeJti: false, requireNonce: false });
|
|
22
49
|
|
|
23
|
-
// Parse form-encoded body
|
|
24
50
|
const bodyText = await request.text();
|
|
25
51
|
const form = new URLSearchParams(bodyText);
|
|
26
|
-
|
|
52
|
+
client_id = form.get('client_id') || '';
|
|
27
53
|
const response_type = form.get('response_type') || '';
|
|
28
54
|
const grant_type = form.get('grant_type') || undefined;
|
|
29
55
|
const redirect_uri = form.get('redirect_uri') || '';
|
|
@@ -33,18 +59,37 @@ export async function POST({ locals, request }: APIContext) {
|
|
|
33
59
|
const code_challenge_method = form.get('code_challenge_method') || '';
|
|
34
60
|
const login_hint = form.get('login_hint') || undefined;
|
|
35
61
|
const prompt = form.get('prompt') || undefined;
|
|
62
|
+
formSummary = summarizeParForm(form);
|
|
36
63
|
|
|
37
64
|
if (!client_id || !isSafeFetchUrl(client_id)) {
|
|
38
|
-
|
|
65
|
+
const error = new Error('client_id must be a safe https metadata URL');
|
|
66
|
+
log('metadata_fetch', { error });
|
|
67
|
+
return new Response(JSON.stringify({ error: 'invalid_client', error_description: error.message }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
|
39
68
|
}
|
|
40
69
|
if (!state) {
|
|
41
|
-
|
|
70
|
+
const error = new Error('state required');
|
|
71
|
+
log('par_validate', { error });
|
|
72
|
+
return new Response(JSON.stringify({ error: 'invalid_request', error_description: error.message }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let rawMetadata: unknown;
|
|
76
|
+
try {
|
|
77
|
+
rawMetadata = await safeFetchJson(env, client_id, 'client metadata');
|
|
78
|
+
} catch (e) {
|
|
79
|
+
const ctx = readFetchContext(e);
|
|
80
|
+
log('metadata_fetch', { error: e, ...ctx });
|
|
81
|
+
return new Response(JSON.stringify({ error: 'invalid_client', error_description: errorMessage(e) ?? 'Client metadata fetch failed' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let clientMeta: OAuthClientMetadata;
|
|
85
|
+
try {
|
|
86
|
+
clientMeta = validateClientMetadataShape(rawMetadata, client_id);
|
|
87
|
+
} catch (e) {
|
|
88
|
+
log('metadata_shape', { error: e });
|
|
89
|
+
return new Response(JSON.stringify({ error: 'invalid_client', error_description: errorMessage(e) ?? 'Client metadata invalid' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
|
42
90
|
}
|
|
43
91
|
|
|
44
|
-
// Fetch and validate client metadata
|
|
45
|
-
let clientMeta;
|
|
46
92
|
try {
|
|
47
|
-
clientMeta = await fetchClientMetadata(env, client_id);
|
|
48
93
|
validateParRequest(clientMeta, {
|
|
49
94
|
response_type,
|
|
50
95
|
grant_type,
|
|
@@ -54,7 +99,8 @@ export async function POST({ locals, request }: APIContext) {
|
|
|
54
99
|
code_challenge_method,
|
|
55
100
|
});
|
|
56
101
|
} catch (e) {
|
|
57
|
-
|
|
102
|
+
log('par_validate', { error: e });
|
|
103
|
+
return new Response(JSON.stringify({ error: 'invalid_client', error_description: errorMessage(e) ?? 'PAR request invalid' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
|
|
58
104
|
}
|
|
59
105
|
|
|
60
106
|
const issuerOrigin = publicPdsOrigin(env, request);
|
|
@@ -62,6 +108,7 @@ export async function POST({ locals, request }: APIContext) {
|
|
|
62
108
|
try {
|
|
63
109
|
clientAuth = await verifyClientAuthentication(env, client_id, issuerOrigin, clientMeta, form);
|
|
64
110
|
} catch (e) {
|
|
111
|
+
log('client_auth', { error: e });
|
|
65
112
|
return new Response(JSON.stringify({ error: 'invalid_client', error_description: errorMessage(e) ?? 'Client authentication failed' }), { status: 401, headers: { 'Content-Type': 'application/json' } });
|
|
66
113
|
}
|
|
67
114
|
|
|
@@ -80,7 +127,7 @@ export async function POST({ locals, request }: APIContext) {
|
|
|
80
127
|
clientAuthMethod: clientAuth.method,
|
|
81
128
|
clientAuthKeyId: clientAuth.keyId,
|
|
82
129
|
createdAt: now,
|
|
83
|
-
expiresAt: now + 300,
|
|
130
|
+
expiresAt: now + 300,
|
|
84
131
|
};
|
|
85
132
|
await consumeDpopVerificationJti(env, ver);
|
|
86
133
|
await savePar(env, id, rec);
|
|
@@ -88,11 +135,14 @@ export async function POST({ locals, request }: APIContext) {
|
|
|
88
135
|
const request_uri = `urn:ietf:params:oauth:request_uri:${id}`;
|
|
89
136
|
const headers = new Headers({ 'Content-Type': 'application/json' });
|
|
90
137
|
setDpopNonceHeader(headers, await getAuthzNonce(env));
|
|
138
|
+
log('success', { outcome: 'ok' });
|
|
91
139
|
return new Response(JSON.stringify({ request_uri, expires_in: 300 }), { status: 201, headers });
|
|
92
140
|
} catch (e) {
|
|
93
141
|
if (e instanceof DpopNonceError) {
|
|
142
|
+
log('dpop', { error: e });
|
|
94
143
|
return dpopErrorResponse(env, e);
|
|
95
144
|
}
|
|
145
|
+
log('outer', { error: e });
|
|
96
146
|
const headers = new Headers({ 'Content-Type': 'application/json' });
|
|
97
147
|
return new Response(JSON.stringify({ error: 'invalid_request', error_description: errorMessage(e) ?? 'Unknown error' }), { status: 400, headers });
|
|
98
148
|
}
|
|
@@ -4,7 +4,7 @@ import { consumeDpopVerificationJti, verifyDpop, dpopErrorResponse } from '../..
|
|
|
4
4
|
import { DpopNonceError } from '../../lib/oauth/dpop-errors';
|
|
5
5
|
import { publicPdsOrigin } from '../../lib/oauth/consent';
|
|
6
6
|
import { verifyAccessToken, verifyRefreshToken } from '../../lib/session-tokens';
|
|
7
|
-
import {
|
|
7
|
+
import { requireStoredClientAuthentication } from '../../lib/oauth/clients';
|
|
8
8
|
import { getOAuthSession, getRefreshToken, revokeOAuthSession, revokeRefreshToken } from '../../db/account';
|
|
9
9
|
|
|
10
10
|
export const prerender = false;
|
|
@@ -21,7 +21,6 @@ export async function POST({ locals, request }: APIContext) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
const issuer = publicPdsOrigin(env, request);
|
|
24
|
-
const clientMeta = await fetchClientMetadata(env, client_id);
|
|
25
24
|
|
|
26
25
|
const refresh = await verifyRefreshToken(env, token, { ignoreExpiration: true }).catch(() => null);
|
|
27
26
|
if (refresh?.decoded?.jti) {
|
|
@@ -29,7 +28,7 @@ export async function POST({ locals, request }: APIContext) {
|
|
|
29
28
|
if (stored?.tokenKind === 'oauth' && stored.oauthSessionId) {
|
|
30
29
|
const session = await getOAuthSession(env, stored.oauthSessionId);
|
|
31
30
|
if (session && session.clientId === client_id && session.dpopJkt === dpop.jkt) {
|
|
32
|
-
await
|
|
31
|
+
await requireStoredClientAuthentication(env, client_id, issuer, form, {
|
|
33
32
|
method: session.clientAuthMethod,
|
|
34
33
|
keyId: session.clientAuthKeyId,
|
|
35
34
|
});
|
|
@@ -46,7 +45,7 @@ export async function POST({ locals, request }: APIContext) {
|
|
|
46
45
|
if (typeof sessionId === 'string') {
|
|
47
46
|
const session = await getOAuthSession(env, sessionId);
|
|
48
47
|
if (session && session.clientId === client_id && session.dpopJkt === dpop.jkt) {
|
|
49
|
-
await
|
|
48
|
+
await requireStoredClientAuthentication(env, client_id, issuer, form, {
|
|
50
49
|
method: session.clientAuthMethod,
|
|
51
50
|
keyId: session.clientAuthKeyId,
|
|
52
51
|
});
|
package/src/pages/oauth/token.ts
CHANGED
|
@@ -5,10 +5,7 @@ import { DpopNonceError } from '../../lib/oauth/dpop-errors';
|
|
|
5
5
|
import { publicPdsOrigin } from '../../lib/oauth/consent';
|
|
6
6
|
import { consumeCode } from '../../lib/oauth/store';
|
|
7
7
|
import { issueSessionTokens, verifyRefreshToken, verifyAccessToken } from '../../lib/session-tokens';
|
|
8
|
-
import {
|
|
9
|
-
fetchClientMetadata,
|
|
10
|
-
requireSameClientAuth,
|
|
11
|
-
} from '../../lib/oauth/clients';
|
|
8
|
+
import { requireStoredClientAuthentication } from '../../lib/oauth/clients';
|
|
12
9
|
import {
|
|
13
10
|
createOAuthSession,
|
|
14
11
|
getOAuthSession,
|
|
@@ -48,12 +45,11 @@ export async function POST({ locals, request }: APIContext) {
|
|
|
48
45
|
if (expected !== rec.code_challenge) return jsonError('invalid_grant', 'PKCE verification failed');
|
|
49
46
|
if (ver.jkt !== rec.dpopJkt) return jsonError('invalid_dpop', 'DPoP key mismatch');
|
|
50
47
|
|
|
51
|
-
|
|
52
|
-
throw new Error(`Client metadata fetch failed: ${errorMessage(error) ?? error}`);
|
|
53
|
-
});
|
|
54
|
-
await requireSameClientAuth(env, client_id, issuer, clientMeta, form, {
|
|
48
|
+
await requireStoredClientAuthentication(env, client_id, issuer, form, {
|
|
55
49
|
method: rec.clientAuthMethod,
|
|
56
50
|
keyId: rec.clientAuthKeyId ?? null,
|
|
51
|
+
}).catch((error) => {
|
|
52
|
+
throw new Error(`Client authentication failed: ${errorMessage(error) ?? error}`);
|
|
57
53
|
});
|
|
58
54
|
await consumeDpopVerificationJti(env, ver);
|
|
59
55
|
|
|
@@ -138,12 +134,11 @@ export async function POST({ locals, request }: APIContext) {
|
|
|
138
134
|
if (client_id !== session.clientId) return jsonError('invalid_grant', 'client_id mismatch');
|
|
139
135
|
if (ver.jkt !== session.dpopJkt) return jsonError('invalid_dpop', 'DPoP key mismatch');
|
|
140
136
|
|
|
141
|
-
|
|
142
|
-
throw new Error(`Client metadata fetch failed: ${errorMessage(error) ?? error}`);
|
|
143
|
-
});
|
|
144
|
-
await requireSameClientAuth(env, client_id, issuer, clientMeta, form, {
|
|
137
|
+
await requireStoredClientAuthentication(env, client_id, issuer, form, {
|
|
145
138
|
method: session.clientAuthMethod,
|
|
146
139
|
keyId: session.clientAuthKeyId,
|
|
140
|
+
}).catch((error) => {
|
|
141
|
+
throw new Error(`Client authentication failed: ${errorMessage(error) ?? error}`);
|
|
147
142
|
});
|
|
148
143
|
await consumeDpopVerificationJti(env, ver);
|
|
149
144
|
|