@poppykit/server 0.4.0 → 0.5.0

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 (52) hide show
  1. package/API.md +10 -3
  2. package/AUTH.md +120 -124
  3. package/CHANGELOG.md +13 -1
  4. package/CONFORMANCE.md +73 -0
  5. package/CONTRIBUTING.md +12 -11
  6. package/DELIVERY.md +2 -0
  7. package/INTEGRATIONS.md +74 -81
  8. package/OPERATIONS.md +142 -0
  9. package/README.md +11 -20
  10. package/RELEASING.md +2 -0
  11. package/SECURITY.md +2 -0
  12. package/STORAGE.md +56 -17
  13. package/dist/adapter-contract.d.ts +18 -0
  14. package/dist/adapter-contract.js +79 -0
  15. package/dist/auth/authenticator.d.ts +3 -1
  16. package/dist/auth/authenticator.js +34 -29
  17. package/dist/auth/authority.d.ts +8 -2
  18. package/dist/auth/authority.js +7 -2
  19. package/dist/auth/session.d.ts +47 -0
  20. package/dist/auth/session.js +94 -0
  21. package/dist/browser.d.ts +15 -16
  22. package/dist/browser.js +65 -33
  23. package/dist/conversation/service.d.ts +11 -1
  24. package/dist/conversation/service.js +39 -9
  25. package/dist/feedback.d.ts +1 -1
  26. package/dist/feedback.js +5 -5
  27. package/dist/index.d.ts +5 -1
  28. package/dist/index.js +3 -0
  29. package/dist/mcp.d.ts +1 -1
  30. package/dist/mcp.js +8 -4
  31. package/dist/network/discovery.d.ts +31 -13
  32. package/dist/network/discovery.js +103 -66
  33. package/dist/operations.d.ts +115 -0
  34. package/dist/operations.js +212 -0
  35. package/dist/postgres.d.ts +6 -0
  36. package/dist/postgres.js +72 -4
  37. package/dist/protocol/operations.d.ts +51 -0
  38. package/dist/protocol/operations.js +98 -0
  39. package/dist/protocol/session.d.ts +28 -0
  40. package/dist/protocol/session.js +93 -0
  41. package/dist/protocol/sse.d.ts +1 -0
  42. package/dist/protocol/sse.js +1 -1
  43. package/dist/protocol/wire.d.ts +8 -4
  44. package/dist/protocol/wire.js +37 -15
  45. package/dist/reference-auth/index.d.ts +10 -16
  46. package/dist/reference-auth/index.js +225 -430
  47. package/dist/sqlite.d.ts +11 -0
  48. package/dist/sqlite.js +84 -4
  49. package/examples/operations.mjs +18 -0
  50. package/examples/quickstart.mjs +8 -5
  51. package/migrations/RETIREMENT.md +77 -0
  52. package/package.json +5 -2
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
2
  import { calculateJwkThumbprint, compactVerify, createLocalJWKSet, errors } from 'jose';
3
3
  import { ValidationError, parseAssertionClaims, parseJson } from '../protocol/wire.js';
4
- import { companyScopes } from '../network/discovery.js';
4
+ import { companyScopes, MEDIATED_GRANT, parseMediatedSignIn } from '../network/discovery.js';
5
5
  import { registeredRedirect } from '../network/redirect.js';
6
6
  import { secureUrl } from '../network/transport.js';
7
7
  import { own } from '../store.js';
@@ -22,16 +22,14 @@ export class AuthorizationError extends Error {
22
22
  this.name = 'AuthorizationError';
23
23
  }
24
24
  }
25
- const JWT_BEARER = 'urn:ietf:params:oauth:grant-type:jwt-bearer';
26
25
  const CLIENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
27
26
  const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
28
- const ASSERTION_ALGORITHMS = ['ES256', 'RS256'];
27
+ const ASSERTION_ALGORITHMS = ['ES256', 'RS256', 'PS384'];
29
28
  const NONCE_SECONDS = 300;
30
29
  const MAX_FORM_BYTES = 65536;
31
30
  const AUTHORIZATION_MS = 600_000;
32
31
  const CODE_MS = 60_000;
33
32
  // A Session ends a day after the last token issued for it (spec 4.2: the Company decides how long a Session lasts).
34
- const SESSION_IDLE_SECONDS = 86_400;
35
33
  const random = (bytes) => randomBytes(bytes).toString('base64url');
36
34
  const tokenHash = (token) => createHash('sha256').update(token).digest('base64url');
37
35
  const grantLive = (grant, at) => !!grant && !grant.revoked && grant.expiresAt > at;
@@ -48,26 +46,27 @@ function checked(state) {
48
46
  }
49
47
  /** Run once over the complete namespace, under the adapter's writer lock. */
50
48
  export function upgradeIssuerState(state) {
51
- if (state.version === 4)
49
+ if (state.version === 5)
52
50
  return;
53
- if (state.version !== 3)
51
+ if (state.version !== 3 && state.version !== 4)
54
52
  throw new Error('unsupported issuer state version');
55
- // Old keys could name several Sessions. Never guess ownership; end those Sessions, keeping IDs and grant lineage.
56
- state.sessionKeys = {};
57
- for (const token of Object.values(state.accessTokens))
58
- if (token.jkt)
59
- state.sessionKeys[token.jkt] = '';
60
- for (const session of Object.values(state.sessions))
61
- session.expiresAt = 0;
62
- state.authorizations = {};
63
- state.codes = {};
64
- for (const [id, row] of Object.entries(state.signIns))
65
- if (row.kind === 'device' || row.kind === 'mediated')
66
- delete state.signIns[id];
67
- state.version = 4;
53
+ state.sessionKeys ??= {};
54
+ for (const token of Object.values(state.accessTokens)) {
55
+ const grant = token.grantId === null ? undefined : own(state.grants, token.grantId);
56
+ const session = own(state.sessions, token.sessionId);
57
+ if (grant && session && grant.clientId !== session.clientId)
58
+ continue;
59
+ const clientId = grant?.clientId ?? session?.clientId;
60
+ if (clientId !== undefined)
61
+ token.clientId ??= clientId;
62
+ if (grant)
63
+ token.accountId ??= grant.accountId;
64
+ }
65
+ // Keep legacy Sessions, pending consent, replay fences and uncertain hooks for explicit reconciliation.
66
+ state.version = 5;
68
67
  }
69
68
  export function initialIssuerState() {
70
- return { version: 4, generation: random(16), sessions: {}, sessionKeys: {}, grants: {}, refreshTokens: {}, authorizations: {}, codes: {}, signIns: {}, accessTokens: {}, replay: {}, nonces: [] };
69
+ return { version: 5, generation: random(16), sessions: {}, sessionKeys: {}, grants: {}, refreshTokens: {}, authorizations: {}, codes: {}, signIns: {}, accessTokens: {}, replay: {}, nonces: [] };
71
70
  }
