@poppykit/server 0.3.1 → 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 (57) hide show
  1. package/API.md +11 -3
  2. package/AUTH.md +120 -95
  3. package/CHANGELOG.md +18 -0
  4. package/CONFORMANCE.md +73 -0
  5. package/CONTRIBUTING.md +13 -10
  6. package/DELIVERY.md +21 -1
  7. package/INTEGRATIONS.md +72 -69
  8. package/OPERATIONS.md +142 -0
  9. package/README.md +14 -17
  10. package/RELEASING.md +2 -0
  11. package/SECURITY.md +2 -0
  12. package/STORAGE.md +57 -2
  13. package/dist/adapter-contract.d.ts +18 -0
  14. package/dist/adapter-contract.js +90 -11
  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 -13
  22. package/dist/browser.js +80 -46
  23. package/dist/conversation/service.d.ts +18 -8
  24. package/dist/conversation/service.js +60 -28
  25. package/dist/feedback.d.ts +1 -1
  26. package/dist/feedback.js +5 -5
  27. package/dist/index.d.ts +6 -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 +51 -22
  32. package/dist/network/discovery.js +159 -69
  33. package/dist/network/redirect.d.ts +3 -0
  34. package/dist/network/redirect.js +38 -0
  35. package/dist/network/tasks.d.ts +23 -0
  36. package/dist/network/tasks.js +45 -0
  37. package/dist/operations.d.ts +115 -0
  38. package/dist/operations.js +212 -0
  39. package/dist/postgres.d.ts +6 -0
  40. package/dist/postgres.js +92 -6
  41. package/dist/protocol/operations.d.ts +51 -0
  42. package/dist/protocol/operations.js +98 -0
  43. package/dist/protocol/session.d.ts +28 -0
  44. package/dist/protocol/session.js +93 -0
  45. package/dist/protocol/sse.d.ts +1 -0
  46. package/dist/protocol/sse.js +1 -1
  47. package/dist/protocol/wire.d.ts +20 -15
  48. package/dist/protocol/wire.js +70 -22
  49. package/dist/reference-auth/index.d.ts +17 -12
  50. package/dist/reference-auth/index.js +252 -386
  51. package/dist/reference-auth/store.d.ts +2 -1
  52. package/dist/sqlite.d.ts +11 -0
  53. package/dist/sqlite.js +104 -6
  54. package/examples/operations.mjs +18 -0
  55. package/examples/quickstart.mjs +10 -6
  56. package/migrations/RETIREMENT.md +77 -0
  57. package/package.json +5 -2
@@ -1,6 +1,8 @@
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, MEDIATED_GRANT, parseMediatedSignIn } from '../network/discovery.js';
5
+ import { registeredRedirect } from '../network/redirect.js';
4
6
  import { secureUrl } from '../network/transport.js';
5
7
  import { own } from '../store.js';
6
8
  import { clientRevisionKey, accountRevisionKey } from './store.js';
@@ -20,16 +22,14 @@ export class AuthorizationError extends Error {
20
22
  this.name = 'AuthorizationError';
21
23
  }
22
24
  }
23
- const JWT_BEARER = 'urn:ietf:params:oauth:grant-type:jwt-bearer';
24
25
  const CLIENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
25
26
  const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
26
- const ASSERTION_ALGORITHMS = ['ES256', 'RS256'];
27
+ const ASSERTION_ALGORITHMS = ['ES256', 'RS256', 'PS384'];
27
28
  const NONCE_SECONDS = 300;
28
29
  const MAX_FORM_BYTES = 65536;
29
30
  const AUTHORIZATION_MS = 600_000;
30
31
  const CODE_MS = 60_000;
31
32
  // A Session ends a day after the last token issued for it (spec 4.2: the Company decides how long a Session lasts).
32
- const SESSION_IDLE_SECONDS = 86_400;
33
33
  const random = (bytes) => randomBytes(bytes).toString('base64url');
34
34
  const tokenHash = (token) => createHash('sha256').update(token).digest('base64url');
35
35
  const grantLive = (grant, at) => !!grant && !grant.revoked && grant.expiresAt > at;
@@ -41,12 +41,32 @@ function callback(redirectUri, fields) {
41
41
  }
42
42
  // Shape changes bump the version; other versions are refused instead of misread.
43
43
  function checked(state) {
44
- if (state.version !== 3)
45
- throw new Error('unsupported issuer state version');
44
+ upgradeIssuerState(state);
46
45
  return state;
47
46
  }
47
+ /** Run once over the complete namespace, under the adapter's writer lock. */
48
+ export function upgradeIssuerState(state) {
49
+ if (state.version === 5)
50
+ return;
51
+ if (state.version !== 3 && state.version !== 4)
52
+ throw new Error('unsupported issuer state version');
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;
67
+ }
48
68
  export function initialIssuerState() {
49
- return { version: 3, generation: random(16), sessions: {}, 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: [] };
50
70
  }
