@alteran/astro 0.6.3 → 0.7.1

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.
Files changed (44) hide show
  1. package/README.md +11 -0
  2. package/index.js +8 -0
  3. package/migrations/0009_oauth_session_state.sql +31 -0
  4. package/migrations/meta/0009_snapshot.json +749 -0
  5. package/migrations/meta/_journal.json +7 -0
  6. package/package.json +2 -1
  7. package/src/db/account.ts +134 -1
  8. package/src/db/schema.ts +31 -0
  9. package/src/lib/appview/proxy.ts +11 -8
  10. package/src/lib/auth.ts +34 -3
  11. package/src/lib/jwt.ts +4 -0
  12. package/src/lib/oauth/as-keys.ts +29 -0
  13. package/src/lib/oauth/clients.ts +453 -24
  14. package/src/lib/oauth/consent.ts +180 -0
  15. package/src/lib/oauth/dpop.ts +39 -5
  16. package/src/lib/oauth/resource.ts +93 -21
  17. package/src/lib/oauth/store.ts +64 -7
  18. package/src/lib/refresh-session.ts +16 -0
  19. package/src/lib/session-tokens.ts +33 -5
  20. package/src/lib/token-cleanup.ts +4 -2
  21. package/src/lib/util.ts +0 -1
  22. package/src/pages/.well-known/oauth-authorization-server.ts +15 -3
  23. package/src/pages/.well-known/oauth-protected-resource.ts +8 -4
  24. package/src/pages/oauth/authorize.ts +31 -52
  25. package/src/pages/oauth/consent.ts +163 -66
  26. package/src/pages/oauth/jwks.ts +15 -0
  27. package/src/pages/oauth/par.ts +34 -56
  28. package/src/pages/oauth/revoke.ts +75 -0
  29. package/src/pages/oauth/token.ts +148 -89
  30. package/src/pages/xrpc/[...nsid].ts +7 -6
  31. package/src/pages/xrpc/app.bsky.actor.getPreferences.ts +3 -4
  32. package/src/pages/xrpc/app.bsky.actor.putPreferences.ts +3 -4
  33. package/src/pages/xrpc/app.bsky.unspecced.getAgeAssuranceState.ts +3 -4
  34. package/src/pages/xrpc/chat.bsky.convo.getLog.ts +3 -4
  35. package/src/pages/xrpc/chat.bsky.convo.listConvos.ts +3 -4
  36. package/src/pages/xrpc/com.atproto.identity.getRecommendedDidCredentials.ts +3 -4
  37. package/src/pages/xrpc/com.atproto.identity.requestPlcOperationSignature.ts +3 -4
  38. package/src/pages/xrpc/com.atproto.identity.signPlcOperation.ts +3 -4
  39. package/src/pages/xrpc/com.atproto.identity.submitPlcOperation.ts +3 -4
  40. package/src/pages/xrpc/com.atproto.repo.listMissingBlobs.ts +3 -4
  41. package/src/pages/xrpc/com.atproto.server.checkAccountStatus.ts +3 -4
  42. package/src/pages/xrpc/com.atproto.server.deleteSession.ts +28 -9
  43. package/src/pages/xrpc/com.atproto.server.getSession.ts +3 -4
  44. package/types/env.d.ts +1 -0
@@ -1,67 +1,496 @@
1
+ import type { Env } from '../../env';
2
+ import { jwkThumbprint } from './dpop';
3
+ import { DpopNonceError } from './dpop-errors';
4
+ import { cleanupExpiredOAuthReplaySecrets, createSecretOnce } from '../../db/account';
1
5
  import { decodeProtectedHeader, importJWK, compactVerify, type JWK as JoseJWK } from 'jose';
2
6
 
