@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 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
- `PDS_OAUTH_CLIENT_HOSTS` is required for OAuth clients that use dynamic
399
- client metadata. It is a comma-separated allowlist of hostnames that this
400
- single-user PDS may fetch for client metadata and JWKS documents.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alteran/astro",
3
- "version": "0.7.3",
3
+ "version": "0.7.6",
4
4
  "description": "Astro integration for running a Cloudflare-hosted Bluesky PDS with Alteran.",
5
5
  "module": "index.js",
6
6
  "types": "index.d.ts",
@@ -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?: string;
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
- export function isAllowedRedirectUri(uri: string): boolean {
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
- const response = await fetch(url, {
200
- signal: ctl.signal,
201
- redirect: 'error',
202
- headers: { accept: 'application/json' },
203
- });
204
- if (!response.ok) throw new Error(`${label} fetch failed: ${response.status}`);
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
- throw new Error(`${label} must be JSON`);
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: 'error',
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
- if (!redirect_uris.every((uri: unknown) => typeof uri === 'string' && isAllowedRedirectUri(uri))) {
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: typeof metadata.application_type === 'string' ? metadata.application_type : undefined,
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
- if (form.get('client_assertion') || form.get('client_assertion_type')) {
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
@@ -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
- fetchClientMetadata,
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
- const client_id = form.get('client_id') || '';
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
- return new Response(JSON.stringify({ error: 'invalid_client', error_description: 'client_id must be a safe https metadata URL' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
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
- return new Response(JSON.stringify({ error: 'invalid_request', error_description: 'state required' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
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
- return new Response(JSON.stringify({ error: 'invalid_client', error_description: errorMessage(e) ?? 'Client metadata fetch failed' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
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, // 5 minutes
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 { fetchClientMetadata, requireSameClientAuth } from '../../lib/oauth/clients';
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 requireSameClientAuth(env, client_id, issuer, clientMeta, form, {
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 requireSameClientAuth(env, client_id, issuer, clientMeta, form, {
48
+ await requireStoredClientAuthentication(env, client_id, issuer, form, {
50
49
  method: session.clientAuthMethod,
51
50
  keyId: session.clientAuthKeyId,
52
51
  });
@@ -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
- const clientMeta = await fetchClientMetadata(env, client_id).catch((error) => {
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
- const clientMeta = await fetchClientMetadata(env, client_id).catch((error) => {
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