51
71
  class TokenError extends Error {
52
72
  code;
@@ -107,6 +127,14 @@ export function createReferenceIssuer(options) {
107
127
  throw new TypeError('issuer');
108
128
  secureUrl(options.tokenEndpoint);
109
129
  secureUrl(options.revocationEndpoint);
130
+ if (!options.protectedResources.length)
131
+ throw new TypeError('protectedResources');
132
+ options.protectedResources.forEach(value => secureUrl(value));
133
+ companyScopes(options.supportedScopes, true);
134
+ if (options.authorizationEndpoint)
135
+ secureUrl(options.authorizationEndpoint);
136
+ if (options.mediated && companyScopes(options.mediated.scopes).some(scope => !options.supportedScopes.includes(scope)))
137
+ throw new TypeError('mediated.scopes');
110
138
  const positive = (value, fallback, name) => {
111
139
  const result = value ?? fallback;
112
140
  if (!Number.isSafeInteger(result) || result <= 0)
@@ -118,21 +146,18 @@ export function createReferenceIssuer(options) {
118
146
  const accountLifetimeMs = positive(options.accountLifetimeSeconds, 2_592_000, 'accountLifetimeSeconds') * 1000;
119
147
  const maxPending = positive(options.maxPendingAuthorizations, 1000, 'maxPendingAuthorizations');
120
148
  const maxPendingPerClient = positive(options.maxPendingAuthorizationsPerClient, 100, 'maxPendingAuthorizationsPerClient');
121
- const maxSessionsPerClient = positive(options.maxSessionsPerClient, 10_000, 'maxSessionsPerClient');
122
- const sessionMs = Math.max(SESSION_IDLE_SECONDS, lifetimeSeconds) * 1000;
149
+ if (options.maxSessionsPerClient !== undefined)
150
+ throw new TypeError('maxSessionsPerClient moved to createSessionAuthority');
123
151
  const requireNonce = options.requireDpopNonce ?? true;
124
152
  if (options.device) {
125
153
  secureUrl(options.device.endpoint);
126
154
  const url = secureUrl(options.device.verificationUri);
127
- const hosts = [new URL(issuer).hostname, ...options.poppyDomains];
155
+ const hosts = [new URL(issuer).hostname, ...options.protectedResources.map(resource => new URL(resource).hostname)];
128
156
  if (!hosts.some(host => url.hostname === host || url.hostname.endsWith(`.${host}`)))
129
157
  throw new TypeError('verificationUri');
130
158
  }
131
- if (options.mediated) {
132
- secureUrl(options.mediated.endpoint);
133
- if (!options.mediated.fields.length || new Set(options.mediated.fields.map(field => field.name)).size !== options.mediated.fields.length)
134
- throw new TypeError('mediated.fields');
135
- }
159
+ if (options.mediated)
160
+ parseMediatedSignIn({ version: '1', fields: options.mediated.fields, scopes: options.mediated.scopes });
136
161
  const deviceIntervalMs = positive(options.device?.intervalSeconds, 5, 'intervalSeconds') * 1000;
137
162
  const maxCodeAttempts = positive(options.mediated?.maxCodeAttempts, 5, 'maxCodeAttempts');
138
163
  const maxAttempts = positive(options.mediated?.maxAttemptsPerMinute, 5, 'maxAttemptsPerMinute');
@@ -140,7 +165,7 @@ export function createReferenceIssuer(options) {
140
165
  const mcpResources = options.mcpResources ?? [];
141
166
  for (const entry of mcpResources) {
142
167
  const url = secureUrl(entry.url);
143
- 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)
144
169
  throw new TypeError('mcpResources');
145
170
  }
146
171
  const resources = [...new Set([...(options.resources ?? []), ...mcpResources.map(entry => entry.url)])];
@@ -148,16 +173,6 @@ export function createReferenceIssuer(options) {
148
173
  // Every issuer mutation is ordered with conversation work by the shared coordinator.
149
174
  const read = async (selection = { kind: 'metadata' }) => checked(await store.read(selection));
150
175
  const commit = (selection, change) => options.coordinator.run(() => store.transaction(selection, draft => change(checked(draft))));
151
- function browserSession(state, binding) {
152
- const session = own(state.sessions, binding.sessionId), at = now();
153
- if (!session || session.expiresAt <= at || session.clientId !== binding.clientId || session.userId !== binding.userId || (binding.issuerGeneration !== undefined && binding.issuerGeneration !== state.generation))
154
- throw new AuthorityError('invalid_token');
155
- const grant = session.grantId === null ? undefined : own(state.grants, session.grantId);
156
- const signedIn = grantLive(grant, at);
157
- return { clientId: session.clientId, userId: session.userId, sessionId: binding.sessionId, issuerGeneration: state.generation, expiresAt: session.expiresAt,
158
- principal: { issuer, clientId: session.clientId, userId: session.userId, sessionId: binding.sessionId, issuerGeneration: state.generation, sessionVersion: session.version, tokenExpiresAt: session.expiresAt,
159
- scopes: signedIn ? [...grant.scopes] : [], ...(signedIn ? { accountId: grant.accountId, grantId: session.grantId } : {}) } };
160
- }
161
176
  // Reuse the current nonce while it has more than half its life left, so challenges cannot grow state.
162
177
  async function currentNonce(state) {
163
178
  const fresh = state.nonces.find(nonce => nonce.expiresAt - now() > NONCE_SECONDS * 500);
@@ -254,7 +269,7 @@ export function createReferenceIssuer(options) {
254
269
  if (record.expiresAt <= at)
255
270
  delete draft.authorizations[key];
256
271
  for (const [key, record] of Object.entries(draft.signIns))
257
- if (record.expiresAt + CLOCK_SKEW_SECONDS * 1000 < at)
272
+ if (!(record.kind === 'mediated' && record.status === 'verifying') && record.expiresAt + CLOCK_SKEW_SECONDS * 1000 < at)
258
273
  delete draft.signIns[key];
259
274
  }
260
275
  // Revoking a grant signs out every Session using it; they continue signed out and stay bound to the account (spec 4.9).
@@ -267,19 +282,12 @@ export function createReferenceIssuer(options) {
267
282
  if (session.grantId === grantId)
268
283
  session.grantId = null;
269
284
  }
270
- function issueSignIn(draft, sessionId, target, accountId, scopes, jkt, resource) {
271
- if (target.accountId !== null && target.accountId !== accountId)
272
- throw new TokenError('account_mismatch');
273
- const grantId = `grt_${random(16)}`, refreshToken = random(32), accessToken = random(32), at = now();
274
- const refreshHash = tokenHash(refreshToken);
275
- 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: '' };
276
288
  draft.refreshTokens[refreshHash] = grantId;
277
- target.accountId = accountId;
278
- target.grantId = grantId;
279
- target.expiresAt = at + sessionMs;
280
- const bearer = isBearerResource(resource);
281
- draft.accessTokens[tokenHash(accessToken)] = { sessionId, grantId, scopes: [...scopes], resource: resource ?? null, jkt: bearer ? '' : jkt, expiresAt: at + lifetimeMs };
282
- return { grantId, body: { access_token: accessToken, token_type: bearer ? 'Bearer' : 'DPoP', expires_in: lifetimeSeconds, refresh_token: refreshToken, refresh_token_expires_in: Math.floor(accountLifetimeMs / 1000), scope: scopes.join(' '), session_id: sessionId, signed_in: true } };
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(' ') } };
283
291
  }
284
292
  function signInScopes(scope, allowed) {
285
293
  const scopes = scope?.split(' ') ?? [];
@@ -303,17 +311,17 @@ export function createReferenceIssuer(options) {
303
311
  if (!options.device)
304
312
  throw new TokenError('unsupported_grant_type');
305
313
  const params = await readParams(request), { clientId, clientAssertion } = clientCredentials(params);
306
- const scopes = signInScopes(params.get('scope'), options.device.scopes), { client } = await authenticateClient(clientId, clientAssertion);
314
+ const scopes = signInScopes(params.get('scope'), options.supportedScopes), { client } = await authenticateClient(clientId, clientAssertion);
307
315
  const { generation } = await read(), userCode = random(8).toUpperCase(), deviceCode = `${userCode}.${random(32)}`, id = tokenHash(userCode);
308
316
  const replayKey = `assertion ${clientId} ${client.jti}`;
309
317
  await commit({ kind: 'sign_in', ids: [id, clientRevisionKey(clientId)], clientId, replayKeys: [replayKey], at: now() }, draft => {
310
318
  if (draft.generation !== generation || !current(client))
311
319
  throw new TokenError('invalid_client');
312
- 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')));
313
321
  if ((draft.limits?.pending ?? pending.length) >= maxPending || (draft.limits?.pendingForClient ?? pending.filter(row => row.clientId === clientId).length) >= maxPendingPerClient)
314
322
  throw new TokenError('rate_limited', { 'Retry-After': '60' });
315
323
  consumeReplay(draft, replayKey, client.exp * 1000);
316
- draft.signIns[id] = { kind: 'device', clientId, scopes, expiresAt: now() + AUTHORIZATION_MS, deviceHash: tokenHash(deviceCode), status: 'pending', nextPollAt: now() + deviceIntervalMs, intervalMs: deviceIntervalMs, revision: revision(draft, clientRevisionKey(clientId)) };
324
+ draft.signIns[id] = { kind: 'device', sessionKey: null, clientId, scopes, expiresAt: now() + AUTHORIZATION_MS, deviceHash: tokenHash(deviceCode), status: 'pending', nextPollAt: now() + deviceIntervalMs, intervalMs: deviceIntervalMs, revision: revision(draft, clientRevisionKey(clientId)) };
317
325
  });
318
326
  const complete = new URL(options.device.verificationUri);
319
327
  complete.searchParams.set('user_code', userCode);
@@ -322,25 +330,26 @@ export function createReferenceIssuer(options) {
322
330
  async function pollDevice(request, params) {
323
331
  if (!options.device)
324
332
  throw new TokenError('unsupported_grant_type');
325
- const { clientId, clientAssertion } = clientCredentials(params), code = params.get('device_code'), sessionId = params.get('session_id'), resource = params.get('resource');
326
- if (!code || !sessionId || params.has('assertion'))
333
+ const { clientId, clientAssertion } = clientCredentials(params), code = params.get('device_code'), resource = params.get('resource');
334
+ if (!code || params.has('session_id') || params.has('assertion'))
327
335
  throw new TokenError('invalid_request');
328
336
  if (resource !== undefined && !resources.includes(resource))
329
337
  throw new TokenError('invalid_target');
330
- const state = await read(), bearer = isBearerResource(resource);
331
- const proof = bearer ? { jkt: '', claims: { iat: now() / 1000, jti: '', nonce: undefined } } : await verifyDpopProof(request.headers.get('dpop'), { method: 'POST', url: options.tokenEndpoint, nowMs: now() });
332
- if (!bearer && requireNonce && !state.nonces.some(nonce => nonce.value === proof.claims.nonce && nonce.expiresAt > now()))
338
+ const state = await read();
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()))
333
341
  throw new TokenError('use_dpop_nonce', { 'DPoP-Nonce': await currentNonce(state) });
334
342
  const { client, keys } = await authenticateClient(clientId, clientAssertion);
335
- for (const key of keys().keys)
336
- if (await calculateJwkThumbprint(key).catch(() => null) === proof.jkt)
337
- throw new TokenError('invalid_dpop_proof');
338
- const id = tokenHash(code.split('.')[0]), replayKey = `assertion ${clientId} ${client.jti}`, proofKey = `proof ${proof.jkt} ${proof.claims.jti}`;
339
- const body = await commit({ kind: 'sign_in', ids: [id], sessionId, 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 => {
340
349
  if (draft.generation !== state.generation || !current(client))
341
350
  throw new TokenError('invalid_client');
342
351
  consumeReplay(draft, replayKey, client.exp * 1000);
343
- if (!bearer)
352
+ if (proof && proofKey)
344
353
  consumeReplay(draft, proofKey, (proof.claims.iat + PROOF_WINDOW_SECONDS) * 1000);
345
354
  const record = own(draft.signIns, id);
346
355
  if (!record || record.kind !== 'device' || record.clientId !== clientId || record.deviceHash !== tokenHash(code))
@@ -359,364 +368,251 @@ export function createReferenceIssuer(options) {
359
368
  record.nextPollAt = now() + record.intervalMs;
360
369
  if (record.status === 'pending')
361
370
  return { error: 'authorization_pending' };
362
- const session = own(draft.sessions, sessionId);
363
- if (!session || session.clientId !== clientId || session.expiresAt <= now())
364
- throw new TokenError('invalid_session');
365
- 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);
366
374
  record.status = 'consumed';
367
- return granted.body;
375
+ return result.body;
368
376
  });
369
377
  return json('error' in body ? 400 : 200, body);
370
378
  }
371
- function mediatedToken(draft, accessHash, jkt) {
372
- const token = own(draft.accessTokens, accessHash), session = token && own(draft.sessions, token.sessionId);
373
- if (!token || !session || token.expiresAt <= now() || session.expiresAt <= now() || token.jkt !== jkt || (token.grantId !== null && !grantLive(own(draft.grants, token.grantId), now())))
374
- throw new TokenError('invalid_token');
375
- return { token, session };
376
- }
377
- async function mediatedSignIn(request) {
379
+ async function mediatedSignIn(request, supplied) {
378
380
  const config = options.mediated;
379
381
  if (!config)
380
382
  throw new TokenError('unsupported_grant_type');
381
- const endpoint = new URL(config.endpoint), url = new URL(request.url);
382
- if (request.method !== 'POST' || url.origin !== endpoint.origin || url.search || url.hash ||
383
- (url.pathname !== endpoint.pathname && !url.pathname.startsWith(`${endpoint.pathname.replace(/\/$/, '')}/`)))
383
+ if (request.url !== options.tokenEndpoint)
384
384
  throw new TokenError('invalid_request');
385
- const id = url.pathname === endpoint.pathname ? random(32) : url.pathname.slice(endpoint.pathname.replace(/\/$/, '').length + 1);
386
- if (!/^[A-Za-z0-9_-]{1,256}$/.test(id))
387
- throw new TokenError('sign_in_not_found');
388
- const challenge = url.pathname !== endpoint.pathname;
389
- if (!/^application\/json(?:;|$)/i.test(request.headers.get('content-type') ?? ''))
385
+ const params = supplied ?? await readParams(request), { clientId, clientAssertion } = clientCredentials(params);
386
+ if (params.get('grant_type') !== MEDIATED_GRANT)
390
387
  throw new TokenError('invalid_request');
391
- let input;
392
- try {
393
- const parsed = parseJson(await readForm(request));
394
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
395
- throw 0;
396
- input = parsed;
397
- }
398
- catch {
399
- throw new TokenError('invalid_request');
400
- }
401
- 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 = '';
402
409
  try {
403
- let scopes = [];
404
410
  if (challenge) {
405
- 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)
406
415
  throw new TokenError('invalid_request');
407
- code = input.code;
408
416
  }
409
417
  else {
410
- scopes = signInScopes(typeof input.scope === 'string' ? input.scope : undefined, config.scopes);
411
- const fields = input.credentials;
412
- if (!fields || typeof fields !== 'object' || Array.isArray(fields) || Object.keys(fields).length !== config.fields.length ||
413
- !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'))
414
419
  throw new TokenError('invalid_request');
415
- 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
+ }
416
426
  }
417
- const access = /^DPoP (\S+)$/i.exec(request.headers.get('authorization') ?? '')?.[1];
418
- if (!access || access.length > 4096)
419
- throw new TokenError('invalid_token');
420
- const proof = await verifyDpopProof(request.headers.get('dpop'), { method: 'POST', url: request.url, nowMs: now(), accessToken: access });
421
- const accessHash = tokenHash(access), proofKey = `proof ${proof.jkt} ${proof.claims.jti}`, state = await read({ kind: 'access', hash: accessHash });
422
- const authenticated = mediatedToken(state, accessHash, proof.jkt), context = { issuer, clientId: authenticated.session.clientId, userId: authenticated.session.userId, sessionId: authenticated.token.sessionId };
423
- const key = tokenHash(id), limitId = tokenHash(`attempts session ${context.clientId} ${context.sessionId}`), clientLimitId = tokenHash(`attempts client ${context.clientId}`), executionId = random(16);
424
- 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() };
425
430
  const claim = await commit(selection, draft => {
426
- if (draft.generation !== state.generation)
427
- throw new TokenError('invalid_token');
428
- mediatedToken(draft, accessHash, proof.jkt);
429
- consumeReplay(draft, proofKey, (proof.claims.iat + PROOF_WINDOW_SECONDS) * 1000);
430
- 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);
431
437
  if (challenge) {
432
- if (!record || record.kind !== 'mediated' || record.sessionId !== context.sessionId || record.clientId !== context.clientId || record.userId !== context.userId)
433
- throw new TokenError('sign_in_not_found');
434
- if (record.status === 'complete')
435
- throw new TokenError('sign_in_not_found');
436
- if (record.status === 'failed' || record.status === 'expired')
437
- return { status: record.status };
438
- if (record.expiresAt <= now()) {
439
- record.status = 'expired';
440
- 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' };
441
447
  }
442
- if (record.status === 'verifying')
443
- throw new TokenError('rate_limited', { 'Retry-After': '5' });
444
- if (record.attempts >= maxCodeAttempts) {
445
- record.status = 'failed';
446
- return { status: 'failed' };
448
+ if (row.attempts >= maxCodeAttempts) {
449
+ row.status = 'failed';
450
+ return { error: 'invalid_grant' };
447
451
  }
448
- record.attempts++;
449
- record.status = 'verifying';
450
- record.executionId = executionId;
451
- return { record };
452
- }
453
- // Count attempts before calling the host's credential backend, including failures.
454
- for (const [limitKey, maximum, sessionId] of [[limitId, maxAttempts, context.sessionId], [clientLimitId, maxClientAttempts, undefined]]) {
455
- const old = own(draft.signIns, limitKey);
456
- const window = old?.kind === 'attempts' && old.expiresAt > now() ? old : { kind: 'attempts', clientId: context.clientId, ...(sessionId ? { sessionId } : {}), count: 0, expiresAt: now() + 60_000 };
457
- if (window.count >= maximum)
458
- throw new TokenError('rate_limited', { 'Retry-After': String(Math.ceil((window.expiresAt - now()) / 1000)) });
459
- window.count++;
460
- draft.signIns[limitKey] = window;
452
+ row.attempts++;
453
+ row.status = 'verifying';
454
+ row.executionId = executionId;
455
+ return { record: row };
461
456
  }
462
- 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')));
463
- 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)
464
464
  throw new TokenError('rate_limited', { 'Retry-After': '60' });
465
- 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)) };
466
466
  draft.signIns[key] = record;
467
467
  return { record };
468
468
  });
469
- if ('status' in claim)
470
- return json(200, claim);
471
- const record = claim.record;
469
+ if ('error' in claim)
470
+ return json(400, claim);
471
+ const row = claim.record;
472
472
  let decision = null, correct = false;
473
473
  try {
474
- if (challenge) {
475
- correct = await config.verifyCode({ challengeId: record.challengeId, code }, context);
476
- if (typeof correct !== 'boolean')
477
- throw 0;
478
- }
474
+ if (challenge)
475
+ correct = await config.verifyCode({ challengeId: row.challengeId, code: oneTimeCode }, context);
479
476
  else
480
477
  decision = await config.authenticate(credentials, context);
481
478
  }
482
479
  catch {
483
- throw new TokenError('temporarily_unavailable');
480
+ throw new TokenError('temporarily_unavailable', { 'Retry-After': '5' });
484
481
  }
485
- const credentialValues = Object.values(credentials);
486
- 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')
487
483
  throw new TokenError('temporarily_unavailable');
488
- 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))))
489
485
  throw new TokenError('temporarily_unavailable');