3
- export function isHttpsUrl(u: string): boolean {
7
+ const MAX_CLIENT_JSON_BYTES = 128 * 1024;
8
+ const CLIENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
9
+
10
+ export type ClientAuthMethod = 'none' | 'private_key_jwt';
11
+
12
+ export type OAuthClientMetadata = {
13
+ client_id: string;
14
+ redirect_uris: string[];
15
+ token_endpoint_auth_method: ClientAuthMethod;
16
+ grant_types: string[];
17
+ response_types: string[];
18
+ scope: string;
19
+ dpop_bound_access_tokens: true;
20
+ jwks?: { keys: JsonWebKey[] };
21
+ jwks_uri?: string;
22
+ application_type?: string;
23
+ };
24
+
25
+ export type VerifiedClientAuth = {
26
+ method: ClientAuthMethod;
27
+ keyId: string | null;
28
+ };
29
+
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
+ function isIpLiteral(hostname: string): boolean {
51
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, '');
52
+ if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) return true;
53
+ return host.includes(':');
54
+ }
55
+
56
+ function isBlockedHost(hostname: string): boolean {
57
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, '');
58
+ if (!host || host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) return true;
59
+ if (host.endsWith('.internal') || host.endsWith('.home.arpa')) return true;
60
+ if (isIpLiteral(host)) return true;
61
+ return false;
62
+ }
63
+
64
+ function ipv4ToNumber(ip: string): number | null {
65
+ const parts = ip.split('.');
66
+ if (parts.length !== 4) return null;
67
+ let n = 0;
68
+ for (const part of parts) {
69
+ if (!/^\d+$/.test(part)) return null;
70
+ const v = Number(part);
71
+ if (v < 0 || v > 255) return null;
72
+ n = (n << 8) + v;
73
+ }
74
+ return n >>> 0;
75
+ }
76
+
77
+ function ipv4InRange(ip: number, base: string, bits: number): boolean {
78
+ const baseNumber = ipv4ToNumber(base);
79
+ if (baseNumber === null) return false;
80
+ const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
81
+ return (ip & mask) === (baseNumber & mask);
82
+ }
83
+
84
+ function isBlockedIpv4Number(ip: number): boolean {
85
+ return [
86
+ ['0.0.0.0', 8],
87
+ ['10.0.0.0', 8],
88
+ ['100.64.0.0', 10],
89
+ ['127.0.0.0', 8],
90
+ ['169.254.0.0', 16],
91
+ ['172.16.0.0', 12],
92
+ ['192.0.0.0', 24],
93
+ ['192.0.2.0', 24],
94
+ ['192.168.0.0', 16],
95
+ ['198.18.0.0', 15],
96
+ ['198.51.100.0', 24],
97
+ ['203.0.113.0', 24],
98
+ ['224.0.0.0', 4],
99
+ ['240.0.0.0', 4],
100
+ ].some(([base, bits]) => ipv4InRange(ip, base as string, bits as number));
101
+ }
102
+
103
+ function isBlockedIpAddress(value: string): boolean {
104
+ const host = value.toLowerCase().replace(/^\[|\]$/g, '');
105
+ const ipv4 = ipv4ToNumber(host);
106
+ if (ipv4 !== null) {
107
+ return isBlockedIpv4Number(ipv4);
108
+ }
109
+
110
+ if (!host.includes(':')) return false;
111
+ const dottedIpv4Suffix = host.match(/(?:^|:)(\d{1,3}(?:\.\d{1,3}){3})$/)?.[1];
112
+ if (dottedIpv4Suffix) {
113
+ const embedded = ipv4ToNumber(dottedIpv4Suffix);
114
+ if (embedded !== null) return true;
115
+ }
116
+ if (host.startsWith('::ffff:') || host.startsWith('0:0:0:0:0:ffff:')) return true;
117
+ return (
118
+ host === '::' ||
119
+ host === '::1' ||
120
+ host.startsWith('fc') ||
121
+ host.startsWith('fd') ||
122
+ host.startsWith('fe8') ||
123
+ host.startsWith('fe9') ||
124
+ host.startsWith('fea') ||
125
+ host.startsWith('feb') ||
126
+ host.startsWith('2001:db8') ||
127
+ host.startsWith('::ffff:10.') ||
128
+ host.startsWith('::ffff:127.') ||
129
+ host.startsWith('::ffff:192.168.')
130
+ );
131
+ }
132
+
133
+ export function isSafeFetchUrl(u: string): boolean {
4
134
  try {
5
135
  const url = new URL(u);
6
136
  if (url.protocol !== 'https:') return false;
7
- const host = url.hostname.toLowerCase();
8
- if (host === 'localhost' || host.endsWith('.local')) return false;
9
- if (/^(\d+\.){3}\d+$/.test(host)) return false;
137
+ if (url.username || url.password || url.hash) return false;
138
+ if (url.port && url.port !== '443') return false;
139
+ if (isBlockedHost(url.hostname)) return false;
140
+ if (isBlockedIpAddress(url.hostname)) return false;
10
141
  return true;
11
142
  } catch {
12
143
  return false;
13
144
  }
14
145
  }
