@alteran/astro 0.7.5 → 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/package.json +1 -1
- package/src/lib/oauth/clients.ts +39 -8
- package/src/lib/oauth/observability.ts +99 -0
- package/src/middleware.ts +10 -2
- package/src/pages/oauth/par.ts +61 -11
package/package.json
CHANGED
package/src/lib/oauth/clients.ts
CHANGED
|
@@ -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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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: '
|
|
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,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
|
}
|