@alteran/astro 0.7.5 → 0.7.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alteran/astro",
3
- "version": "0.7.5",
3
+ "version": "0.7.7",
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",
@@ -212,15 +212,46 @@ export async function safeFetchJson(env: Env, url: string, label: string): Promi
212
212
  const ctl = new AbortController();
213
213
  const t = setTimeout(() => ctl.abort(), 3000);
214
214
  try {
215
- const response = await fetch(url, {
216
- signal: ctl.signal,
217
- redirect: 'error',
218
- headers: { accept: 'application/json' },
219
- });
220
- 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
+ }
221
246
  const ctype = response.headers.get('content-type') || '';
222
247
  if (!ctype.includes('application/json') && !ctype.includes('json')) {
223
- 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;
224
255
  }
225
256
  const text = await readResponseTextBounded(response, label);
226
257
  return JSON.parse(text || '{}');
@@ -276,7 +307,7 @@ async function resolveHostAddresses(hostname: string): Promise<string[]> {
276
307
  url.searchParams.set('type', type);
277
308
  const response = await fetch(url.toString(), {
278
309
  headers: { accept: 'application/dns-json' },
279
- redirect: 'error',
310
+ redirect: 'manual',
280
311
  });
281
312
  if (!response.ok) continue;
282
313
  const body = await response.json().catch(() => null) as any;
@@ -0,0 +1,140 @@
1
+ import { errorMessage } from '../errors';
2
+
3
+ export type OauthEndpoint = 'par' | 'authorize' | 'consent' | 'token' | 'revoke';
4
+
5
+ export type OauthParStage =
6
+ | 'metadata_fetch'
7
+ | 'metadata_shape'
8
+ | 'par_validate'
9
+ | 'client_auth'
10
+ | 'dpop'
11
+ | 'outer'
12
+ | 'success';
13
+
14
+ export type OauthTokenStage =
15
+ | 'dpop'
16
+ | 'parse'
17
+ | 'unsupported_grant'
18
+ | 'auth_code'
19
+ | 'refresh_token'
20
+ | 'client_auth'
21
+ | 'session_issue'
22
+ | 'outer'
23
+ | 'success';
24
+
25
+ export type OauthParFormSummary = {
26
+ redirectUri: string;
27
+ responseType: string;
28
+ grantType: string | null;
29
+ scope: string;
30
+ codeChallengeMethod: string;
31
+ hasState: boolean;
32
+ hasCodeChallenge: boolean;
33
+ hasClientAssertion: boolean;
34
+ };
35
+
36
+ export type OauthTokenFormSummary = {
37
+ grantType: string;
38
+ clientId: string;
39
+ redirectUri: string;
40
+ hasCode: boolean;
41
+ hasCodeVerifier: boolean;
42
+ hasRefreshToken: boolean;
43
+ hasClientAssertion: boolean;
44
+ clientAssertionType: string | null;
45
+ };
46
+
47
+ export type OauthLogDetails = {
48
+ endpoint: OauthEndpoint;
49
+ stage: string;
50
+ outcome: 'ok' | 'error';
51
+ requestId?: string | null;
52
+ error?: unknown;
53
+ clientId?: string | null;
54
+ form?: Record<string, unknown> | null;
55
+ metadataStatus?: number | null;
56
+ metadataContentType?: string | null;
57
+ metadataRedirected?: boolean | null;
58
+ };
59
+
60
+ export type FetchAugmentedError = Error & {
61
+ metadataStatus?: number;
62
+ metadataContentType?: string | null;
63
+ metadataRedirected?: boolean;
64
+ };
65
+
66
+ export function readFetchContext(error: unknown): {
67
+ metadataStatus: number | null;
68
+ metadataContentType: string | null;
69
+ metadataRedirected: boolean | null;
70
+ } {
71
+ if (!(error instanceof Error)) {
72
+ return { metadataStatus: null, metadataContentType: null, metadataRedirected: null };
73
+ }
74
+ const augmented = error as FetchAugmentedError;
75
+ return {
76
+ metadataStatus: typeof augmented.metadataStatus === 'number' ? augmented.metadataStatus : null,
77
+ metadataContentType: augmented.metadataContentType ?? null,
78
+ metadataRedirected: typeof augmented.metadataRedirected === 'boolean' ? augmented.metadataRedirected : null,
79
+ };
80
+ }
81
+
82
+ export function summarizeParForm(form: URLSearchParams): OauthParFormSummary {
83
+ return {
84
+ redirectUri: form.get('redirect_uri') ?? '',
85
+ responseType: form.get('response_type') ?? '',
86
+ grantType: form.get('grant_type'),
87
+ scope: form.get('scope') ?? '',
88
+ codeChallengeMethod: form.get('code_challenge_method') ?? '',
89
+ hasState: !!form.get('state'),
90
+ hasCodeChallenge: !!form.get('code_challenge'),
91
+ hasClientAssertion: !!form.get('client_assertion'),
92
+ };
93
+ }
94
+
95
+ export function summarizeTokenForm(form: URLSearchParams): OauthTokenFormSummary {
96
+ return {
97
+ grantType: form.get('grant_type') ?? '',
98
+ clientId: form.get('client_id') ?? '',
99
+ redirectUri: form.get('redirect_uri') ?? '',
100
+ hasCode: !!form.get('code'),
101
+ hasCodeVerifier: !!form.get('code_verifier'),
102
+ hasRefreshToken: !!form.get('refresh_token'),
103
+ hasClientAssertion: !!form.get('client_assertion'),
104
+ clientAssertionType: form.get('client_assertion_type'),
105
+ };
106
+ }
107
+
108
+ export function logOauth(request: Request, details: OauthLogDetails): void {
109
+ const url = new URL(request.url);
110
+ const record = {
111
+ level: details.outcome === 'ok' ? 'info' : 'error',
112
+ type: `oauth_${details.endpoint}`,
113
+ stage: details.stage,
114
+ outcome: details.outcome,
115
+ requestId: details.requestId ?? null,
116
+ method: request.method,
117
+ path: url.pathname,
118
+ timestamp: new Date().toISOString(),
119
+ clientId: details.clientId ?? null,
120
+ errorMessage: details.error !== undefined ? errorMessage(details.error) : null,
121
+ form: details.form ?? null,
122
+ metadataStatus: details.metadataStatus ?? null,
123
+ metadataContentType: details.metadataContentType ?? null,
124
+ metadataRedirected: details.metadataRedirected ?? null,
125
+ };
126
+ if (details.outcome === 'ok') {
127
+ console.log(JSON.stringify(record));
128
+ } else {
129
+ console.error(JSON.stringify(record));
130
+ }
131
+ }
132
+
133
+ // Backward-compatible alias for PAR call sites.
134
+ export function logOauthPar(
135
+ stage: OauthParStage,
136
+ request: Request,
137
+ details: Omit<OauthLogDetails, 'endpoint' | 'stage'>,
138
+ ): void {
139
+ logOauth(request, { ...details, endpoint: 'par', stage });
140
+ }
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
  }