490
- const accountId = decision?.accountId ?? record.accountId;
491
- const completionSelection = { ...selection, ids: [...selection.ids, ...(accountId ? [accountRevisionKey(context.clientId, accountId)] : [])] };
492
- const body = await commit(completionSelection, draft => {
493
- if (draft.generation !== state.generation)
494
- throw new TokenError('invalid_token');
495
- const current = own(draft.signIns, key), { token, session } = mediatedToken(draft, accessHash, proof.jkt);
496
- if (!current || current.kind !== 'mediated' || current.executionId !== executionId || current.status !== 'verifying')
497
- throw new TokenError('sign_in_not_found');
498
- if (accountId && !consentCurrent(draft, context.clientId, accountId, current.revision)) {
499
- current.accountId = accountId;
500
- 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';
501
497
  return { error: 'invalid_grant' };
502
498
  }
503
- if (current.expiresAt <= now()) {
504
- current.status = 'expired';
505
- return { status: 'expired' };
499
+ if (completed.expiresAt <= now()) {
500
+ completed.status = 'expired';
501
+ return { error: 'expired_token' };
506
502
  }
507
- if ((!challenge && !decision) || (challenge && !correct && current.attempts >= maxCodeAttempts)) {
508
- current.status = 'failed';
509
- return { status: 'failed' };
503
+ if ((!challenge && !decision) || (challenge && !correct && completed.attempts >= maxCodeAttempts)) {
504
+ completed.status = 'failed';
505
+ return { error: 'invalid_grant' };
510
506
  }
511
- if (decision)
512
- current.accountId = decision.accountId;
513
507
  if (decision?.scopes)
514
- current.scopes = [...new Set(decision.scopes)];
508
+ completed.scopes = [...new Set(decision.scopes)];
515
509
  if (decision?.challenge) {
516
- current.accountId = decision.accountId;
517
- current.challengeId = decision.challenge.id;
518
- 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;
519
512
  }
520
513
  if ((!challenge && decision?.challenge) || (challenge && !correct)) {
521
- current.status = 'pending';
522
- 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) };
523
516
  }
524
- const result = issueSignIn(draft, token.sessionId, session, decision?.accountId ?? current.accountId, current.scopes, proof.jkt, token.resource ?? undefined);
525
- current.status = 'complete';
526
- 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;
527
521
  });
