@alteran/astro 0.7.6 → 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.6",
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",
@@ -1,5 +1,7 @@
1
1
  import { errorMessage } from '../errors';
2
2
 
3
+ export type OauthEndpoint = 'par' | 'authorize' | 'consent' | 'token' | 'revoke';
4
+
3
5
  export type OauthParStage =
4
6
  | 'metadata_fetch'
5
7
  | 'metadata_shape'
@@ -9,6 +11,17 @@ export type OauthParStage =
9
11
  | 'outer'
10
12
  | 'success';
11
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
+
12
25
  export type OauthParFormSummary = {
13
26
  redirectUri: string;
14
27
  responseType: string;
@@ -20,20 +33,30 @@ export type OauthParFormSummary = {
20
33
  hasClientAssertion: boolean;
21
34
  };
22
35
 
23
- export type OauthParLogDetails = {
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;
24
50
  outcome: 'ok' | 'error';
25
51
  requestId?: string | null;
26
52
  error?: unknown;
27
53
  clientId?: string | null;
28
- form?: OauthParFormSummary | null;
54
+ form?: Record<string, unknown> | null;
29
55
  metadataStatus?: number | null;
30
56
  metadataContentType?: string | null;
31
57
  metadataRedirected?: boolean | null;
32
58
  };
33
59
 
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
60
  export type FetchAugmentedError = Error & {
38
61
  metadataStatus?: number;
39
62
  metadataContentType?: string | null;
@@ -69,16 +92,25 @@ export function summarizeParForm(form: URLSearchParams): OauthParFormSummary {
69
92
  };
70
93
  }
71
94
 
72
- export function logOauthPar(
73
- stage: OauthParStage,
74
- request: Request,
75
- details: OauthParLogDetails,
76
- ): void {
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 {
77
109
  const url = new URL(request.url);
78
110
  const record = {
79
111
  level: details.outcome === 'ok' ? 'info' : 'error',
80
- type: 'oauth_par',
81
- stage,
112
+ type: `oauth_${details.endpoint}`,
113
+ stage: details.stage,
82
114
  outcome: details.outcome,
83
115
  requestId: details.requestId ?? null,
84
116
  method: request.method,
@@ -97,3 +129,12 @@ export function logOauthPar(
97
129
  console.error(JSON.stringify(record));
98
130
  }
99
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
+ }
@@ -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
  }