@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 +1 -1
- package/src/lib/oauth/clients.ts +39 -8
- package/src/lib/oauth/observability.ts +140 -0
- package/src/middleware.ts +10 -2
- package/src/pages/oauth/par.ts +61 -11
- package/src/pages/oauth/token.ts +78 -33
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,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
|
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
|
}
|
package/src/pages/oauth/token.ts
CHANGED
|
@@ -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
|
|
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
|
|
41
|
-
if (rec.client_id !== client_id) return
|
|
42
|
-
if (rec.redirect_uri !== redirect_uri) return
|
|
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
|
|
46
|
-
if (ver.jkt !== rec.dpopJkt) return
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
163
|
+
return fail('refresh_token', 'invalid_grant', 'Refresh token replayed');
|
|
128
164
|
}
|
|
129
|
-
if (stored.expiresAt <= nowSec) return
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
}
|