@@ -15,15 +15,46 @@ import {
15
15
  storeRefreshToken,
16
16
  updateOAuthSessionCurrent,
17
17
  } from '../../db/account';
18
+ import {
19
+ logOauth,
20
+ summarizeTokenForm,
21
+ type OauthTokenFormSummary,
22
+ type OauthTokenStage,
23
+ } from '../../lib/oauth/observability';
18
24
 
19
25
  export const prerender = false;
20
26
 
21
27
  export async function POST({ locals, request }: APIContext) {
22
28
  const { env } = locals.runtime;
29
+ const requestId = (locals as { requestId?: string }).requestId ?? null;
30
+
31
+ let formSummary: OauthTokenFormSummary | null = null;
32
+ let clientId: string | null = null;
33
+
34
+ const log = (
35
+ stage: OauthTokenStage,
36
+ extra: { error?: unknown; outcome?: 'ok' | 'error' } = {},
37
+ ) =>
38
+ logOauth(request, {
39
+ endpoint: 'token',
40
+ stage,
41
+ outcome: extra.outcome ?? (extra.error !== undefined ? 'error' : 'ok'),
42
+ requestId,
43
+ error: extra.error,
44
+ clientId,
45
+ form: formSummary as Record<string, unknown> | null,
46
+ });
47
+
48
+ const fail = (stage: OauthTokenStage, code: string, desc: string): Response => {
49
+ log(stage, { error: new Error(desc) });
50
+ return jsonError(code, desc);
51
+ };
23
52
 
24
53
  try {
25
54
  const ver = await verifyDpop(env, request, { consumeJti: false, requireNonce: false });
26
55
  const form = new URLSearchParams(await request.text());
56
+ formSummary = summarizeTokenForm(form);
57
+ clientId = form.get('client_id') || null;
27
58
  const grant_type = form.get('grant_type') || '';
28
59
  const issuer = publicPdsOrigin(env, request);
29
60
 
@@ -33,24 +64,28 @@ export async function POST({ locals, request }: APIContext) {
33
64
  const redirect_uri = form.get('redirect_uri') || '';
34
65
  const code_verifier = form.get('code_verifier') || '';
35
66
  if (!code || !client_id || !redirect_uri || !code_verifier) {
36
- return jsonError('invalid_request', 'Missing parameters');
67
+ return fail('auth_code', 'invalid_request', 'Missing parameters');
37
68
  }
38
69
 
39
70
  const rec = await consumeCode(env, code);
40
- if (!rec) return jsonError('invalid_grant', 'Invalid or used code');
41
- if (rec.client_id !== client_id) return jsonError('invalid_grant', 'client_id mismatch');
42
- if (rec.redirect_uri !== redirect_uri) return jsonError('invalid_grant', 'redirect_uri mismatch');
71
+ if (!rec) return fail('auth_code', 'invalid_grant', 'Invalid or used code');
72
+ if (rec.client_id !== client_id) return fail('auth_code', 'invalid_grant', 'client_id mismatch');
73
+ if (rec.redirect_uri !== redirect_uri) return fail('auth_code', 'invalid_grant', 'redirect_uri mismatch');
43
74
 
44
75
  const expected = await sha256b64url(code_verifier);
45
- if (expected !== rec.code_challenge) return jsonError('invalid_grant', 'PKCE verification failed');
46
- if (ver.jkt !== rec.dpopJkt) return jsonError('invalid_dpop', 'DPoP key mismatch');
47
-
48
- await requireStoredClientAuthentication(env, client_id, issuer, form, {
49
- method: rec.clientAuthMethod,
50
- keyId: rec.clientAuthKeyId ?? null,
51
- }).catch((error) => {
52
- throw new Error(`Client authentication failed: ${errorMessage(error) ?? error}`);
53
- });
76
+ if (expected !== rec.code_challenge) return fail('auth_code', 'invalid_grant', 'PKCE verification failed');
77
+ if (ver.jkt !== rec.dpopJkt) return fail('auth_code', 'invalid_dpop', 'DPoP key mismatch');
78
+
79
+ try {
80
+ await requireStoredClientAuthentication(env, client_id, issuer, form, {
81
+ method: rec.clientAuthMethod,
82
+ keyId: rec.clientAuthKeyId ?? null,
83
+ });
84
+ } catch (error) {
85
+ const wrapped = new Error(`Client authentication failed: ${errorMessage(error) ?? error}`);
86
+ log('client_auth', { error: wrapped });
87
+ throw wrapped;
88
+ }
54
89
  await consumeDpopVerificationJti(env, ver);
55
90
 
56
91
  const sessionId = crypto.randomUUID().replace(/-/g, '');
@@ -91,6 +126,7 @@ export async function POST({ locals, request }: APIContext) {
91
126
  });
92
127
 
93
128
  const expires_in = accessExpiresIn(accessPayload);
129
+ log('success', { outcome: 'ok' });
94
130
  return tokenResponse({
95
131
  access_token: accessJwt,
96
132
  token_type: 'DPoP',
@@ -105,41 +141,45 @@ export async function POST({ locals, request }: APIContext) {
105
141
  const refresh_token = form.get('refresh_token') || '';
106
142
  const client_id = form.get('client_id') || '';
107
143
  if (!refresh_token || !client_id) {
108
- return jsonError('invalid_request', 'Missing refresh_token or client_id');
144
+ return fail('refresh_token', 'invalid_request', 'Missing refresh_token or client_id');
109
145
  }
110
146
 
111
147
  const verification = await verifyRefreshToken(env, refresh_token).catch(() => null);
112
- if (!verification || !verification.decoded) return jsonError('invalid_grant', 'Invalid refresh token');
148
+ if (!verification || !verification.decoded) return fail('refresh_token', 'invalid_grant', 'Invalid refresh token');
113
149
  const nowSec = Math.floor(Date.now() / 1000);
114
- if (verification.decoded.exp <= nowSec) return jsonError('invalid_grant', 'Expired refresh token');
150
+ if (verification.decoded.exp <= nowSec) return fail('refresh_token', 'invalid_grant', 'Expired refresh token');
115
151
 
116
152
  const stored = await getRefreshToken(env, verification.decoded.jti);
117
153
  if (!stored || stored.tokenKind !== 'oauth' || !stored.oauthSessionId) {
118
- return jsonError('invalid_grant', 'Refresh token revoked');
154
+ return fail('refresh_token', 'invalid_grant', 'Refresh token revoked');
119
155
  }
120
156
  const session = await getOAuthSession(env, stored.oauthSessionId);
121
157
  if (!session || session.revokedAt || session.expiresAt <= nowSec) {
122
- return jsonError('invalid_grant', 'OAuth session revoked');
158
+ return fail('refresh_token', 'invalid_grant', 'OAuth session revoked');
123
159
  }
124
160
 
125
161
  if (stored.revokedAt || stored.nextId || stored.id !== session.currentRefreshTokenId) {
126
162
  await revokeOAuthSession(env, session.id, nowSec);
127
- return jsonError('invalid_grant', 'Refresh token replayed');
163
+ return fail('refresh_token', 'invalid_grant', 'Refresh token replayed');
128
164
  }
129
- if (stored.expiresAt <= nowSec) return jsonError('invalid_grant', 'Expired refresh token');
165
+ if (stored.expiresAt <= nowSec) return fail('refresh_token', 'invalid_grant', 'Expired refresh token');
130
166
  if (stored.did !== verification.decoded.sub || stored.did !== session.did) {
131
167
  await revokeOAuthSession(env, session.id, nowSec);
132
- return jsonError('invalid_grant', 'Subject mismatch');
168
+ return fail('refresh_token', 'invalid_grant', 'Subject mismatch');
169
+ }
170
+ if (client_id !== session.clientId) return fail('refresh_token', 'invalid_grant', 'client_id mismatch');
171
+ if (ver.jkt !== session.dpopJkt) return fail('refresh_token', 'invalid_dpop', 'DPoP key mismatch');
172
+
173
+ try {
174
+ await requireStoredClientAuthentication(env, client_id, issuer, form, {
175
+ method: session.clientAuthMethod,
176
+ keyId: session.clientAuthKeyId,
177
+ });
178
+ } catch (error) {
179
+ const wrapped = new Error(`Client authentication failed: ${errorMessage(error) ?? error}`);
180
+ log('client_auth', { error: wrapped });
181
+ throw wrapped;
133
182
  }
134
- if (client_id !== session.clientId) return jsonError('invalid_grant', 'client_id mismatch');
135
- if (ver.jkt !== session.dpopJkt) return jsonError('invalid_dpop', 'DPoP key mismatch');
136
-
137
- await requireStoredClientAuthentication(env, client_id, issuer, form, {
138
- method: session.clientAuthMethod,
139
- keyId: session.clientAuthKeyId,
140
- }).catch((error) => {
141
- throw new Error(`Client authentication failed: ${errorMessage(error) ?? error}`);
142
- });
143
183
  await consumeDpopVerificationJti(env, ver);
144
184
 
145
185
  const accessJti = crypto.randomUUID().replace(/-/g, '');
@@ -175,9 +215,10 @@ export async function POST({ locals, request }: APIContext) {
175
215
  });
176
216
  } catch {
177
217
  await revokeOAuthSession(env, session.id, nowSec);
178
- return jsonError('invalid_grant', 'Refresh token replayed');
218
+ return fail('refresh_token', 'invalid_grant', 'Refresh token replayed');
179
219
  }
180
220
 
221
+ log('success', { outcome: 'ok' });
181
222
  return tokenResponse({
182
223
  access_token: accessJwt,
183
224
  token_type: 'DPoP',
@@ -188,9 +229,13 @@ export async function POST({ locals, request }: APIContext) {
188
229
  }, await getAuthzNonce(env));
189
230
  }
190
231
 
191
- return jsonError('unsupported_grant_type', 'grant_type must be authorization_code or refresh_token');
232
+ return fail('unsupported_grant', 'unsupported_grant_type', 'grant_type must be authorization_code or refresh_token');
192
233
  } catch (e) {
193
- if (e instanceof DpopNonceError) return dpopErrorResponse(env, e);
234
+ if (e instanceof DpopNonceError) {
235
+ log('dpop', { error: e });
236
+ return dpopErrorResponse(env, e);
237
+ }
238
+ log('outer', { error: e });
194
239
  return jsonError('invalid_request', errorMessage(e) ?? 'Unknown error');
195
240
  }
196
241
  }