15
146
 
16
- export async function fetchClientMetadata(client_id: string): Promise<any> {
147
+ export function isHttpsUrl(u: string): boolean {
148
+ return isSafeFetchUrl(u);
149
+ }
150
+
151
+ function isLoopbackHostname(hostname: string): boolean {
152
+ const host = hostname.toLowerCase().replace(/^\[|\]$/g, '');
153
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1';
154
+ }
155
+
156
+ export function isAllowedRedirectUri(uri: string): boolean {
157
+ try {
158
+ const url = new URL(uri);
159
+ if (url.username || url.password || url.hash) return false;
160
+ if (url.protocol === 'https:') {
161
+ return !isBlockedHost(url.hostname);
162
+ }
163
+ if (url.protocol === 'http:') {
164
+ return isLoopbackHostname(url.hostname);
165
+ }
166
+ return false;
167
+ } catch {
168
+ return false;
169
+ }
170
+ }
171
+
172
+ export function redirectUriMatches(registered: string, requested: string): boolean {
173
+ if (registered === requested) return true;
174
+ try {
175
+ const reg = new URL(registered);
176
+ const req = new URL(requested);
177
+ if (reg.protocol !== 'http:' || req.protocol !== 'http:') return false;
178
+ if (!isLoopbackHostname(reg.hostname) || !isLoopbackHostname(req.hostname)) return false;
179
+ if (reg.hostname.toLowerCase() !== req.hostname.toLowerCase()) return false;
180
+ if (reg.pathname !== req.pathname || reg.search !== req.search) return false;
181
+ return !reg.port && !!req.port;
182
+ } catch {
183
+ return false;
184
+ }
185
+ }
186
+
187
+ export async function safeFetchJson(env: Env, url: string, label: string): Promise<any> {
188
+ if (!isSafeFetchUrl(url)) {
189
+ throw new Error(`${label} URL is not safe to fetch`);
190
+ }
191
+
192
+ const parsed = new URL(url);
193
+ assertClientHostAllowed(env, parsed, label);
194
+ await assertHostnameResolvesPublic(parsed, label);
195
+
17
196
  const ctl = new AbortController();
18
197
  const t = setTimeout(() => ctl.abort(), 3000);
19
198
  try {
20
- const response = await fetch(client_id, { signal: ctl.signal });
21
- if (!response.ok) throw new Error(`client metadata fetch failed: ${response.status}`);
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}`);
22
205
  const ctype = response.headers.get('content-type') || '';
23
- if (!ctype.includes('application/json') && !ctype.includes('json'))
24
- throw new Error('client metadata must be JSON');
25
- return await response.json();
206
+ if (!ctype.includes('application/json') && !ctype.includes('json')) {
207
+ throw new Error(`${label} must be JSON`);
208
+ }
209
+ const text = await readResponseTextBounded(response, label);
210
+ return JSON.parse(text || '{}');
26
211
  } finally {
27
212
  clearTimeout(t);
28
213
  }
29
214
  }
30
215
 
31
- // removed local b64url/DER helpers in favor of jose
216
+ async function readResponseTextBounded(response: Response, label: string): Promise<string> {
217
+ if (!response.body) {
218
+ const text = await response.text();
219
+ if (text.length > MAX_CLIENT_JSON_BYTES) throw new Error(`${label} response too large`);
220
+ return text;
221
+ }
222
+ const reader = response.body.getReader();
223
+ const decoder = new TextDecoder();
224
+ let total = 0;
225
+ let text = '';
226
+ while (true) {
227
+ const { done, value } = await reader.read();
228
+ if (done) break;
229
+ total += value.byteLength;
230
+ if (total > MAX_CLIENT_JSON_BYTES) {
231
+ await reader.cancel().catch(() => {});
232
+ throw new Error(`${label} response too large`);
233
+ }
234
+ text += decoder.decode(value, { stream: true });
235
+ }
236
+ text += decoder.decode();
237
+ return text;
238
+ }
239
+
240
+ async function assertHostnameResolvesPublic(url: URL, label: string): Promise<void> {
241
+ if (isIpLiteral(url.hostname)) {
242
+ if (isBlockedIpAddress(url.hostname)) throw new Error(`${label} resolves to blocked address`);
243
+ return;
244
+ }
245
+
246
+ const records = await resolveHostAddresses(url.hostname);
247
+ if (records.length === 0) {
248
+ throw new Error(`${label} hostname has no public address records`);
249
+ }
250
+ if (records.some(isBlockedIpAddress)) {
251
+ throw new Error(`${label} resolves to blocked address`);
252
+ }
253
+ }
254
+
255
+ async function resolveHostAddresses(hostname: string): Promise<string[]> {
256
+ const records: string[] = [];
257
+ for (const type of ['A', 'AAAA']) {
258
+ const url = new URL('https://cloudflare-dns.com/dns-query');
259
+ url.searchParams.set('name', hostname);
260
+ url.searchParams.set('type', type);
261
+ const response = await fetch(url.toString(), {
262
+ headers: { accept: 'application/dns-json' },
263
+ redirect: 'error',
264
+ });
265
+ if (!response.ok) continue;
266
+ const body = await response.json().catch(() => null) as any;
267
+ const answers = Array.isArray(body?.Answer) ? body.Answer : [];
268
+ for (const answer of answers) {
269
+ if ((answer?.type === 1 || answer?.type === 28) && typeof answer.data === 'string') {
270
+ records.push(answer.data);
271
+ }
272
+ }
273
+ }
274
+ return records;
275
+ }
276
+
277
+ export async function fetchClientMetadata(env: Env, client_id: string): Promise<OAuthClientMetadata> {
278
+ const metadata = await safeFetchJson(env, client_id, 'client metadata');
279
+ return validateClientMetadataShape(metadata, client_id);
280
+ }
281
+
282
+ export function validateClientMetadataShape(metadata: any, clientId: string): OAuthClientMetadata {
283
+ if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
284
+ throw new Error('client metadata must be an object');
285
+ }
286
+ if (metadata.client_id !== clientId) {
287
+ throw new Error('client_id mismatch');
288
+ }
289
+ if (!Array.isArray(metadata.redirect_uris) || metadata.redirect_uris.length === 0) {
290
+ throw new Error('redirect_uris required');
291
+ }
292
+ const redirect_uris = metadata.redirect_uris;
293
+ if (!redirect_uris.every((uri: unknown) => typeof uri === 'string' && isAllowedRedirectUri(uri))) {
294
+ throw new Error('redirect_uris contains unsupported URI');
295
+ }
296
+ if (metadata.dpop_bound_access_tokens !== true) {
297
+ throw new Error('client must require DPoP');
298
+ }
299
+
300
+ const method = metadata.token_endpoint_auth_method;
301
+ if (method !== 'none' && method !== 'private_key_jwt') {
302
+ throw new Error('unsupported token_endpoint_auth_method');
303
+ }
304
+
305
+ if (!Array.isArray(metadata.response_types) || !metadata.response_types.includes('code')) {
306
+ throw new Error('response_types must include code');
307
+ }
308
+ if (!Array.isArray(metadata.grant_types) || !metadata.grant_types.includes('authorization_code')) {
309
+ throw new Error('grant_types must include authorization_code');
310
+ }
311
+ if (!metadata.grant_types.includes('refresh_token')) {
312
+ throw new Error('grant_types must include refresh_token');
313
+ }
314
+ if (typeof metadata.scope !== 'string' || !metadata.scope.split(' ').includes('atproto')) {
315
+ throw new Error('metadata scope must include atproto');
316
+ }
317
+ if (metadata.scope.split(' ').includes('openid')) {
318
+ throw new Error('openid scope is not supported');
319
+ }
320
+ if (metadata.jwks && metadata.jwks_uri) {
321
+ throw new Error('client metadata must not include both jwks and jwks_uri');
322
+ }
323
+ if (method === 'private_key_jwt' && !metadata.jwks && typeof metadata.jwks_uri !== 'string') {
324
+ throw new Error('private_key_jwt clients must provide jwks or jwks_uri');
325
+ }
326
+ if (metadata.jwks_uri && !isSafeFetchUrl(metadata.jwks_uri)) {
327
+ throw new Error('jwks_uri is not safe to fetch');
328
+ }
329
+
330
+ return {
331
+ client_id: metadata.client_id,
332
+ redirect_uris,
333
+ token_endpoint_auth_method: method,
334
+ grant_types: metadata.grant_types,
335
+ response_types: metadata.response_types,
336
+ scope: metadata.scope,
337
+ dpop_bound_access_tokens: true,
338
+ jwks: metadata.jwks,
339
+ jwks_uri: metadata.jwks_uri,
340
+ application_type: typeof metadata.application_type === 'string' ? metadata.application_type : undefined,
341
+ };
342
+ }
343
+
344
+ export function validateParRequest(metadata: OAuthClientMetadata, request: {
345
+ response_type: string;
346
+ grant_type?: string;
347
+ redirect_uri: string;
348
+ scope: string;
349
+ code_challenge: string;
350
+ code_challenge_method: string;
351
+ }): void {
352
+ if (request.grant_type && request.grant_type !== 'authorization_code') {
353
+ throw new Error('unsupported grant_type');
354
+ }
355
+ if (request.response_type !== 'code') {
356
+ throw new Error('unsupported response_type');
357
+ }
358
+ if (!request.redirect_uri || !isAllowedRedirectUri(request.redirect_uri)) {
359
+ throw new Error('unsupported redirect_uri');
360
+ }
361
+ if (!metadata.redirect_uris.some((uri) => redirectUriMatches(uri, request.redirect_uri))) {
362
+ throw new Error('redirect_uri not registered');
363
+ }
364
+ const requestedScopes = request.scope.split(' ').filter(Boolean);
365
+ const allowedScopes = new Set(metadata.scope.split(' ').filter(Boolean));
366
+ if (!requestedScopes.length || !requestedScopes.includes('atproto')) {
367
+ throw new Error('invalid scope');
368
+ }
369
+ if (requestedScopes.includes('openid')) {
370
+ throw new Error('openid scope is not supported');
371
+ }
372
+ for (const scope of requestedScopes) {
373
+ if (!allowedScopes.has(scope)) {
374
+ throw new Error(`scope not allowed: ${scope}`);
375
+ }
376
+ }
377
+ if (!request.code_challenge || request.code_challenge_method !== 'S256') {
378
+ throw new Error('PKCE S256 required');
379
+ }
380
+ }
381
+
382
+ export async function resolveClientJwks(env: Env, metadata: OAuthClientMetadata): Promise<{ keys: JsonWebKey[] }> {
383
+ let jwks = metadata.jwks;
384
+ if (!jwks && metadata.jwks_uri) {
385
+ jwks = await safeFetchJson(env, metadata.jwks_uri, 'client jwks');
386
+ }
387
+ if (!jwks || typeof jwks !== 'object' || !Array.isArray((jwks as any).keys)) {
388
+ throw new Error('invalid JWKS');
389
+ }
390
+ const keys = (jwks as any).keys;
391
+ if (!keys.every((key: unknown) => key && typeof key === 'object')) {
392
+ throw new Error('invalid JWKS key');
393
+ }
394
+ return { keys };
395
+ }
396
+
397
+ export async function verifyClientAuthentication(
398
+ env: Env,
399
+ client_id: string,
400
+ issuerOrigin: string,
401
+ metadata: OAuthClientMetadata,
402
+ form: URLSearchParams,
403
+ ): Promise<VerifiedClientAuth> {
404
+ 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 };
409
+ }
410
+
411
+ const client_assertion_type = form.get('client_assertion_type') || '';
412
+ const client_assertion = form.get('client_assertion') || '';
413
+ if (client_assertion_type !== CLIENT_ASSERTION_TYPE || !client_assertion) {
414
+ throw new Error('missing client assertion');
415
+ }
416
+ const jwks = await resolveClientJwks(env, metadata);
417
+ const result = await verifyClientAssertion(env, client_id, issuerOrigin, client_assertion, jwks);
418
+ if (!result) {
419
+ throw new Error('invalid client assertion');
420
+ }
421
+ return { method: 'private_key_jwt', keyId: result.keyId };
422
+ }
423
+
424
+ async function consumeClientAssertionJti(env: Env, clientId: string, jti: string, exp: number): Promise<boolean> {
425
+ const key = `oauth:client-assertion:jti:${clientId}:${jti}`;
426
+ await cleanupExpiredOAuthReplaySecrets(env, Math.floor(Date.now() / 1000));
427
+ return createSecretOnce(env, key, JSON.stringify({ exp }));
428
+ }
32
429
 
33
- export async function verifyClientAssertion(client_id: string, issuerOrigin: string, assertionJwt: string, jwks: any): Promise<boolean> {
430
+ export async function verifyClientAssertion(
431
+ env: Env,
432
+ client_id: string,
433
+ issuerOrigin: string,
434
+ assertionJwt: string,
435
+ jwks: { keys: JsonWebKey[] },
436
+ ): Promise<{ keyId: string } | null> {
34
437
  try {
35
438
  const [h, p] = assertionJwt.split('.');
36
- if (!h || !p) return false;
439
+ if (!h || !p) return null;
37
440
  const header = decodeProtectedHeader(assertionJwt) as any;
38
- if (header.alg !== 'ES256') return false;
441
+ if (header.alg !== 'ES256') return null;
39
442
  const keys: any[] = Array.isArray(jwks?.keys) ? jwks.keys : [];
40
- if (!keys.length) return false;
443
+ if (!keys.length) return null;
41
444
  const byKid = typeof header.kid === 'string' ? keys.find((k) => k.kid === header.kid) : null;
42
445
  const candidates = byKid ? [byKid] : keys;
43
446
 
44
447
  let payload: any | null = null;
448
+ let matchedKey: JsonWebKey | null = null;
45
449
  for (const jwk of candidates) {
46
450
  try {
47
451
  const key = await importJWK(jwk as JoseJWK, 'ES256');
48
452
  const verified = await compactVerify(assertionJwt, key);
49
453
  payload = JSON.parse(new TextDecoder().decode(verified.payload));
454
+ matchedKey = jwk as JsonWebKey;
50
455
  break;
51
456
  } catch {
52
457
  // Try the next JWK candidate; only the final no-payload check matters.
53
458
  }
54
459
  }
55
- if (!payload) return false;
460
+ if (!payload || !matchedKey) return null;
56
461
 
57
462
  const now = Math.floor(Date.now() / 1000);
58
- if (payload.iss !== client_id) return false;
59
- if (payload.sub !== client_id) return false;
60
- if (payload.aud !== issuerOrigin) return false;
61
- if (typeof payload.iat !== 'number' || now - payload.iat > 300) return false;
62
- if (typeof payload.jti !== 'string' || payload.jti.length < 8) return false;
63
- return true;
64
- } catch {
65
- return false;
463
+ if (payload.iss !== client_id) return null;
464
+ if (payload.sub !== client_id) return null;
465
+ if (payload.aud !== issuerOrigin) return null;
466
+ if (typeof payload.iat !== 'number' || now - payload.iat > 300 || payload.iat - now > 30) return null;
467
+ if (typeof payload.exp !== 'number' || payload.exp <= now || payload.exp - now > 300) return null;
468
+ if (typeof payload.jti !== 'string' || payload.jti.length < 8) return null;
469
+ if (!(await consumeClientAssertionJti(env, client_id, payload.jti, payload.exp))) return null;
470
+
471
+ return {
472
+ keyId: typeof header.kid === 'string' ? header.kid : await jwkThumbprint(matchedKey),
473
+ };
474
+ } catch (error) {
475
+ if (error instanceof DpopNonceError) throw error;
476
+ return null;
477
+ }
478
+ }
479
+
480
+ export async function requireSameClientAuth(
481
+ env: Env,
482
+ clientId: string,
483
+ issuerOrigin: string,
484
+ metadata: OAuthClientMetadata,
485
+ form: URLSearchParams,
486
+ expected: { method: string | null; keyId?: string | null },
487
+ ): Promise<VerifiedClientAuth> {
488
+ const actual = await verifyClientAuthentication(env, clientId, issuerOrigin, metadata, form);
489
+ if (actual.method !== expected.method) {
490
+ throw new Error('client authentication method changed');
491
+ }
492
+ if (actual.method === 'private_key_jwt' && expected.keyId && actual.keyId !== expected.keyId) {
493
+ throw new Error('client authentication key changed');
66
494
  }
495
+ return actual;
67
496
  }