72
71
  class TokenError extends Error {
73
72
  code;
@@ -147,8 +146,8 @@ export function createReferenceIssuer(options) {
147
146
  const accountLifetimeMs = positive(options.accountLifetimeSeconds, 2_592_000, 'accountLifetimeSeconds') * 1000;
148
147
  const maxPending = positive(options.maxPendingAuthorizations, 1000, 'maxPendingAuthorizations');
149
148
  const maxPendingPerClient = positive(options.maxPendingAuthorizationsPerClient, 100, 'maxPendingAuthorizationsPerClient');
150
- const maxSessionsPerClient = positive(options.maxSessionsPerClient, 10_000, 'maxSessionsPerClient');
151
- const sessionMs = Math.max(SESSION_IDLE_SECONDS, lifetimeSeconds) * 1000;
149
+ if (options.maxSessionsPerClient !== undefined)
150
+ throw new TypeError('maxSessionsPerClient moved to createSessionAuthority');
152
151
  const requireNonce = options.requireDpopNonce ?? true;
153
152
  if (options.device) {
154
153
  secureUrl(options.device.endpoint);
@@ -157,11 +156,8 @@ export function createReferenceIssuer(options) {
157
156
  if (!hosts.some(host => url.hostname === host || url.hostname.endsWith(`.${host}`)))
158
157
  throw new TypeError('verificationUri');
159
158
  }
160
- if (options.mediated) {
161
- secureUrl(options.mediated.endpoint);
162
- if (!options.mediated.fields.length || new Set(options.mediated.fields.map(field => field.name)).size !== options.mediated.fields.length)
163
- throw new TypeError('mediated.fields');
164
- }
159
+ if (options.mediated)
160
+ parseMediatedSignIn({ version: '1', fields: options.mediated.fields, scopes: options.mediated.scopes });
165
161
  const deviceIntervalMs = positive(options.device?.intervalSeconds, 5, 'intervalSeconds') * 1000;
166
162
  const maxCodeAttempts = positive(options.mediated?.maxCodeAttempts, 5, 'maxCodeAttempts');
167
163
  const maxAttempts = positive(options.mediated?.maxAttemptsPerMinute, 5, 'maxAttemptsPerMinute');
@@ -169,7 +165,7 @@ export function createReferenceIssuer(options) {
169
165
  const mcpResources = options.mcpResources ?? [];
170
166
  for (const entry of mcpResources) {
171
167
  const url = secureUrl(entry.url);
172
- if (url.search || typeof entry.dpop !== 'boolean' || mcpResources.filter(item => item.url === entry.url).length !== 1)
168
+ if (typeof entry.dpop !== 'boolean' || mcpResources.filter(item => item.url === entry.url).length !== 1)
173
169
  throw new TypeError('mcpResources');
174
170
  }
175
171
  const resources = [...new Set([...(options.resources ?? []), ...mcpResources.map(entry => entry.url)])];
@@ -177,16 +173,6 @@ export function createReferenceIssuer(options) {
177
173
  // Every issuer mutation is ordered with conversation work by the shared coordinator.
178
174
  const read = async (selection = { kind: 'metadata' }) => checked(await store.read(selection));
179
175
  const commit = (selection, change) => options.coordinator.run(() => store.transaction(selection, draft => change(checked(draft))));
180
- function browserSession(state, binding) {
181
- const session = own(state.sessions, binding.sessionId), at = now();
182
- if (!session || session.expiresAt <= at || session.clientId !== binding.clientId || session.userId !== binding.userId || (binding.issuerGeneration !== undefined && binding.issuerGeneration !== state.generation))
183
- throw new AuthorityError('invalid_token');
184
- const grant = session.grantId === null ? undefined : own(state.grants, session.grantId);
185
- const signedIn = grantLive(grant, at);
186
- return { clientId: session.clientId, userId: session.userId, sessionId: binding.sessionId, issuerGeneration: state.generation, expiresAt: session.expiresAt,
187
- principal: { issuer, clientId: session.clientId, userId: session.userId, sessionId: binding.sessionId, issuerGeneration: state.generation, sessionVersion: session.version, tokenExpiresAt: session.expiresAt,
188
- scopes: signedIn ? [...grant.scopes] : [], ...(signedIn ? { accountId: grant.accountId, grantId: session.grantId } : {}) } };
189
- }
190
176
  // Reuse the current nonce while it has more than half its life left, so challenges cannot grow state.
191
177
  async function currentNonce(state) {
192
178
  const fresh = state.nonces.find(nonce => nonce.expiresAt - now() > NONCE_SECONDS * 500);
@@ -283,7 +269,7 @@ export function createReferenceIssuer(options) {
283
269
  if (record.expiresAt <= at)
284
270
  delete draft.authorizations[key];
285
271
  for (const [key, record] of Object.entries(draft.signIns))
286
- if (record.expiresAt + CLOCK_SKEW_SECONDS * 1000 < at)
272
+ if (!(record.kind === 'mediated' && record.status === 'verifying') && record.expiresAt + CLOCK_SKEW_SECONDS * 1000 < at)
287
273
  delete draft.signIns[key];
288
274
  }
289
275
  // Revoking a grant signs out every Session using it; they continue signed out and stay bound to the account (spec 4.9).
@@ -296,18 +282,12 @@ export function createReferenceIssuer(options) {
296
282
  if (session.grantId === grantId)
297
283
  session.grantId = null;
298
284
  }
299
- function issueSignIn(draft, sessionId, target, accountId, scopes, jkt, resource) {
300
- if (target.accountId !== null && target.accountId !== accountId)
301
- throw new TokenError('account_mismatch');
302
- const grantId = `grt_${random(16)}`, refreshToken = random(32), accessToken = random(32), at = now();
303
- const refreshHash = tokenHash(refreshToken);
304
- draft.grants[grantId] = { clientId: target.clientId, userId: target.userId, accountId, scopes: [...scopes], expiresAt: at + accountLifetimeMs, revoked: false, refreshHash, previousHash: '' };
285
+ function issueSignIn(draft, clientId, accountId, scopes, jkt, resource) {
286
+ const grantId = 'grt_' + random(16), refreshToken = random(32), accessToken = random(32), at = now(), refreshHash = tokenHash(refreshToken);
287
+ draft.grants[grantId] = { clientId, userId: '', accountId, scopes: [...scopes], expiresAt: at + accountLifetimeMs, revoked: false, refreshHash, previousHash: '' };
305
288
  draft.refreshTokens[refreshHash] = grantId;
306
- target.accountId = accountId;
307
- target.grantId = grantId;
308
- target.expiresAt = at + sessionMs;
309
- draft.accessTokens[tokenHash(accessToken)] = { sessionId, grantId, scopes: [...scopes], resource: resource ?? null, jkt, expiresAt: at + lifetimeMs };
310
- return { grantId, body: { access_token: accessToken, token_type: 'DPoP', expires_in: lifetimeSeconds, refresh_token: refreshToken, refresh_token_expires_in: Math.floor(accountLifetimeMs / 1000), scope: scopes.join(' ') } };
289
+ draft.accessTokens[tokenHash(accessToken)] = { clientId, accountId, sessionId: '', grantId, scopes: [...scopes], resource: resource ?? null, jkt, expiresAt: at + lifetimeMs };
290
+ return { grantId, body: { access_token: accessToken, token_type: jkt ? 'DPoP' : 'Bearer', expires_in: lifetimeSeconds, refresh_token: refreshToken, refresh_token_expires_in: Math.floor(accountLifetimeMs / 1000), scope: scopes.join(' ') } };
311
291
  }
312
292
  function signInScopes(scope, allowed) {
313
293
  const scopes = scope?.split(' ') ?? [];
@@ -337,7 +317,7 @@ export function createReferenceIssuer(options) {
337
317
  await commit({ kind: 'sign_in', ids: [id, clientRevisionKey(clientId)], clientId, replayKeys: [replayKey], at: now() }, draft => {
338
318
  if (draft.generation !== generation || !current(client))
339
319
  throw new TokenError('invalid_client');
340
- const pending = Object.values(draft.signIns).filter(row => row.expiresAt > now() && (row.kind === 'device' ? row.status === 'pending' || row.status === 'approved' : row.kind === 'mediated' && (row.status === 'pending' || row.status === 'verifying')));
320
+ const pending = Object.values(draft.signIns).filter(row => (row.kind === 'mediated' && row.status === 'verifying') || row.expiresAt > now() && (row.kind === 'device' ? row.status === 'pending' || row.status === 'approved' : row.kind === 'mediated' && (row.status === 'pending' || row.status === 'verifying')));
341
321
  if ((draft.limits?.pending ?? pending.length) >= maxPending || (draft.limits?.pendingForClient ?? pending.filter(row => row.clientId === clientId).length) >= maxPendingPerClient)
342
322
  throw new TokenError('rate_limited', { 'Retry-After': '60' });
343
323
  consumeReplay(draft, replayKey, client.exp * 1000);
@@ -353,31 +333,27 @@ export function createReferenceIssuer(options) {
353
333
  const { clientId, clientAssertion } = clientCredentials(params), code = params.get('device_code'), resource = params.get('resource');
354
334
  if (!code || params.has('session_id') || params.has('assertion'))
355
335
  throw new TokenError('invalid_request');
356
- if (resource !== undefined && (!resources.includes(resource) || isBearerResource(resource)))
336
+ if (resource !== undefined && !resources.includes(resource))
357
337
  throw new TokenError('invalid_target');
358
338
  const state = await read();
359
- const proof = await verifyDpopProof(request.headers.get('dpop'), { method: 'POST', url: options.tokenEndpoint, nowMs: now() });
360
- if (requireNonce && !state.nonces.some(nonce => nonce.value === proof.claims.nonce && nonce.expiresAt > now()))
339
+ const proof = isBearerResource(resource) ? null : await verifyDpopProof(request.headers.get('dpop'), { method: 'POST', url: options.tokenEndpoint, nowMs: now() });
340
+ if (proof && requireNonce && !state.nonces.some(nonce => nonce.value === proof.claims.nonce && nonce.expiresAt > now()))
361
341
  throw new TokenError('use_dpop_nonce', { 'DPoP-Nonce': await currentNonce(state) });
362
342
  const { client, keys } = await authenticateClient(clientId, clientAssertion);
363
- for (const key of keys().keys)
364
- if (await calculateJwkThumbprint(key).catch(() => null) === proof.jkt)
365
- throw new TokenError('invalid_dpop_proof');
366
- const id = tokenHash(code.split('.')[0]), replayKey = `assertion ${clientId} ${client.jti}`, proofKey = `proof ${proof.jkt} ${proof.claims.jti}`;
367
- const body = await commit({ kind: 'sign_in', ids: [id], sessionKey: proof.jkt, clientId, replayKeys: [replayKey, proofKey], at: now() }, draft => {
343
+ if (proof)
344
+ for (const key of keys().keys)
345
+ if (await calculateJwkThumbprint(key).catch(() => null) === proof.jkt)
346
+ throw new TokenError('invalid_dpop_proof');
347
+ const id = tokenHash(code.split('.')[0]), replayKey = `assertion ${clientId} ${client.jti}`, proofKey = proof ? `proof ${proof.jkt} ${proof.claims.jti}` : null;
348
+ const body = await commit({ kind: 'sign_in', ids: [id], clientId, replayKeys: [replayKey, ...(proofKey ? [proofKey] : [])], at: now() }, draft => {
368
349
  if (draft.generation !== state.generation || !current(client))
369
350
  throw new TokenError('invalid_client');
370
351
  consumeReplay(draft, replayKey, client.exp * 1000);
371
- consumeReplay(draft, proofKey, (proof.claims.iat + PROOF_WINDOW_SECONDS) * 1000);
352
+ if (proof && proofKey)
353
+ consumeReplay(draft, proofKey, (proof.claims.iat + PROOF_WINDOW_SECONDS) * 1000);
372
354
  const record = own(draft.signIns, id);
373
355
  if (!record || record.kind !== 'device' || record.clientId !== clientId || record.deviceHash !== tokenHash(code))
374
356
  return { error: 'invalid_grant' };
375
- if (record.sessionKey === undefined || (record.sessionKey !== null && record.sessionKey !== proof.jkt))
376
- return { error: 'invalid_grant' };
377
- const sessionId = own(draft.sessionKeys, proof.jkt), session = sessionId ? own(draft.sessions, sessionId) : undefined;
378
- if (!session || session.clientId !== clientId || session.expiresAt <= now())
379
- return { error: 'invalid_grant' };
380
- record.sessionKey ??= proof.jkt;
381
357
  if (record.expiresAt <= now())
382
358
  return { error: 'expired_token' };
383
359
  if (record.status === 'denied')
@@ -392,401 +368,251 @@ export function createReferenceIssuer(options) {
392
368
  record.nextPollAt = now() + record.intervalMs;
393
369
  if (record.status === 'pending')
394
370
  return { error: 'authorization_pending' };
395
- if (session.accountId !== null && session.accountId !== record.accountId)
396
- return { error: 'account_mismatch' };
397
- const granted = issueSignIn(draft, sessionId, session, record.accountId, record.scopes, proof.jkt, resource);
371
+ if (!consentCurrent(draft, clientId, record.accountId, record.revision))
372
+ return { error: 'invalid_grant' };
373
+ const result = issueSignIn(draft, clientId, record.accountId, record.scopes, proof?.jkt ?? '', resource);
398
374
  record.status = 'consumed';
399
- return granted.body;
375
+ return result.body;
400
376
  });
401
377
  return json('error' in body ? 400 : 200, body);
402
378
  }
403
- function mediatedToken(draft, accessHash, jkt) {
404
- const token = own(draft.accessTokens, accessHash), session = token && own(draft.sessions, token.sessionId);
405
- if (!token || !session || token.expiresAt <= now() || session.expiresAt <= now() || token.jkt !== jkt || (token.grantId !== null && !grantLive(own(draft.grants, token.grantId), now())))
406
- throw new TokenError('invalid_token');
407
- return { token, session };
408
- }
409
- async function mediatedSignIn(request) {
379
+ async function mediatedSignIn(request, supplied) {
410
380
  const config = options.mediated;
411
381
  if (!config)
412
382
  throw new TokenError('unsupported_grant_type');
413
- const endpoint = new URL(config.endpoint), url = new URL(request.url);
414
- if (request.method !== 'POST' || url.origin !== endpoint.origin || url.search || url.hash ||
415
- (url.pathname !== endpoint.pathname && !url.pathname.startsWith(`${endpoint.pathname.replace(/\/$/, '')}/`)))
416
- throw new TokenError('invalid_request');
417
- const id = url.pathname === endpoint.pathname ? random(32) : url.pathname.slice(endpoint.pathname.replace(/\/$/, '').length + 1);
418
- if (!/^[A-Za-z0-9_-]{1,256}$/.test(id))
419
- throw new TokenError('sign_in_not_found');
420
- const challenge = url.pathname !== endpoint.pathname;
421
- if (!/^application\/json(?:;|$)/i.test(request.headers.get('content-type') ?? ''))
383
+ if (request.url !== options.tokenEndpoint)
422
384
  throw new TokenError('invalid_request');
423
- let input;
424
- try {
425
- const parsed = parseJson(await readForm(request));
426
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
427
- throw 0;
428
- input = parsed;
429
- }
430
- catch {
385
+ const params = supplied ?? await readParams(request), { clientId, clientAssertion } = clientCredentials(params);
386
+ if (params.get('grant_type') !== MEDIATED_GRANT)
431
387
  throw new TokenError('invalid_request');
432
- }
433
- let credentials = {}, code = '';
388
+ const { client, keys } = await authenticateClient(clientId, clientAssertion), state = await read();
389
+ const challenge = params.has('sign_in_id'), id = challenge ? params.get('sign_in_id') : random(32);
390
+ if (!/^[A-Za-z0-9_-]{1,256}$/.test(id))
391
+ throw new TokenError('invalid_grant');
392
+ const key = tokenHash(id), existing = challenge ? own((await read({ kind: 'sign_in', ids: [key], replayKeys: [], at: now() })).signIns, key) : undefined;
393
+ if (challenge && (!existing || existing.kind !== 'mediated' || existing.clientId !== clientId || !Object.hasOwn(existing, 'resource')))
394
+ throw new TokenError('invalid_grant');
395
+ const resource = challenge && existing?.kind === 'mediated' ? existing.resource ?? undefined : params.get('resource');
396
+ if (resource !== undefined && !resources.includes(resource))
397
+ throw new TokenError('invalid_target');
398
+ if (challenge && params.has('resource') && params.get('resource') !== resource)
399
+ throw new TokenError('invalid_target');
400
+ const proof = isBearerResource(resource) ? null : await verifyDpopProof(request.headers.get('dpop'), { method: 'POST', url: options.tokenEndpoint, nowMs: now() });
401
+ if (proof && requireNonce && !state.nonces.some(nonce => nonce.value === proof.claims.nonce && nonce.expiresAt > now()))
402
+ throw new TokenError('use_dpop_nonce', { 'DPoP-Nonce': await currentNonce(state) });
403
+ if (proof)
404
+ for (const key of keys().keys)
405
+ if (await calculateJwkThumbprint(key).catch(() => null) === proof.jkt)
406
+ throw new TokenError('invalid_dpop_proof');
407
+ const credentials = Object.create(null);
408
+ let oneTimeCode = '';
434
409
  try {
435
- let scopes = [];
436
410
  if (challenge) {
437
- if (Object.keys(input).length !== 1 || typeof input.code !== 'string' || !input.code || input.code.length > 4096)
411
+ if (params.has('scope') || config.fields.some(field => params.has(field.name)))
412
+ throw new TokenError('invalid_request');
413
+ oneTimeCode = params.get('one_time_code') ?? '';
414
+ if (!oneTimeCode || oneTimeCode.length > 4096)
438
415
  throw new TokenError('invalid_request');
439
- code = input.code;
440
416
  }
441
417
  else {
442
- scopes = signInScopes(typeof input.scope === 'string' ? input.scope : undefined, config.scopes);
443
- const fields = input.credentials;
444
- if (!fields || typeof fields !== 'object' || Array.isArray(fields) || Object.keys(fields).length !== config.fields.length ||
445
- !config.fields.every(field => Object.hasOwn(fields, field.name) && typeof fields[field.name] === 'string' && (fields[field.name].length > 0) && fields[field.name].length <= 4096))
418
+ if (params.has('one_time_code'))
446
419
  throw new TokenError('invalid_request');
447
- credentials = fields;
420
+ for (const field of config.fields) {
421
+ const value = params.get(field.name);
422
+ if (!value || value.length > 4096)
423
+ throw new TokenError('invalid_request');
424
+ credentials[field.name] = value;
425
+ }
448
426
  }
449
- const access = /^DPoP (\S+)$/i.exec(request.headers.get('authorization') ?? '')?.[1];
450
- if (!access || access.length > 4096)
451
- throw new TokenError('invalid_token');
452
- const proof = await verifyDpopProof(request.headers.get('dpop'), { method: 'POST', url: request.url, nowMs: now(), accessToken: access });
453
- const accessHash = tokenHash(access), proofKey = `proof ${proof.jkt} ${proof.claims.jti}`, state = await read({ kind: 'access', hash: accessHash });
454
- const authenticated = mediatedToken(state, accessHash, proof.jkt), context = { issuer, clientId: authenticated.session.clientId, userId: authenticated.session.userId, sessionId: authenticated.token.sessionId };
455
- const key = tokenHash(id), limitId = tokenHash(`attempts session ${context.clientId} ${context.sessionId}`), clientLimitId = tokenHash(`attempts client ${context.clientId}`), executionId = random(16);
456
- const selection = { kind: 'sign_in', ids: [key, limitId, clientLimitId, clientRevisionKey(context.clientId)], clientId: context.clientId, accessHash, replayKeys: [proofKey], at: now() };
427
+ const scopes = challenge ? [] : signInScopes(params.get('scope'), config.scopes), context = { issuer, clientId };
428
+ const replayKey = `assertion ${clientId} ${client.jti}`, proofKey = proof ? `proof ${proof.jkt} ${proof.claims.jti}` : null, limitId = tokenHash('credential attempts ' + clientId), executionId = random(16);
429
+ const selection = { kind: 'sign_in', ids: [key, limitId, clientRevisionKey(clientId)], clientId, replayKeys: [replayKey, ...(proofKey ? [proofKey] : [])], at: now() };
457
430
  const claim = await commit(selection, draft => {
458
- if (draft.generation !== state.generation)
459
- throw new TokenError('invalid_token');
460
- mediatedToken(draft, accessHash, proof.jkt);
461
- consumeReplay(draft, proofKey, (proof.claims.iat + PROOF_WINDOW_SECONDS) * 1000);
462
- let record = own(draft.signIns, key);
431
+ if (draft.generation !== state.generation || !current(client))
432
+ throw new TokenError('invalid_client');
433
+ consumeReplay(draft, replayKey, client.exp * 1000);
434
+ if (proof && proofKey)
435
+ consumeReplay(draft, proofKey, (proof.claims.iat + PROOF_WINDOW_SECONDS) * 1000);
436
+ let row = own(draft.signIns, key);
463
437
  if (challenge) {
464
- if (!record || record.kind !== 'mediated' || record.sessionId !== context.sessionId || record.clientId !== context.clientId || record.userId !== context.userId)
465
- throw new TokenError('sign_in_not_found');
466
- if (record.status === 'complete')
467
- throw new TokenError('sign_in_not_found');
468
- if (record.status === 'failed' || record.status === 'expired')
469
- return { status: record.status };
470
- if (record.expiresAt <= now()) {
471
- record.status = 'expired';
472
- return { status: 'expired' };
438
+ if (!row || row.kind !== 'mediated' || row.clientId !== clientId || !Object.hasOwn(row, 'resource'))
439
+ throw new TokenError('invalid_grant');
440
+ if (row.status === 'complete' || row.status === 'failed')
441
+ return { error: 'invalid_grant' };
442
+ if (row.status === 'verifying')
443
+ throw new TokenError('temporarily_unavailable', { 'Retry-After': '5' });
444
+ if (row.expiresAt <= now() || row.status === 'expired') {
445
+ row.status = 'expired';
446
+ return { error: 'expired_token' };
473
447
  }
474
- if (record.status === 'verifying')
475
- throw new TokenError('rate_limited', { 'Retry-After': '5' });
476
- if (record.attempts >= maxCodeAttempts) {
477
- record.status = 'failed';
478
- return { status: 'failed' };
448
+ if (row.attempts >= maxCodeAttempts) {
449
+ row.status = 'failed';
450
+ return { error: 'invalid_grant' };
479
451
  }
480
- record.attempts++;
481
- record.status = 'verifying';
482
- record.executionId = executionId;
483
- return { record };
484
- }
485
- // Count attempts before calling the host's credential backend, including failures.
486
- for (const [limitKey, maximum, sessionId] of [[limitId, maxAttempts, context.sessionId], [clientLimitId, maxClientAttempts, undefined]]) {
487
- const old = own(draft.signIns, limitKey);
488
- const window = old?.kind === 'attempts' && old.expiresAt > now() ? old : { kind: 'attempts', clientId: context.clientId, ...(sessionId ? { sessionId } : {}), count: 0, expiresAt: now() + 60_000 };
489
- if (window.count >= maximum)
490
- throw new TokenError('rate_limited', { 'Retry-After': String(Math.ceil((window.expiresAt - now()) / 1000)) });
491
- window.count++;
492
- draft.signIns[limitKey] = window;
452
+ row.attempts++;
453
+ row.status = 'verifying';
454
+ row.executionId = executionId;
455
+ return { record: row };
493
456
  }
494
- const pending = Object.values(draft.signIns).filter(row => row.expiresAt > now() && (row.kind === 'device' ? row.status === 'pending' || row.status === 'approved' : row.kind === 'mediated' && (row.status === 'pending' || row.status === 'verifying')));
495
- if ((draft.limits?.pending ?? pending.length) >= maxPending || (draft.limits?.pendingForClient ?? pending.filter(row => row.clientId === context.clientId).length) >= maxPendingPerClient)
457
+ const old = own(draft.signIns, limitId), window = old?.kind === 'attempts' && old.expiresAt > now() ? old : { kind: 'attempts', clientId, count: 0, expiresAt: now() + 60000 };
458
+ if (window.count >= Math.min(maxAttempts, maxClientAttempts))
459
+ throw new TokenError('rate_limited', { 'Retry-After': String(Math.ceil((window.expiresAt - now()) / 1000)) });
460
+ window.count++;
461
+ draft.signIns[limitId] = window;
462
+ const pending = Object.values(draft.signIns).filter(row => (row.kind === 'mediated' && row.status === 'verifying') || row.expiresAt > now() && ((row.kind === 'device' && (row.status === 'pending' || row.status === 'approved')) || (row.kind === 'mediated' && (row.status === 'pending' || row.status === 'verifying'))));
463
+ if ((draft.limits?.pending ?? pending.length) >= maxPending || (draft.limits?.pendingForClient ?? pending.filter(row => row.clientId === clientId).length) >= maxPendingPerClient)
496
464
  throw new TokenError('rate_limited', { 'Retry-After': '60' });
497
- record = { kind: 'mediated', clientId: context.clientId, userId: context.userId, sessionId: context.sessionId, scopes, expiresAt: now() + AUTHORIZATION_MS, status: 'verifying', attempts: 0, executionId, revision: revision(draft, clientRevisionKey(context.clientId)) };
465
+ const record = { kind: 'mediated', clientId, resource: resource ?? null, scopes, expiresAt: now() + AUTHORIZATION_MS, status: 'verifying', attempts: 0, executionId, revision: revision(draft, clientRevisionKey(clientId)) };
498
466
  draft.signIns[key] = record;
499
467
  return { record };
500
468
  });
501
- if ('status' in claim)
502
- return json(200, claim);
503
- const record = claim.record;
469
+ if ('error' in claim)
470
+ return json(400, claim);
471
+ const row = claim.record;
504
472
  let decision = null, correct = false;
505
473
  try {
506
- if (challenge) {
507
- correct = await config.verifyCode({ challengeId: record.challengeId, code }, context);
508
- if (typeof correct !== 'boolean')
509
- throw 0;
510
- }
474
+ if (challenge)
475
+ correct = await config.verifyCode({ challengeId: row.challengeId, code: oneTimeCode }, context);
511
476
  else
512
477
  decision = await config.authenticate(credentials, context);
513
478
  }
514
479
  catch {
515
- throw new TokenError('temporarily_unavailable');
480
+ throw new TokenError('temporarily_unavailable', { 'Retry-After': '5' });
516
481
  }
517
- const credentialValues = Object.values(credentials);
518
- if (decision && (typeof decision.accountId !== 'string' || !decision.accountId || decision.accountId.length > 256 || (decision.challenge && (typeof decision.challenge.id !== 'string' || !decision.challenge.id || typeof decision.challenge.sentTo !== 'string' || decision.challenge.sentTo.length > 512))))
482
+ if (challenge && typeof correct !== 'boolean')
519
483
  throw new TokenError('temporarily_unavailable');
520
- if (decision?.scopes !== undefined && (!Array.isArray(decision.scopes) || !decision.scopes.length || !decision.scopes.every(scope => record.scopes.includes(scope))))
484
+ if (decision && (typeof decision.accountId !== 'string' || !decision.accountId || decision.accountId.length > 256 || (decision.scopes !== undefined && (!Array.isArray(decision.scopes) || !decision.scopes.length || !decision.scopes.every(scope => row.scopes.includes(scope)))) || (decision.challenge && (typeof decision.challenge.id !== 'string' || !decision.challenge.id || typeof decision.challenge.sentTo !== 'string' || decision.challenge.sentTo.length > 512))))
521
485
  throw new TokenError('temporarily_unavailable');
522
- const accountId = decision?.accountId ?? record.accountId;
523
- const completionSelection = { ...selection, ids: [...selection.ids, ...(accountId ? [accountRevisionKey(context.clientId, accountId)] : [])] };
524
- const body = await commit(completionSelection, draft => {
525
- if (draft.generation !== state.generation)
526
- throw new TokenError('invalid_token');
527
- const current = own(draft.signIns, key), { token, session } = mediatedToken(draft, accessHash, proof.jkt);
528
- if (!current || current.kind !== 'mediated' || current.executionId !== executionId || current.status !== 'verifying')
529
- throw new TokenError('sign_in_not_found');
530
- if (accountId && !consentCurrent(draft, context.clientId, accountId, current.revision)) {
531
- current.accountId = accountId;
532
- current.status = 'failed';
486
+ const accountId = decision?.accountId ?? row.accountId;
487
+ const body = await commit({ ...selection, ids: [...selection.ids, ...(accountId ? [accountRevisionKey(clientId, accountId)] : [])] }, draft => {
488
+ if (draft.generation !== state.generation || !current(client))
489
+ throw new TokenError('invalid_client');
490
+ const completed = own(draft.signIns, key);
491
+ if (!completed || completed.kind !== 'mediated' || completed.executionId !== executionId || completed.status !== 'verifying')
492
+ throw new TokenError('invalid_grant');
493
+ if (accountId)
494
+ completed.accountId = accountId;
495
+ if (accountId && !consentCurrent(draft, clientId, accountId, completed.revision)) {
496
+ completed.status = 'failed';
533
497
  return { error: 'invalid_grant' };
534
498
  }
535
- if (current.expiresAt <= now()) {
536
- current.status = 'expired';
537
- return { status: 'expired' };
499
+ if (completed.expiresAt <= now()) {
500
+ completed.status = 'expired';
501
+ return { error: 'expired_token' };
538
502
  }
539
- if ((!challenge && !decision) || (challenge && !correct && current.attempts >= maxCodeAttempts)) {
540
- current.status = 'failed';
541
- return { status: 'failed' };
503
+ if ((!challenge && !decision) || (challenge && !correct && completed.attempts >= maxCodeAttempts)) {
504
+ completed.status = 'failed';
505
+ return { error: 'invalid_grant' };
542
506
  }
543
- if (decision)
544
- current.accountId = decision.accountId;
545
507
  if (decision?.scopes)
546
- current.scopes = [...new Set(decision.scopes)];
508
+ completed.scopes = [...new Set(decision.scopes)];
547
509
  if (decision?.challenge) {
548
- current.accountId = decision.accountId;
549
- current.challengeId = decision.challenge.id;
550
- current.sentTo = credentialValues.some(value => decision.challenge.sentTo.includes(value)) ? 'The company sent a one-time code.' : decision.challenge.sentTo;
510
+ completed.challengeId = decision.challenge.id;
511
+ completed.sentTo = Object.values(credentials).some(value => decision.challenge.sentTo.includes(value)) ? 'The company sent a one-time code.' : decision.challenge.sentTo;
551
512
  }
552
513
  if ((!challenge && decision?.challenge) || (challenge && !correct)) {
553
- current.status = 'pending';
554
- return { status: 'code_required', sign_in_id: id, code: { sent_to: current.sentTo }, expires_at: new Date(current.expiresAt).toISOString() };
514
+ completed.status = 'pending';
515
+ return { error: 'code_required', sign_in_id: id, sent_to: completed.sentTo, expires_in: Math.ceil((completed.expiresAt - now()) / 1000) };
555
516
  }
556
- const result = issueSignIn(draft, token.sessionId, session, decision?.accountId ?? current.accountId, current.scopes, proof.jkt, token.resource ?? undefined);
557
- current.status = 'complete';
558
- return { status: 'complete', ...result.body };
517
+ const result = issueSignIn(draft, clientId, completed.accountId, completed.scopes, proof?.jkt ?? '', completed.resource ?? undefined);
518
+ completed.status = 'complete';
519
+ completed.grantId = result.grantId;
520
+ return result.body;
559
521
  });
560
522
  return json('error' in body ? 400 : 200, body);
561
523
  }
562
524
  finally {
563
- for (const key of Object.keys(credentials))
564
- delete credentials[key];
565
- for (const key of Object.keys(input))
566
- delete input[key];
567
- code = '';
525
+ for (const name of Object.keys(credentials))
526
+ delete credentials[name];
527
+ for (const name of config.fields.map(field => field.name))
528
+ params.delete(name);
529
+ params.delete('one_time_code');
530
+ oneTimeCode = '';
568
531
  }
569
532
  }
570
- async function exchangeToken(request, params) {
533
+ async function accountToken(request, params) {
571
534
  const { clientId, clientAssertion } = clientCredentials(params);
572
- const subject = params.get('subject_token'), resource = params.get('resource');
573
- if (!subject || params.get('subject_token_type') !== 'urn:ietf:params:oauth:token-type:access_token' || ['assertion', 'session_id', 'refresh_token', 'code', 'actor_token', 'actor_token_type'].some(key => params.has(key)) || (params.has('requested_token_type') && params.get('requested_token_type') !== 'urn:ietf:params:oauth:token-type:access_token'))
535
+ const grantType = params.get('grant_type'), code = params.get('code'), refresh = params.get('refresh_token');
536
+ const verifier = params.get('code_verifier'), redirect = params.get('redirect_uri'), resource = params.get('resource');
537
+ if (params.has('session_id') || params.has('assertion'))
574
538
  throw new TokenError('invalid_request');
575
- if (!resource || !isBearerResource(resource))
539
+ if (resource !== undefined && !resources.includes(resource))
576
540
  throw new TokenError('invalid_target');
577
- const state = await read();
578
- const proof = await verifyDpopProof(request.headers.get('dpop'), { method: 'POST', url: options.tokenEndpoint, nowMs: now() });
579
- if (requireNonce && !state.nonces.some(nonce => nonce.value === proof.claims.nonce && nonce.expiresAt > now()))
580
- throw new TokenError('use_dpop_nonce', { 'DPoP-Nonce': await currentNonce(state) });
581
- const { client, keys } = await authenticateClient(clientId, clientAssertion);
582
- for (const key of keys().keys)
583
- if (await calculateJwkThumbprint(key).catch(() => null) === proof.jkt)
584
- throw new TokenError('invalid_dpop_proof');
585
- const replayKey = 'assertion ' + clientId + ' ' + client.jti, proofKey = 'proof ' + proof.jkt + ' ' + proof.claims.jti;
586
- const body = await commit({ kind: 'sign_in', ids: [], sessionKey: proof.jkt, clientId, accessHash: tokenHash(subject), replayKeys: [replayKey, proofKey], at: now() }, draft => {
587
- const at = now(), token = own(draft.accessTokens, tokenHash(subject)), session = token && own(draft.sessions, token.sessionId);
588
- if (draft.generation !== state.generation || !current(client))
589
- throw new TokenError('invalid_client');
590
- if ((proof.claims.iat + PROOF_WINDOW_SECONDS) * 1000 <= at)
591
- throw new TokenError('invalid_dpop_proof');
592
- if (!token || !session || !token.jkt || token.jkt !== proof.jkt || own(draft.sessionKeys, proof.jkt) !== token.sessionId || session.clientId !== clientId || token.expiresAt <= at || session.expiresAt <= at || session.grantId !== token.grantId || (token.grantId !== null && !grantLive(own(draft.grants, token.grantId), at)))
593
- throw new TokenError('invalid_grant');
594
- const scopes = params.has('scope') ? signInScopes(params.get('scope'), token.scopes) : [...token.scopes];
595
- if ((own(draft.replay, proofKey) ?? 0) > at)
596
- throw new TokenError('invalid_dpop_proof');
597
- consumeReplay(draft, replayKey, client.exp * 1000);
598
- consumeReplay(draft, proofKey, (proof.claims.iat + PROOF_WINDOW_SECONDS) * 1000);
599
- const access = random(32), expiresAt = Math.min(at + lifetimeMs, session.expiresAt);
600
- draft.accessTokens[tokenHash(access)] = { sessionId: token.sessionId, grantId: token.grantId, scopes, resource, jkt: '', expiresAt };
601
- return { access_token: access, issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', token_type: 'Bearer', expires_in: Math.floor((expiresAt - at) / 1000), scope: scopes.join(' ') };
602
- });
603
- return json(200, body);
604
- }
605
- async function issue(request) {
606
- const params = await readParams(request);
607
- const grantType = params.get('grant_type');
608
- if (!grantType)
541
+ if (grantType === 'authorization_code' && (!code || !verifier || !redirect || !/^[A-Za-z0-9._~-]{43,128}$/.test(verifier)))
609
542
  throw new TokenError('invalid_request');
610
- if (grantType === 'urn:ietf:params:oauth:grant-type:token-exchange')
611
- return exchangeToken(request, params);
612
- if (grantType === DEVICE_GRANT)
613
- return pollDevice(request, params);
614
- if (grantType !== JWT_BEARER && grantType !== 'authorization_code' && grantType !== 'refresh_token')
615
- throw new TokenError('unsupported_grant_type');
616
- const { clientId, clientAssertion } = clientCredentials(params);
617
- const assertion = params.get('assertion');
618
- const code = params.get('code');
619
- const redirectUri = params.get('redirect_uri');
620
- const codeVerifier = params.get('code_verifier');
621
- const refreshToken = params.get('refresh_token');
622
- const resource = params.get('resource');
623
- const scope = params.get('scope');
624
- if (params.has('session_id'))
625
- throw new TokenError('invalid_request');
626
- // Each grant takes only its own credential, so a stray assertion can never stand in for a code or Account Token.
627
- if (grantType !== JWT_BEARER && assertion !== undefined)
543
+ if (grantType === 'refresh_token' && !refresh)
628
544
  throw new TokenError('invalid_request');
629
- if (grantType === JWT_BEARER) {
630
- // A missing assertion is invalid_grant (spec 4.2 error table).
631
- if (!assertion)
632
- throw new TokenError('invalid_grant');
633
- // Signed-out sessions hold no account scopes.
634
- if (scope?.trim())
635
- throw new TokenError('invalid_scope');
636
- }
637
- else if (grantType === 'authorization_code') {
638
- if (!code || !redirectUri || !codeVerifier)
639
- throw new TokenError('invalid_request');
640
- // RFC 7636 4.1: 43-128 unreserved characters.
641
- if (!/^[A-Za-z0-9._~-]{43,128}$/.test(codeVerifier))
642
- throw new TokenError('invalid_request');
643
- }
644
- else if (!refreshToken)
645
- throw new TokenError('invalid_request');
646
- // scope narrows only (spec 4.3, 4.8); an empty request asks for nothing and is refused.
647
- const requested = scope === undefined || grantType === JWT_BEARER ? undefined : [...new Set(scope.split(' ').filter(Boolean))];
648
- if (requested?.length === 0)
649
- throw new TokenError('invalid_scope');
650
- // Resources are exact configured identifiers, chosen per token request; grants are never narrowed to one.
651
- if (resource !== undefined && (!resources.includes(resource) || isBearerResource(resource)))
652
- throw new TokenError('invalid_target');
653
545
  const state = await read();
654
- const generation = state.generation;
655
- // htu is checked against the configured token endpoint, never the incoming Request URL.
656
- const proof = await verifyDpopProof(request.headers.get('dpop'), { method: 'POST', url: options.tokenEndpoint, nowMs: now() });
657
- // Nonce challenge precedes any network fetch and any replay consumption.
658
- if (requireNonce && !state.nonces.some(nonce => nonce.value === proof.claims.nonce && nonce.expiresAt > now()))
546
+ const proof = isBearerResource(resource) ? null : await verifyDpopProof(request.headers.get('dpop'), { method: 'POST', url: options.tokenEndpoint, nowMs: now() });
547
+ if (proof && requireNonce && !state.nonces.some(nonce => nonce.value === proof.claims.nonce && nonce.expiresAt > now()))
659
548
  throw new TokenError('use_dpop_nonce', { 'DPoP-Nonce': await currentNonce(state) });
660
- const { client, verify, keys } = await authenticateClient(clientId, clientAssertion);
661
- let session;
662
- if (grantType === JWT_BEARER) {
663
- try {
664
- session = await verify(assertion);
665
- }
666
- catch (error) {
667
- throw error instanceof TokenError ? error : new TokenError('invalid_grant');
668
- }
669
- if (session.iss !== clientId || session.aud !== issuer || session.sub.length > 256 || !current(session) || session.jti === client.jti)
670
- throw new TokenError('invalid_grant');
671
- }
672
- // Identity keys and DPoP keys must differ (spec 4.1).
673
- for (const key of keys().keys)
674
- if (await calculateJwkThumbprint(key).catch(() => null) === proof.jkt)
675
- throw new TokenError('invalid_dpop_proof');
676
- const body = await commit({ kind: 'token', clientId, sessionKey: proof.jkt, ...(code ? { codeHash: tokenHash(code) } : {}), ...(refreshToken ? { refreshHash: tokenHash(refreshToken) } : {}), replayKeys: [...(session ? [client, session] : [client]).map(claims => `assertion ${clientId} ${claims.jti}`), `proof ${proof.jkt} ${proof.claims.jti}`], at: now() }, draft => {
677
- // A reset between verification and commit must not recreate state from a stale verification.
678
- if (draft.generation !== generation)
679
- throw new TokenError('invalid_grant');
680
- // Re-check freshness at commit time with the same clock used to prune, so a slow fetch cannot outlive its replay entry.
549
+ const { client, keys } = await authenticateClient(clientId, clientAssertion);
550
+ if (proof)
551
+ for (const key of keys().keys)
552
+ if (await calculateJwkThumbprint(key).catch(() => null) === proof.jkt)
553
+ throw new TokenError('invalid_dpop_proof');
554
+ const replayKey = `assertion ${clientId} ${client.jti}`, proofKey = proof ? `proof ${proof.jkt} ${proof.claims.jti}` : null;
555
+ const body = await commit({ kind: 'token', clientId, sessionKey: '', ...(code ? { codeHash: tokenHash(code) } : {}), ...(refresh ? { refreshHash: tokenHash(refresh) } : {}), replayKeys: [replayKey, ...(proofKey ? [proofKey] : [])], at: now() }, draft => {
681
556
  const at = now();
682
- if ((proof.claims.iat + PROOF_WINDOW_SECONDS) * 1000 < at)
683
- throw new TokenError('invalid_dpop_proof');
684
- if (client.exp * 1000 <= at)
557
+ if (draft.generation !== state.generation || !current(client))
685
558
  throw new TokenError('invalid_client');
686
- if (session && session.exp * 1000 <= at)
687
- throw new TokenError('invalid_grant');
688
- prune(draft, at);
689
- // Client and session assertions share one jti namespace per client.
690
- const assertions = session ? [client, session] : [client];
691
- const assertionKeys = assertions.map(claims => `assertion ${clientId} ${claims.jti}`);
692
- const proofKey = `proof ${proof.jkt} ${proof.claims.jti}`;
693
- if (assertionKeys.some(key => own(draft.replay, key) !== undefined))
694
- throw new TokenError('invalid_grant');
695
- if (own(draft.replay, proofKey) !== undefined)
696
- throw new TokenError('invalid_dpop_proof');
697
- assertions.forEach((claims, index) => { draft.replay[assertionKeys[index]] = claims.exp * 1000; });
698
- draft.replay[proofKey] = (proof.claims.iat + PROOF_WINDOW_SECONDS) * 1000;
699
- const sessionId = own(draft.sessionKeys, proof.jkt);
700
- const existing = (userId) => {
701
- const found = sessionId === undefined ? undefined : own(draft.sessions, sessionId);
702
- if (!found || !(found.expiresAt > at) || found.clientId !== clientId || (userId !== undefined && found.userId !== userId))
703
- throw new TokenError('invalid_grant');
704
- return found;
705
- };
706
- const create = (userId) => {
707
- // Pruned above, so these are live; a slot frees when the soonest of them ends (spec 4.2 rate_limited).
708
- const live = Object.values(draft.sessions).filter(record => record.clientId === clientId);
709
- if ((draft.limits?.sessions ?? live.length) >= maxSessionsPerClient)
710
- throw new TokenError('rate_limited', { 'Retry-After': String(Math.ceil(((draft.limits?.earliestSessionExpiry ?? Math.min(...live.map(record => record.expiresAt))) - at) / 1000)) });
711
- const id = `ses_${random(16)}`;
712
- draft.sessionKeys[proof.jkt] = id;
713
- return [id, draft.sessions[id] = { clientId, userId, ...(session?.agent_id === undefined ? {} : { agentId: session.agent_id }), version: 1, createdAt: at, expiresAt: at, accountId: null, grantId: null }];
714
- };
715
- const accessToken = random(32);
716
- const grant = (id, record, grantId, scopes) => {
717
- record.expiresAt = at + sessionMs;
718
- draft.accessTokens[tokenHash(accessToken)] = { sessionId: id, grantId, scopes, resource: resource ?? null, jkt: proof.jkt, expiresAt: at + lifetimeMs };
719
- return { access_token: accessToken, token_type: 'DPoP', expires_in: lifetimeSeconds, ...(grantId === null ? {} : { scope: scopes.join(' ') }) };
720
- };
721
- // Signs the Session in to the lineage and rotates its Account Token: consume, successor and session update in this one commit.
722
- const signIn = (id, record, grantId) => {
723
- const lineage = draft.grants[grantId];
724
- // Sticky account (spec 4.4): a Session once signed in to another account never switches, and no tokens are issued.
725
- if (record.accountId !== null && record.accountId !== lineage.accountId)
726
- throw new TokenError('account_mismatch');
727
- const scopes = requested ?? lineage.scopes;
728
- if (!scopes.every(item => lineage.scopes.includes(item)))
729
- throw new TokenError('invalid_scope');
730
- const successor = random(32);
731
- // Retain ancestors until the fixed grant expiry so every replay can revoke its descendants.
732
- lineage.previousHash = lineage.refreshHash;
733
- lineage.refreshHash = tokenHash(successor);
734
- draft.refreshTokens[lineage.refreshHash] = grantId;
735
- record.accountId = lineage.accountId;
736
- record.grantId = grantId;
737
- return { ...grant(id, record, grantId, scopes), refresh_token: successor, refresh_token_expires_in: Math.floor((lineage.expiresAt - at) / 1000) };
738
- };
739
- if (grantType === JWT_BEARER) {
740
- if (!session)
741
- throw new TokenError('invalid_grant');
742
- if (sessionId === undefined)
743
- return grant(...create(session.sub), null, []);
744
- const record = existing(session.sub);
745
- if (record.agentId !== session.agent_id)
746
- throw new TokenError('invalid_grant');
747
- // A signed-in Session is renewed with its Account Token instead (spec 4.2).
748
- if (record.grantId !== null && grantLive(own(draft.grants, record.grantId), at))
749
- throw new TokenError('invalid_grant');
750
- record.grantId = null;
751
- return grant(sessionId, record, null, []);
559
+ if ((own(draft.replay, replayKey) ?? 0) > at)
560
+ throw new TokenError('invalid_client');
561
+ consumeReplay(draft, replayKey, client.exp * 1000);
562
+ if (proof && proofKey) {
563
+ if ((proof.claims.iat + PROOF_WINDOW_SECONDS) * 1000 <= at || (own(draft.replay, proofKey) ?? 0) > at)
564
+ throw new TokenError('invalid_dpop_proof');
565
+ consumeReplay(draft, proofKey, (proof.claims.iat + PROOF_WINDOW_SECONDS) * 1000);
752
566
  }
753
567
  if (grantType === 'authorization_code') {
754
- const record = own(draft.codes, tokenHash(code));
755
- if (!record || record.expiresAt <= at || record.clientId !== clientId || record.sessionKey !== proof.jkt)
568
+ const row = own(draft.codes, tokenHash(code));
569
+ if (!row || row.sessionKey === undefined || row.clientId !== clientId || row.expiresAt <= at || (row.sessionKey !== null && row.sessionKey !== proof?.jkt))
756
570
  throw new TokenError('invalid_grant');
757
- if (record.grantId !== null) {
758
- revokeGrant(draft, record.grantId);
571
+ if (row.grantId !== null) {
572
+ revokeGrant(draft, row.grantId);
759
573
  return null;
760
574
  }
761
- if (record.redirectUri !== redirectUri || tokenHash(codeVerifier) !== record.codeChallenge)
762
- throw new TokenError('invalid_grant');
763
- if (!consentCurrent(draft, record.clientId, record.accountId, record.revision))
575
+ if (row.redirectUri !== redirect || tokenHash(verifier) !== row.codeChallenge || !consentCurrent(draft, clientId, row.accountId, row.revision))
764
576
  throw new TokenError('invalid_grant');
765
- const target = existing();
766
- const grantId = `grt_${random(16)}`;
767
- draft.grants[grantId] = { clientId, userId: target.userId, accountId: record.accountId, scopes: record.scopes, expiresAt: at + accountLifetimeMs, revoked: false, refreshHash: '', previousHash: '' };
768
- record.grantId = grantId;
769
- return signIn(sessionId, target, grantId);
577
+ const issued = issueSignIn(draft, clientId, row.accountId, row.scopes, proof?.jkt ?? '', resource);
578
+ row.grantId = issued.grantId;
579
+ return issued.body;
770
580
  }
771
- const presented = tokenHash(refreshToken);
772
- const grantId = own(draft.refreshTokens, presented);
773
- const lineage = grantId === undefined ? undefined : own(draft.grants, grantId);
774
- // Reuse of any ancestor Account Token (e.g. a retry after a lost response) revokes the lineage, so its Sessions
775
- // continue signed out instead of staying bound to a successor nobody holds (RFC 9700 4.14.2).
776
- if (lineage && lineage.clientId === clientId && presented !== lineage.refreshHash) {
581
+ const hash = tokenHash(refresh), grantId = own(draft.refreshTokens, hash), grant = grantId ? own(draft.grants, grantId) : undefined;
582
+ if (grant && grant.clientId === clientId && grant.refreshHash !== hash) {
777
583
  revokeGrant(draft, grantId);
778
584
  return null;
779
585
  }
780
- // A rotated Account Token never issues another successor.
781
- if (!lineage || !grantLive(lineage, at) || lineage.clientId !== clientId || lineage.refreshHash !== presented)
586
+ if (!grant || !grantLive(grant, at) || grant.clientId !== clientId)
782
587
  throw new TokenError('invalid_grant');
783
- return sessionId === undefined ? signIn(...create(lineage.userId), grantId) : signIn(sessionId, existing(lineage.userId), grantId);
588
+ const token = random(32);
589
+ if (params.has('scope'))
590
+ signInScopes(params.get('scope'), grant.scopes);
591
+ grant.previousHash = grant.refreshHash;
592
+ grant.refreshHash = tokenHash(token);
593
+ const scopes = grantType === 'refresh_token' && params.has('scope') ? signInScopes(params.get('scope'), grant.scopes) : [...grant.scopes];
594
+ draft.refreshTokens[grant.refreshHash] = grantId;
595
+ const access = random(32);
596
+ draft.accessTokens[tokenHash(access)] = { clientId, accountId: grant.accountId, sessionId: '', grantId: grantId, scopes, resource: resource ?? null, jkt: proof?.jkt ?? '', expiresAt: at + lifetimeMs };
597
+ return { access_token: access, token_type: proof ? 'DPoP' : 'Bearer', expires_in: lifetimeSeconds, refresh_token: token, refresh_token_expires_in: Math.floor((grant.expiresAt - at) / 1000), scope: scopes.join(' ') };
784
598
  });
785
- // A reused code or previous Account Token revoked its lineage in the commit above.
786
599
  if (body === null)
787
600
  throw new TokenError('invalid_grant');
788
601
  return json(200, body);
789
602
  }
603
+ async function issue(request) {
604
+ const params = await readParams(request);
605
+ const grantType = params.get('grant_type');
606
+ if (!grantType)
607
+ throw new TokenError('invalid_request');
608
+ if (grantType === DEVICE_GRANT)
609
+ return pollDevice(request, params);
610
+ if (grantType === MEDIATED_GRANT)
611
+ return mediatedSignIn(request, params);
612
+ if (grantType === 'authorization_code' || grantType === 'refresh_token')
613
+ return accountToken(request, params);
614
+ throw new TokenError('unsupported_grant_type');
615
+ }
790
616
  // RFC 7009 with private_key_jwt client authentication; unknown and foreign tokens succeed without changing any grant.
791
617
  async function revokeToken(request) {
792
618
  const params = await readParams(request);
@@ -826,7 +652,7 @@ export function createReferenceIssuer(options) {
826
652
  if (!code)
827
653
  throw error;
828
654
  const status = code === 'invalid_client' || code === 'invalid_token' ? 401 : code === 'rate_limited' ? 429 : code === 'sign_in_not_found' ? 404 : code === 'temporarily_unavailable' ? 503 : 400;
829
- return json(status, { error: code }, error instanceof TokenError ? error.headers : {});
655
+ return json(status, { error: code === 'rate_limited' ? 'temporarily_unavailable' : code }, { ...(code === 'rate_limited' ? { 'Retry-After': '60' } : code === 'temporarily_unavailable' ? { 'Retry-After': '5' } : {}), ...(error instanceof TokenError ? error.headers : {}) });
830
656
  }
831
657
  }
832
658
  // Plain checks over one issuer snapshot; only the policy wrappers below take the coordinator, once.
@@ -844,9 +670,7 @@ export function createReferenceIssuer(options) {
844
670
  const policy = {
845
671
  withCurrent: (principal, scopes, action) => options.coordinator.run(async () => {
846
672
  const state = await read({ kind: 'principal', sessionId: principal.sessionId, ...(principal.grantId ? { grantId: principal.grantId } : {}) });
847
- const session = own(state.sessions, principal.sessionId);
848
- if (principal.issuer !== issuer || principal.issuerGeneration !== state.generation || !session || session.clientId !== principal.clientId
849
- || principal.tokenExpiresAt <= now() || session.expiresAt <= now() || session.userId !== principal.userId || !liveGrant(state, principal))
673
+ if (principal.issuer !== issuer || principal.issuerGeneration !== state.generation || principal.tokenExpiresAt <= now() || !liveGrant(state, principal))
850
674
  throw new AuthorityError('invalid_token');
851
675
  requireScopes(principal.scopes, scopes);
852
676
  return action();
@@ -865,27 +689,9 @@ export function createReferenceIssuer(options) {
865
689
  }),
866
690
  };
867
691
  return {
868
- browserSessions: {
869
- async current(binding) { return browserSession(await read({ kind: 'browser', sessionId: binding.sessionId }), binding); },
870
- async join(sessionKey) {
871
- const state = await read({ kind: 'sign_in', ids: [], sessionKey, replayKeys: [], at: now() });
872
- const id = own(state.sessionKeys, sessionKey), session = id && own(state.sessions, id);
873
- if (!id || !session)
874
- throw new AuthorityError('invalid_token');
875
- return browserSession(state, { clientId: session.clientId, userId: session.userId, sessionId: id });
876
- },
877
- },
878
692
  token: request => respond(request, issue),
879
693
  revoke: request => respond(request, revokeToken),
880
- async sessionForKey(binding) {
881
- const state = await read({ kind: 'sign_in', ids: [], sessionKey: binding.sessionKey, replayKeys: [], at: now() });
882
- const sessionId = own(state.sessionKeys, binding.sessionKey);
883
- if (!sessionId)
884
- throw new AuthorityError('invalid_token');
885
- return browserSession(state, { clientId: binding.clientId, userId: binding.userId, sessionId });
886
- },
887
694
  deviceAuthorization: request => respond(request, startDevice),
888
- mediatedSignIn: request => respond(request, mediatedSignIn),
889
695
  async beginDeviceAuthorization(userCode, browserBinding) {
890
696
  if (!options.device || typeof userCode !== 'string' || typeof browserBinding !== 'string' || browserBinding.length < 22)
891
697
  throw new AuthorizationError('invalid_request');
@@ -959,8 +765,8 @@ export function createReferenceIssuer(options) {
959
765
  const codeChallenge = params.get('code_challenge') ?? '';
960
766
  if (params.get('code_challenge_method') !== 'S256' || !/^[A-Za-z0-9_-]{43}$/.test(codeChallenge))
961
767
  throw fail('invalid_request');
962
- const sessionKey = params.get('dpop_jkt');
963
- if (!sessionKey || !/^[A-Za-z0-9_-]{43}$/.test(sessionKey))
768
+ const sessionKey = params.get('dpop_jkt') ?? null;
769
+ if (sessionKey !== null && !/^[A-Za-z0-9_-]{43}$/.test(sessionKey))
964
770
  throw fail('invalid_request');
965
771
  const scopes = [...new Set((params.get('scope') ?? '').split(' ').filter(Boolean))];
966
772
  if (!scopes.length || !scopes.every(scope => options.supportedScopes.includes(scope)))
@@ -995,7 +801,7 @@ export function createReferenceIssuer(options) {
995
801
  const accountId = 'accountId' in decision && typeof decision.accountId === 'string' ? decision.accountId : undefined;
996
802
  const href = await commit({ kind: 'authorization', id, ...(snapshot && accountId ? { revocationKey: accountRevisionKey(snapshot.clientId, accountId) } : {}) }, draft => {
997
803
  const record = own(draft.authorizations, id);
998
- if (!record || !record.sessionKey || record.expiresAt <= now() || record.browserHash !== tokenHash(browserBinding))
804
+ if (!record || record.sessionKey === undefined || record.expiresAt <= now() || record.browserHash !== tokenHash(browserBinding))
999
805
  throw new AuthorizationError('invalid_request');
1000
806
  const denied = 'denied' in decision && decision.denied === true;
1001
807
  const { accountId, scopes } = decision;
@@ -1031,9 +837,9 @@ export function createReferenceIssuer(options) {
1031
837
  issuer, token_endpoint: options.tokenEndpoint, revocation_endpoint: options.revocationEndpoint,
1032
838
  ...(options.authorizationEndpoint ? { authorization_endpoint: options.authorizationEndpoint, response_types_supported: ['code'], code_challenge_methods_supported: ['S256'] } : {}),
1033
839
  ...(options.device ? { device_authorization_endpoint: options.device.endpoint } : {}),
1034
- dpop_signing_alg_values_supported: ['ES256'], client_id_metadata_document_supported: true,
840
+ dpop_signing_alg_values_supported: ['ES256'], token_endpoint_auth_signing_alg_values_supported: [...ASSERTION_ALGORITHMS], client_id_metadata_document_supported: true,
1035
841
  protected_resources: [...options.protectedResources],
1036
- grant_types_supported: [JWT_BEARER, 'refresh_token', ...(mcpResources.some(entry => !entry.dpop) ? ['urn:ietf:params:oauth:grant-type:token-exchange'] : []), ...(options.authorizationEndpoint ? ['authorization_code'] : []), ...(options.device ? [DEVICE_GRANT] : [])],
842
+ grant_types_supported: ['refresh_token', ...(options.mediated ? [MEDIATED_GRANT] : []), ...(options.authorizationEndpoint ? ['authorization_code'] : []), ...(options.device ? [DEVICE_GRANT] : [])],
1037
843
  scopes_supported: [...options.supportedScopes],
1038
844
  };
1039
845
  },
@@ -1041,24 +847,13 @@ export function createReferenceIssuer(options) {
1041
847
  issuer,
1042
848
  revocationVisibility: 'immediate: every introspection and policy check reads the shared issuer store; policy checks are ordered by the shared coordinator',
1043
849
  async introspect(token, resource) {
1044
- const state = await read({ kind: 'access', hash: tokenHash(token) });
1045
- const at = now();
1046
- const record = own(state.accessTokens, tokenHash(token));
1047
- const session = record && own(state.sessions, record.sessionId);
1048
- if (!record || !session || !(session.expiresAt > at) || record.expiresAt <= at || record.resource !== (resource ?? null))
850
+ const state = await read({ kind: 'access', hash: tokenHash(token) }), record = own(state.accessTokens, tokenHash(token));
851
+ if (!record || !record.grantId || record.expiresAt <= now() || record.resource !== (resource ?? null))
1049
852
  throw new AuthorityError('invalid_token');
1050
- // A signed-in token stops working once its sign-in is revoked or expires; a signed-out one stays signed out.
1051
- const grant = record.grantId === null ? undefined : own(state.grants, record.grantId);
1052
- if (record.grantId !== null && !grantLive(grant, at))
853
+ const grant = own(state.grants, record.grantId);
854
+ if (!grantLive(grant, now()) || (record.clientId !== undefined && record.clientId !== grant.clientId) || (record.accountId !== undefined && record.accountId !== grant.accountId))
1053
855
  throw new AuthorityError('invalid_token');
1054
- return {
1055
- principal: {
1056
- issuer, clientId: session.clientId, userId: session.userId, sessionId: record.sessionId,
1057
- ...(grant ? { accountId: grant.accountId, grantId: record.grantId } : {}),
1058
- scopes: [...record.scopes], tokenExpiresAt: record.expiresAt, sessionVersion: session.version, issuerGeneration: state.generation,
1059
- },
1060
- dpopJkt: record.jkt || null,
1061
- };
856
+ return { principal: { issuer, clientId: grant.clientId, userId: '', sessionId: '', accountId: grant.accountId, grantId: record.grantId, scopes: [...record.scopes], tokenExpiresAt: record.expiresAt, sessionVersion: 0, issuerGeneration: state.generation }, dpopJkt: record.jkt || null };
1062
857
  },
1063
858
  policy,
1064
859
  },