528
522
  return json('error' in body ? 400 : 200, body);
529
523
  }
530
524
  finally {
531
- for (const key of Object.keys(credentials))
532
- delete credentials[key];
533
- for (const key of Object.keys(input))
534
- delete input[key];
535
- 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 = '';
536
531
  }
537
532
  }
538
- async function issue(request) {
539
- const params = await readParams(request);
540
- const grantType = params.get('grant_type');
541
- if (!grantType)
542
- throw new TokenError('invalid_request');
543
- if (grantType === DEVICE_GRANT)
544
- return pollDevice(request, params);
545
- if (grantType !== JWT_BEARER && grantType !== 'authorization_code' && grantType !== 'refresh_token')
546
- throw new TokenError('unsupported_grant_type');
533
+ async function accountToken(request, params) {
547
534
  const { clientId, clientAssertion } = clientCredentials(params);
548
- const assertion = params.get('assertion');
549
- const code = params.get('code');
550
- const redirectUri = params.get('redirect_uri');
551
- const codeVerifier = params.get('code_verifier');
552
- const refreshToken = params.get('refresh_token');
553
- const sessionId = params.get('session_id');
554
- const resource = params.get('resource');
555
- const scope = params.get('scope');
556
- if (sessionId === '')
557
- throw new TokenError('invalid_request');
558
- // Each grant takes only its own credential, so a stray assertion can never stand in for a code or Account Token.
559
- if (grantType !== JWT_BEARER && assertion !== undefined)
560
- throw new TokenError('invalid_request');
561
- if (grantType === JWT_BEARER) {
562
- // A missing assertion is invalid_grant (spec 4.2 error table).
563
- if (!assertion)
564
- throw new TokenError('invalid_grant');
565
- // Signed-out sessions hold no account scopes.
566
- if (scope?.trim())
567
- throw new TokenError('invalid_scope');
568
- }
569
- else if (grantType === 'authorization_code') {
570
- // Direct Sign-In always names the Session to sign in (spec 4.5).
571
- if (!code || !redirectUri || !codeVerifier || sessionId === undefined)
572
- throw new TokenError('invalid_request');
573
- // RFC 7636 4.1: 43-128 unreserved characters.
574
- if (!/^[A-Za-z0-9._~-]{43,128}$/.test(codeVerifier))
575
- throw new TokenError('invalid_request');
576
- }
577
- else if (!refreshToken)
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'))
578
538
  throw new TokenError('invalid_request');
579
- // scope narrows only (spec 4.3, 4.8); an empty request asks for nothing and is refused.
580
- const requested = scope === undefined || grantType === JWT_BEARER ? undefined : [...new Set(scope.split(' ').filter(Boolean))];
581
- if (requested?.length === 0)
582
- throw new TokenError('invalid_scope');
583
- // Resources are exact configured identifiers, chosen per token request; grants are never narrowed to one.
584
539
  if (resource !== undefined && !resources.includes(resource))
585
540
  throw new TokenError('invalid_target');
541
+ if (grantType === 'authorization_code' && (!code || !verifier || !redirect || !/^[A-Za-z0-9._~-]{43,128}$/.test(verifier)))
542
+ throw new TokenError('invalid_request');
543
+ if (grantType === 'refresh_token' && !refresh)
544
+ throw new TokenError('invalid_request');
586
545
  const state = await read();
587
- const generation = state.generation;
588
- // htu is checked against the configured token endpoint, never the incoming Request URL.
589
- const bearerResource = isBearerResource(resource);
590
- const proof = bearerResource ? { jkt: '', claims: { iat: now() / 1000, jti: '', nonce: undefined } } : await verifyDpopProof(request.headers.get('dpop'), { method: 'POST', url: options.tokenEndpoint, nowMs: now() });
591
- // Nonce challenge precedes any network fetch and any replay consumption.
592
- if (!bearerResource && 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()))
593
548
  throw new TokenError('use_dpop_nonce', { 'DPoP-Nonce': await currentNonce(state) });
594
- const { client, verify, keys } = await authenticateClient(clientId, clientAssertion);
595
- let session;
596
- if (grantType === JWT_BEARER) {
597
- try {
598
- session = await verify(assertion);
599
- }
600
- catch (error) {
601
- throw error instanceof TokenError ? error : new TokenError('invalid_grant');
602
- }
603
- if (session.iss !== clientId || session.aud !== issuer || session.sub.length > 256 || !current(session) || session.jti === client.jti)
604
- throw new TokenError('invalid_grant');
605
- }
606
- // Identity keys and DPoP keys must differ (spec 4.1).
607
- for (const key of keys().keys)
608
- if (await calculateJwkThumbprint(key).catch(() => null) === proof.jkt)
609
- throw new TokenError('invalid_dpop_proof');
610
- const body = await commit({ kind: 'token', clientId, ...(sessionId === undefined ? {} : { sessionId }), ...(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 => {
611
- // A reset between verification and commit must not recreate state from a stale verification.
612
- if (draft.generation !== generation)
613
- throw new TokenError('invalid_grant');
614
- // 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 => {
615
556
  const at = now();
616
- if ((proof.claims.iat + PROOF_WINDOW_SECONDS) * 1000 < at)
617
- throw new TokenError('invalid_dpop_proof');
618
- if (client.exp * 1000 <= at)
557
+ if (draft.generation !== state.generation || !current(client))
619
558
  throw new TokenError('invalid_client');
620
- if (session && session.exp * 1000 <= at)
621
- throw new TokenError('invalid_grant');
622
- prune(draft, at);
623
- // Client and session assertions share one jti namespace per client.
624
- const assertions = session ? [client, session] : [client];
625
- const assertionKeys = assertions.map(claims => `assertion ${clientId} ${claims.jti}`);
626
- const proofKey = `proof ${proof.jkt} ${proof.claims.jti}`;
627
- if (assertionKeys.some(key => own(draft.replay, key) !== undefined))
628
- throw new TokenError('invalid_grant');
629
- if (!bearerResource && own(draft.replay, proofKey) !== undefined)
630
- throw new TokenError('invalid_dpop_proof');
631
- assertions.forEach((claims, index) => { draft.replay[assertionKeys[index]] = claims.exp * 1000; });
632
- if (!bearerResource)
633
- draft.replay[proofKey] = (proof.claims.iat + PROOF_WINDOW_SECONDS) * 1000;
634
- const existing = (userId) => {
635
- const found = sessionId === undefined ? undefined : own(draft.sessions, sessionId);
636
- if (!found || !(found.expiresAt > at) || found.clientId !== clientId || (userId !== undefined && found.userId !== userId))
637
- throw new TokenError('invalid_session');
638
- return found;
639
- };
640
- const create = (userId) => {
641
- // Pruned above, so these are live; a slot frees when the soonest of them ends (spec 4.2 rate_limited).
642
- const live = Object.values(draft.sessions).filter(record => record.clientId === clientId);
643
- if ((draft.limits?.sessions ?? live.length) >= maxSessionsPerClient)
644
- throw new TokenError('rate_limited', { 'Retry-After': String(Math.ceil(((draft.limits?.earliestSessionExpiry ?? Math.min(...live.map(record => record.expiresAt))) - at) / 1000)) });
645
- const id = `ses_${random(16)}`;
646
- return [id, draft.sessions[id] = { clientId, userId, version: 1, createdAt: at, expiresAt: at, accountId: null, grantId: null }];
647
- };
648
- const accessToken = random(32);
649
- const grant = (id, record, grantId, scopes) => {
650
- record.expiresAt = at + sessionMs;
651
- draft.accessTokens[tokenHash(accessToken)] = { sessionId: id, grantId, scopes, resource: resource ?? null, jkt: proof.jkt, expiresAt: at + lifetimeMs };
652
- return { access_token: accessToken, token_type: bearerResource ? 'Bearer' : 'DPoP', expires_in: lifetimeSeconds, scope: scopes.join(' '), session_id: id, signed_in: grantId !== null };
653
- };
654
- // Signs the Session in to the lineage and rotates its Account Token: consume, successor and session update in this one commit.
655
- const signIn = (id, record, grantId) => {
656
- const lineage = draft.grants[grantId];
657
- // Sticky account (spec 4.4): a Session once signed in to another account never switches, and no tokens are issued.
658
- if (record.accountId !== null && record.accountId !== lineage.accountId)
659
- throw new TokenError('account_mismatch');
660
- const scopes = requested ?? lineage.scopes;
661
- if (!scopes.every(item => lineage.scopes.includes(item)))
662
- throw new TokenError('invalid_scope');
663
- const successor = random(32);
664
- // Retain ancestors until the fixed grant expiry so every replay can revoke its descendants.
665
- lineage.previousHash = lineage.refreshHash;
666
- lineage.refreshHash = tokenHash(successor);
667
- draft.refreshTokens[lineage.refreshHash] = grantId;
668
- record.accountId = lineage.accountId;
669
- record.grantId = grantId;
670
- return { ...grant(id, record, grantId, scopes), refresh_token: successor, refresh_token_expires_in: Math.floor((lineage.expiresAt - at) / 1000) };
671
- };
672
- if (grantType === JWT_BEARER) {
673
- if (!session)
674
- throw new TokenError('invalid_grant');
675
- if (sessionId === undefined)
676
- return grant(...create(session.sub), null, []);
677
- const record = existing(session.sub);
678
- // A signed-in Session is renewed with its Account Token instead (spec 4.2).
679
- if (record.grantId !== null && grantLive(own(draft.grants, record.grantId), at))
680
- throw new TokenError('invalid_grant');
681
- 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);
682
566
  }
683
567
  if (grantType === 'authorization_code') {
684
- const record = own(draft.codes, tokenHash(code));
685
- if (!record || record.expiresAt <= at || record.clientId !== clientId)
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))
686
570
  throw new TokenError('invalid_grant');
687
- if (record.grantId !== null) {
688
- revokeGrant(draft, record.grantId);
571
+ if (row.grantId !== null) {
572
+ revokeGrant(draft, row.grantId);
689
573
  return null;
690
574
  }
691
- if (record.redirectUri !== redirectUri || tokenHash(codeVerifier) !== record.codeChallenge)
692
- throw new TokenError('invalid_grant');
693
- 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))
694
576
  throw new TokenError('invalid_grant');
695
- const target = existing();
696
- const grantId = `grt_${random(16)}`;
697
- draft.grants[grantId] = { clientId, userId: target.userId, accountId: record.accountId, scopes: record.scopes, expiresAt: at + accountLifetimeMs, revoked: false, refreshHash: '', previousHash: '' };
698
- record.grantId = grantId;
699
- 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;
700
580
  }
701
- const presented = tokenHash(refreshToken);
702
- const grantId = own(draft.refreshTokens, presented);
703
- const lineage = grantId === undefined ? undefined : own(draft.grants, grantId);
704
- // Reuse of any ancestor Account Token (e.g. a retry after a lost response) revokes the lineage, so its Sessions
705
- // continue signed out instead of staying bound to a successor nobody holds (RFC 9700 4.14.2).
706
- 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) {
707
583
  revokeGrant(draft, grantId);
708
584
  return null;
709
585
  }
710
- // A rotated Account Token never issues another successor.
711
- if (!lineage || !grantLive(lineage, at) || lineage.clientId !== clientId || lineage.refreshHash !== presented)
586
+ if (!grant || !grantLive(grant, at) || grant.clientId !== clientId)
712
587
  throw new TokenError('invalid_grant');
713
- 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(' ') };
714
598
  });
715
- // A reused code or previous Account Token revoked its lineage in the commit above.
716
599
  if (body === null)
717
600
  throw new TokenError('invalid_grant');
718
601
  return json(200, body);
719
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
+ }
720
616
  // RFC 7009 with private_key_jwt client authentication; unknown and foreign tokens succeed without changing any grant.
721
617
  async function revokeToken(request) {
722
618
  const params = await readParams(request);
@@ -756,7 +652,7 @@ export function createReferenceIssuer(options) {
756
652
  if (!code)
757
653
  throw error;
758
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;
759
- 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 : {}) });
760
656
  }
761
657
  }
762
658
  // Plain checks over one issuer snapshot; only the policy wrappers below take the coordinator, once.
@@ -774,9 +670,7 @@ export function createReferenceIssuer(options) {
774
670
  const policy = {
775
671
  withCurrent: (principal, scopes, action) => options.coordinator.run(async () => {
776
672
  const state = await read({ kind: 'principal', sessionId: principal.sessionId, ...(principal.grantId ? { grantId: principal.grantId } : {}) });
777
- const session = own(state.sessions, principal.sessionId);
778
- if (principal.issuer !== issuer || principal.issuerGeneration !== state.generation || !session || session.clientId !== principal.clientId
779
- || 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))
780
674
  throw new AuthorityError('invalid_token');
781
675
  requireScopes(principal.scopes, scopes);
782
676
  return action();
@@ -795,23 +689,9 @@ export function createReferenceIssuer(options) {
795
689
  }),
796
690
  };
797
691
  return {
798
- browserSessions: {
799
- async current(binding) { return browserSession(await read({ kind: 'browser', sessionId: binding.sessionId }), binding); },
800
- join(binding, assertion) {
801
- const replayKey = `assertion ${binding.clientId} ${assertion.jti}`;
802
- return commit({ kind: 'browser', sessionId: binding.sessionId, replayKey }, draft => {
803
- if (assertion.expiresAt <= now() || assertion.expiresAt > now() + 90000 || !assertion.jti || assertion.jti.length > 256 || (own(draft.replay, replayKey) ?? 0) > now())
804
- throw new AuthorityError('invalid_token');
805
- const session = browserSession(draft, binding);
806
- draft.replay[replayKey] = assertion.expiresAt;
807
- return session;
808
- });
809
- },
810
- },
811
692
  token: request => respond(request, issue),
812
693
  revoke: request => respond(request, revokeToken),
813
694
  deviceAuthorization: request => respond(request, startDevice),
814
- mediatedSignIn: request => respond(request, mediatedSignIn),
815
695
  async beginDeviceAuthorization(userCode, browserBinding) {
816
696
  if (!options.device || typeof userCode !== 'string' || typeof browserBinding !== 'string' || browserBinding.length < 22)
817
697
  throw new AuthorizationError('invalid_request');
@@ -874,16 +754,8 @@ export function createReferenceIssuer(options) {
874
754
  catch {
875
755
  throw new AuthorizationError('invalid_client');
876
756
  }
877
- // Exact string match against the client's metadata (spec 4.5).
878
- if (!client.redirect_uris.includes(redirectUri))
757
+ if (!registeredRedirect(redirectUri, client.redirect_uris, clientId))
879
758
  throw new AuthorizationError('invalid_request');
880
- try {
881
- if (secureUrl(redirectUri).hostname !== secureUrl(clientId).hostname)
882
- throw 0;
883
- }
884
- catch {
885
- throw new AuthorizationError('invalid_request');
886
- }
887
759
  const state = params.get('state');
888
760
  const fail = (code) => new AuthorizationError(code, new URL(callback(redirectUri, { error: code, ...(state ? { state } : {}), iss: issuer })));
889
761
  if (params.get('response_type') !== 'code')
@@ -893,8 +765,11 @@ export function createReferenceIssuer(options) {
893
765
  const codeChallenge = params.get('code_challenge') ?? '';
894
766
  if (params.get('code_challenge_method') !== 'S256' || !/^[A-Za-z0-9_-]{43}$/.test(codeChallenge))
895
767
  throw fail('invalid_request');
768
+ const sessionKey = params.get('dpop_jkt') ?? null;
769
+ if (sessionKey !== null && !/^[A-Za-z0-9_-]{43}$/.test(sessionKey))
770
+ throw fail('invalid_request');
896
771
  const scopes = [...new Set((params.get('scope') ?? '').split(' ').filter(Boolean))];
897
- if (!scopes.length || !scopes.every(scope => options.directScopes.includes(scope)))
772
+ if (!scopes.length || !scopes.every(scope => options.supportedScopes.includes(scope)))
898
773
  throw fail('invalid_scope');
899
774
  // resource is accepted and ignored here: grants cover every resource, and each token request selects one (spec 4.3).
900
775
  const resource = params.get('resource');
@@ -911,7 +786,7 @@ export function createReferenceIssuer(options) {
911
786
  throw fail('temporarily_unavailable');
912
787
  const id = random(16);
913
788
  const expiresAt = at + AUTHORIZATION_MS;
914
- draft.authorizations[id] = { clientId, redirectUri, state, codeChallenge, scopes, browserHash: tokenHash(browserBinding), expiresAt, revision: revision(draft, clientRevisionKey(clientId)) };
789
+ draft.authorizations[id] = { sessionKey, clientId, redirectUri, state, codeChallenge, scopes, browserHash: tokenHash(browserBinding), expiresAt, revision: revision(draft, clientRevisionKey(clientId)) };
915
790
  return { id, clientId, clientName: client.client_name, scopes, expiresAt };
916
791
  });
917
792
  },
@@ -926,7 +801,7 @@ export function createReferenceIssuer(options) {
926
801
  const accountId = 'accountId' in decision && typeof decision.accountId === 'string' ? decision.accountId : undefined;
927
802
  const href = await commit({ kind: 'authorization', id, ...(snapshot && accountId ? { revocationKey: accountRevisionKey(snapshot.clientId, accountId) } : {}) }, draft => {
928
803
  const record = own(draft.authorizations, id);
929
- if (!record || record.expiresAt <= now() || record.browserHash !== tokenHash(browserBinding))
804
+ if (!record || record.sessionKey === undefined || record.expiresAt <= now() || record.browserHash !== tokenHash(browserBinding))
930
805
  throw new AuthorizationError('invalid_request');
931
806
  const denied = 'denied' in decision && decision.denied === true;
932
807
  const { accountId, scopes } = decision;
@@ -950,7 +825,7 @@ export function createReferenceIssuer(options) {
950
825
  }
951
826
  const code = random(32);
952
827
  record.expiresAt = Math.min(record.expiresAt, now() + CODE_MS);
953
- draft.codes[tokenHash(code)] = { clientId: record.clientId, redirectUri: record.redirectUri, codeChallenge: record.codeChallenge, accountId: accountId, scopes: granted, expiresAt: record.expiresAt, grantId: null, revision: record.revision };
828
+ draft.codes[tokenHash(code)] = { sessionKey: record.sessionKey, clientId: record.clientId, redirectUri: record.redirectUri, codeChallenge: record.codeChallenge, accountId: accountId, scopes: granted, expiresAt: record.expiresAt, grantId: null, revision: record.revision };
954
829
  const href = callback(record.redirectUri, { code, ...fields });
955
830
  record.completion = { decisionHash, callback: cipher.seal(href) };
956
831
  return href;
@@ -962,32 +837,23 @@ export function createReferenceIssuer(options) {
962
837
  issuer, token_endpoint: options.tokenEndpoint, revocation_endpoint: options.revocationEndpoint,
963
838
  ...(options.authorizationEndpoint ? { authorization_endpoint: options.authorizationEndpoint, response_types_supported: ['code'], code_challenge_methods_supported: ['S256'] } : {}),
964
839
  ...(options.device ? { device_authorization_endpoint: options.device.endpoint } : {}),
965
- dpop_signing_alg_values_supported: ['ES256'], client_id_metadata_document_supported: true,
966
- poppy_domains: [...options.poppyDomains],
840
+ dpop_signing_alg_values_supported: ['ES256'], token_endpoint_auth_signing_alg_values_supported: [...ASSERTION_ALGORITHMS], client_id_metadata_document_supported: true,
841
+ protected_resources: [...options.protectedResources],
842
+ grant_types_supported: ['refresh_token', ...(options.mediated ? [MEDIATED_GRANT] : []), ...(options.authorizationEndpoint ? ['authorization_code'] : []), ...(options.device ? [DEVICE_GRANT] : [])],
843
+ scopes_supported: [...options.supportedScopes],
967
844
  };
968
845
  },
969
846
  authority: {
970
847
  issuer,
971
848
  revocationVisibility: 'immediate: every introspection and policy check reads the shared issuer store; policy checks are ordered by the shared coordinator',
972
849
  async introspect(token, resource) {
973
- const state = await read({ kind: 'access', hash: tokenHash(token) });
974
- const at = now();
975
- const record = own(state.accessTokens, tokenHash(token));
976
- const session = record && own(state.sessions, record.sessionId);
977
- 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))
978
852
  throw new AuthorityError('invalid_token');
979
- // A signed-in token stops working once its sign-in is revoked or expires; a signed-out one stays signed out.
980
- const grant = record.grantId === null ? undefined : own(state.grants, record.grantId);
981
- 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))
982
855
  throw new AuthorityError('invalid_token');
983
- return {
984
- principal: {
985
- issuer, clientId: session.clientId, userId: session.userId, sessionId: record.sessionId,
986
- ...(grant ? { accountId: grant.accountId, grantId: record.grantId } : {}),
987
- scopes: [...record.scopes], tokenExpiresAt: record.expiresAt, sessionVersion: session.version, issuerGeneration: state.generation,
988
- },
989
- dpopJkt: record.jkt || null,
990
- };
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 };
991
857
  },
992
858
  policy,
993
859
  },