@poppykit/server 0.0.0-stage → 0.1.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 (51) hide show
  1. package/API.md +21 -0
  2. package/AUTH.md +60 -0
  3. package/CHANGELOG.md +22 -0
  4. package/CONTRIBUTING.md +50 -0
  5. package/DELIVERY.md +113 -0
  6. package/INTEGRATIONS.md +84 -0
  7. package/LICENSE +21 -0
  8. package/README.md +116 -2
  9. package/RELEASING.md +193 -0
  10. package/SECURITY.md +13 -0
  11. package/STORAGE.md +119 -0
  12. package/dist/adapter-contract.d.ts +12 -0
  13. package/dist/adapter-contract.js +140 -0
  14. package/dist/auth/authenticator.d.ts +6 -0
  15. package/dist/auth/authenticator.js +51 -0
  16. package/dist/auth/authority.d.ts +55 -0
  17. package/dist/auth/authority.js +16 -0
  18. package/dist/auth/dpop.d.ts +15 -0
  19. package/dist/auth/dpop.js +49 -0
  20. package/dist/browser.d.ts +44 -0
  21. package/dist/browser.js +110 -0
  22. package/dist/conversation/service.d.ts +126 -0
  23. package/dist/conversation/service.js +754 -0
  24. package/dist/conversation/store.d.ts +26 -0
  25. package/dist/conversation/store.js +14 -0
  26. package/dist/feedback.d.ts +13 -0
  27. package/dist/feedback.js +44 -0
  28. package/dist/index.d.ts +11 -0
  29. package/dist/index.js +7 -0
  30. package/dist/mcp.d.ts +7 -0
  31. package/dist/mcp.js +14 -0
  32. package/dist/network/discovery.d.ts +101 -0
  33. package/dist/network/discovery.js +276 -0
  34. package/dist/network/transport.d.ts +31 -0
  35. package/dist/network/transport.js +305 -0
  36. package/dist/protocol/sse.d.ts +15 -0
  37. package/dist/protocol/sse.js +90 -0
  38. package/dist/protocol/wire.d.ts +128 -0
  39. package/dist/protocol/wire.js +278 -0
  40. package/dist/reference-auth/index.d.ts +209 -0
  41. package/dist/reference-auth/index.js +999 -0
  42. package/dist/reference-auth/store.d.ts +53 -0
  43. package/dist/reference-auth/store.js +2 -0
  44. package/dist/sqlite-init.d.ts +2 -0
  45. package/dist/sqlite-init.js +17 -0
  46. package/dist/sqlite.d.ts +27 -0
  47. package/dist/sqlite.js +369 -0
  48. package/dist/store.d.ts +1 -0
  49. package/dist/store.js +1 -0
  50. package/examples/quickstart.mjs +29 -0
  51. package/package.json +67 -4
@@ -0,0 +1,999 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { calculateJwkThumbprint, compactVerify, createLocalJWKSet, errors } from 'jose';
3
+ import { ValidationError, parseAssertionClaims, parseJson } from '../protocol/wire.js';
4
+ import { secureUrl } from '../network/transport.js';
5
+ import { own } from '../store.js';
6
+ import { clientRevisionKey, accountRevisionKey } from './store.js';
7
+ import { AuthorityError } from '../auth/authority.js';
8
+ import { CLOCK_SKEW_SECONDS, PROOF_WINDOW_SECONDS, assertUniqueJwtMembers, verifyDpopProof } from '../auth/dpop.js';
9
+ export { createMetadataResolver } from '../network/discovery.js';
10
+ export { createNodeTransport } from '../network/transport.js';
11
+ // Authorization request failure for the host's sign-in page. redirectTo is set only once client and redirect_uri are verified;
12
+ // without it the host shows an error page and never redirects (RFC 6749 4.1.2.1).
13
+ export class AuthorizationError extends Error {
14
+ code;
15
+ redirectTo;
16
+ constructor(code, redirectTo) {
17
+ super(`Authorization failed: ${code}`);
18
+ this.code = code;
19
+ this.redirectTo = redirectTo;
20
+ this.name = 'AuthorizationError';
21
+ }
22
+ }
23
+ const JWT_BEARER = 'urn:ietf:params:oauth:grant-type:jwt-bearer';
24
+ const CLIENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
25
+ const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
26
+ const ASSERTION_ALGORITHMS = ['ES256', 'RS256'];
27
+ const NONCE_SECONDS = 300;
28
+ const MAX_FORM_BYTES = 65536;
29
+ const AUTHORIZATION_MS = 600_000;
30
+ const CODE_MS = 60_000;
31
+ // 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
+ const random = (bytes) => randomBytes(bytes).toString('base64url');
34
+ const tokenHash = (token) => createHash('sha256').update(token).digest('base64url');
35
+ const grantLive = (grant, at) => !!grant && !grant.revoked && grant.expiresAt > at;
36
+ function callback(redirectUri, fields) {
37
+ const url = new URL(redirectUri);
38
+ for (const [name, value] of Object.entries(fields))
39
+ url.searchParams.set(name, value);
40
+ return url.href;
41
+ }
42
+ // Shape changes bump the version; other versions are refused instead of misread.
43
+ function checked(state) {
44
+ if (state.version !== 3)
45
+ throw new Error('unsupported issuer state version');
46
+ return state;
47
+ }
48
+ export function initialIssuerState() {
49
+ return { version: 3, generation: random(16), sessions: {}, grants: {}, refreshTokens: {}, authorizations: {}, codes: {}, signIns: {}, accessTokens: {}, replay: {}, nonces: [] };
50
+ }
51
+ class TokenError extends Error {
52
+ code;
53
+ headers;
54
+ constructor(code, headers = {}) {
55
+ super(`Token request failed: ${code}`);
56
+ this.code = code;
57
+ this.headers = headers;
58
+ this.name = 'TokenError';
59
+ }
60
+ }
61
+ function json(status, body, headers = {}) {
62
+ return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', 'cache-control': 'no-store', ...headers } });
63
+ }
64
+ // Unauthenticated public endpoint: stop reading once the byte limit is passed.
65
+ async function readForm(request) {
66
+ if (Number(request.headers.get('content-length') ?? 0) > MAX_FORM_BYTES)
67
+ throw new TokenError('invalid_request');
68
+ if (!request.body)
69
+ return '';
70
+ const reader = request.body.getReader();
71
+ const chunks = [];
72
+ let length = 0;
73
+ try {
74
+ while (true) {
75
+ const item = await reader.read();
76
+ if (item.done)
77
+ break;
78
+ length += item.value.length;
79
+ if (length > MAX_FORM_BYTES)
80
+ throw new TokenError('invalid_request');
81
+ chunks.push(item.value);
82
+ }
83
+ }
84
+ finally {
85
+ await reader.cancel().catch(() => { });
86
+ }
87
+ return Buffer.concat(chunks).toString('utf8');
88
+ }
89
+ // Repeated parameters are rejected (RFC 6749 3.1, 3.2).
90
+ function uniqueParams(entries, error) {
91
+ const params = new Map();
92
+ for (const [key, value] of entries) {
93
+ if (params.has(key))
94
+ throw error();
95
+ params.set(key, value);
96
+ }
97
+ return params;
98
+ }
99
+ async function readParams(request) {
100
+ if (request.method !== 'POST' || !/^application\/x-www-form-urlencoded\s*(;|$)/i.test(request.headers.get('content-type') ?? ''))
101
+ throw new TokenError('invalid_request');
102
+ return uniqueParams(new URLSearchParams(await readForm(request)), () => new TokenError('invalid_request'));
103
+ }
104
+ export function createReferenceIssuer(options) {
105
+ const { issuer, metadata, store, now } = options;
106
+ if (secureUrl(issuer).search)
107
+ throw new TypeError('issuer');
108
+ secureUrl(options.tokenEndpoint);
109
+ secureUrl(options.revocationEndpoint);
110
+ const positive = (value, fallback, name) => {
111
+ const result = value ?? fallback;
112
+ if (!Number.isSafeInteger(result) || result <= 0)
113
+ throw new TypeError(name);
114
+ return result;
115
+ };
116
+ const lifetimeSeconds = positive(options.tokenLifetimeSeconds, 3600, 'tokenLifetimeSeconds');
117
+ const lifetimeMs = lifetimeSeconds * 1000;
118
+ const accountLifetimeMs = positive(options.accountLifetimeSeconds, 2_592_000, 'accountLifetimeSeconds') * 1000;
119
+ const maxPending = positive(options.maxPendingAuthorizations, 1000, 'maxPendingAuthorizations');
120
+ 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;
123
+ const requireNonce = options.requireDpopNonce ?? true;
124
+ if (options.device) {
125
+ secureUrl(options.device.endpoint);
126
+ const url = secureUrl(options.device.verificationUri);
127
+ const hosts = [new URL(issuer).hostname, ...options.poppyDomains];
128
+ if (!hosts.some(host => url.hostname === host || url.hostname.endsWith(`.${host}`)))
129
+ throw new TypeError('verificationUri');
130
+ }
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
+ }
136
+ const deviceIntervalMs = positive(options.device?.intervalSeconds, 5, 'intervalSeconds') * 1000;
137
+ const maxCodeAttempts = positive(options.mediated?.maxCodeAttempts, 5, 'maxCodeAttempts');
138
+ const maxAttempts = positive(options.mediated?.maxAttemptsPerMinute, 5, 'maxAttemptsPerMinute');
139
+ const maxClientAttempts = positive(options.mediated?.maxClientAttemptsPerMinute, 100, 'maxClientAttemptsPerMinute');
140
+ const mcpResources = options.mcpResources ?? [];
141
+ for (const entry of mcpResources) {
142
+ const url = secureUrl(entry.url);
143
+ if (url.search || typeof entry.dpop !== 'boolean' || mcpResources.filter(item => item.url === entry.url).length !== 1)
144
+ throw new TypeError('mcpResources');
145
+ }
146
+ const resources = [...new Set([...(options.resources ?? []), ...mcpResources.map(entry => entry.url)])];
147
+ const isBearerResource = (resource) => mcpResources.some(entry => entry.url === resource && !entry.dpop);
148
+ // Every issuer mutation is ordered with conversation work by the shared coordinator.
149
+ const read = async (selection = { kind: 'metadata' }) => checked(await store.read(selection));
150
+ 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
+ // Reuse the current nonce while it has more than half its life left, so challenges cannot grow state.
162
+ async function currentNonce(state) {
163
+ const fresh = state.nonces.find(nonce => nonce.expiresAt - now() > NONCE_SECONDS * 500);
164
+ if (fresh)
165
+ return fresh.value;
166
+ return commit({ kind: 'metadata' }, draft => {
167
+ const value = random(16);
168
+ draft.nonces = [...draft.nonces.filter(nonce => nonce.expiresAt > now()), { value, expiresAt: now() + NONCE_SECONDS * 1000 }].slice(-2);
169
+ return value;
170
+ });
171
+ }
172
+ async function verifyAssertion(jwt, keys) {
173
+ assertUniqueJwtMembers(jwt); // before jose, which keeps the last of any duplicate claim
174
+ const { payload, protectedHeader } = await compactVerify(jwt, createLocalJWKSet(keys), { algorithms: ASSERTION_ALGORITHMS });
175
+ // Media types are case-insensitive and may carry the application/ prefix (RFC 7515 4.1.9).
176
+ if (protectedHeader.typ?.toLowerCase().replace(/^application\//, '') === 'poppy-browser+jwt')
177
+ throw new TypeError('browser assertion'); // spec 5: not accepted here
178
+ return parseAssertionClaims(JSON.parse(new TextDecoder().decode(payload)));
179
+ }
180
+ const current = (claims) => claims.exp * 1000 > now() && claims.iat <= now() / 1000 + CLOCK_SKEW_SECONDS;
181
+ // Fetches the client's keys and verifies its client assertion (spec 4.1); verify() is reused for the session assertion.
182
+ async function authenticateClient(clientId, clientAssertion) {
183
+ let jwksUri;
184
+ let keys;
185
+ try {
186
+ jwksUri = (await metadata.resolveClient(clientId)).jwks_uri;
187
+ keys = await metadata.resolveJwks(jwksUri);
188
+ }
189
+ catch (error) {
190
+ // The shared resolver signals its fetch cap as ValidationError('metadata.rate_limited'); its window is 60 s.
191
+ if (error instanceof ValidationError && error.field === 'metadata.rate_limited')
192
+ throw new TokenError('rate_limited', { 'Retry-After': '60' });
193
+ throw new TokenError('invalid_client');
194
+ }
195
+ let refreshed = false;
196
+ const verify = async (jwt) => {
197
+ try {
198
+ return await verifyAssertion(jwt, keys);
199
+ }
200
+ catch (error) {
201
+ if (refreshed || !(error instanceof errors.JWKSNoMatchingKey))
202
+ throw error;
203
+ refreshed = true; // one bounded refresh for an unknown kid; the resolver rate-limits it too
204
+ try {
205
+ keys = await metadata.resolveJwks(jwksUri, { refresh: true });
206
+ }
207
+ catch (limit) {
208
+ if (limit instanceof ValidationError && limit.field === 'jwks.refresh_limited')
209
+ throw new TokenError('rate_limited', { 'Retry-After': '60' });
210
+ throw limit;
211
+ }
212
+ return verifyAssertion(jwt, keys);
213
+ }
214
+ };
215
+ let client;
216
+ try {
217
+ client = await verify(clientAssertion);
218
+ }
219
+ catch (error) {
220
+ throw error instanceof TokenError ? error : new TokenError('invalid_client');
221
+ }
222
+ if (client.iss !== clientId || client.sub !== clientId || client.aud !== issuer || !current(client))
223
+ throw new TokenError('invalid_client');
224
+ return { client, verify, keys: () => keys };
225
+ }
226
+ function clientCredentials(params) {
227
+ const clientId = params.get('client_id');
228
+ const clientAssertion = params.get('client_assertion');
229
+ if (!clientId || !clientAssertion || params.get('client_assertion_type') !== CLIENT_ASSERTION_TYPE)
230
+ throw new TokenError('invalid_client');
231
+ return { clientId, clientAssertion };
232
+ }
233
+ // Drops everything past its lifetime so state stays bounded by live records.
234
+ function prune(draft, at) {
235
+ for (const [key, until] of Object.entries(draft.replay))
236
+ if (until < at)
237
+ delete draft.replay[key];
238
+ for (const [key, record] of Object.entries(draft.accessTokens))
239
+ if (record.expiresAt <= at)
240
+ delete draft.accessTokens[key];
241
+ for (const [key, record] of Object.entries(draft.sessions))
242
+ if (!(record.expiresAt > at))
243
+ delete draft.sessions[key];
244
+ for (const [key, record] of Object.entries(draft.grants))
245
+ if (!grantLive(record, at))
246
+ delete draft.grants[key];
247
+ for (const [key, grantId] of Object.entries(draft.refreshTokens))
248
+ if (!Object.hasOwn(draft.grants, grantId))
249
+ delete draft.refreshTokens[key];
250
+ for (const [key, record] of Object.entries(draft.codes))
251
+ if (record.expiresAt <= at)
252
+ delete draft.codes[key];
253
+ for (const [key, record] of Object.entries(draft.authorizations))
254
+ if (record.expiresAt <= at)
255
+ delete draft.authorizations[key];
256
+ for (const [key, record] of Object.entries(draft.signIns))
257
+ if (record.expiresAt + CLOCK_SKEW_SECONDS * 1000 < at)
258
+ delete draft.signIns[key];
259
+ }
260
+ // Revoking a grant signs out every Session using it; they continue signed out and stay bound to the account (spec 4.9).
261
+ function revokeGrant(draft, grantId) {
262
+ const grant = own(draft.grants, grantId);
263
+ if (!grant)
264
+ return;
265
+ grant.revoked = true;
266
+ for (const session of Object.values(draft.sessions))
267
+ if (session.grantId === grantId)
268
+ session.grantId = null;
269
+ }
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: '' };
276
+ 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 } };
283
+ }
284
+ function signInScopes(scope, allowed) {
285
+ const scopes = scope?.split(' ') ?? [];
286
+ if (!scopes.length || scopes.some(value => !/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(value) || !allowed.includes(value)) || new Set(scopes).size !== scopes.length)
287
+ throw new TokenError('invalid_scope');
288
+ return scopes;
289
+ }
290
+ function consumeReplay(draft, key, until) {
291
+ if ((own(draft.replay, key) ?? 0) > now())
292
+ throw new TokenError('invalid_grant');
293
+ draft.replay[key] = until;
294
+ }
295
+ function revision(draft, key) {
296
+ const row = own(draft.signIns, key);
297
+ return row?.kind === 'revision' ? row.revision : 0;
298
+ }
299
+ function consentCurrent(draft, clientId, accountId, started) {
300
+ return revision(draft, accountRevisionKey(clientId, accountId)) <= started;
301
+ }
302
+ async function startDevice(request) {
303
+ if (!options.device)
304
+ throw new TokenError('unsupported_grant_type');
305
+ const params = await readParams(request), { clientId, clientAssertion } = clientCredentials(params);
306
+ const scopes = signInScopes(params.get('scope'), options.device.scopes), { client } = await authenticateClient(clientId, clientAssertion);
307
+ const { generation } = await read(), userCode = random(8).toUpperCase(), deviceCode = `${userCode}.${random(32)}`, id = tokenHash(userCode);
308
+ const replayKey = `assertion ${clientId} ${client.jti}`;
309
+ await commit({ kind: 'sign_in', ids: [id, clientRevisionKey(clientId)], clientId, replayKeys: [replayKey], at: now() }, draft => {
310
+ if (draft.generation !== generation || !current(client))
311
+ 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')));
313
+ if ((draft.limits?.pending ?? pending.length) >= maxPending || (draft.limits?.pendingForClient ?? pending.filter(row => row.clientId === clientId).length) >= maxPendingPerClient)
314
+ throw new TokenError('rate_limited', { 'Retry-After': '60' });
315
+ 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)) };
317
+ });
318
+ const complete = new URL(options.device.verificationUri);
319
+ complete.searchParams.set('user_code', userCode);
320
+ return json(200, { device_code: deviceCode, user_code: userCode, verification_uri: options.device.verificationUri, verification_uri_complete: complete.href, expires_in: AUTHORIZATION_MS / 1000, interval: deviceIntervalMs / 1000 });
321
+ }
322
+ async function pollDevice(request, params) {
323
+ if (!options.device)
324
+ 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'))
327
+ throw new TokenError('invalid_request');
328
+ if (resource !== undefined && !resources.includes(resource))
329
+ 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()))
333
+ throw new TokenError('use_dpop_nonce', { 'DPoP-Nonce': await currentNonce(state) });
334
+ 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 => {
340
+ if (draft.generation !== state.generation || !current(client))
341
+ throw new TokenError('invalid_client');
342
+ consumeReplay(draft, replayKey, client.exp * 1000);
343
+ if (!bearer)
344
+ consumeReplay(draft, proofKey, (proof.claims.iat + PROOF_WINDOW_SECONDS) * 1000);
345
+ const record = own(draft.signIns, id);
346
+ if (!record || record.kind !== 'device' || record.clientId !== clientId || record.deviceHash !== tokenHash(code))
347
+ return { error: 'invalid_grant' };
348
+ if (record.expiresAt <= now())
349
+ return { error: 'expired_token' };
350
+ if (record.status === 'denied')
351
+ return { error: 'access_denied' };
352
+ if (record.status === 'consumed')
353
+ return { error: 'invalid_grant' };
354
+ if (record.nextPollAt > now()) {
355
+ record.intervalMs += 5000;
356
+ record.nextPollAt = now() + record.intervalMs;
357
+ return { error: 'slow_down' };
358
+ }
359
+ record.nextPollAt = now() + record.intervalMs;
360
+ if (record.status === 'pending')
361
+ 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);
366
+ record.status = 'consumed';
367
+ return granted.body;
368
+ });
369
+ return json('error' in body ? 400 : 200, body);
370
+ }
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) {
378
+ const config = options.mediated;
379
+ if (!config)
380
+ 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(/\/$/, '')}/`)))
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') ?? ''))
390
+ 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 = '';
402
+ try {
403
+ let scopes = [];
404
+ if (challenge) {
405
+ if (Object.keys(input).length !== 1 || typeof input.code !== 'string' || !input.code || input.code.length > 4096)
406
+ throw new TokenError('invalid_request');
407
+ code = input.code;
408
+ }
409
+ 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))
414
+ throw new TokenError('invalid_request');
415
+ credentials = fields;
416
+ }
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() };
425
+ 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 (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' };
441
+ }
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' };
447
+ }
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;
461
+ }
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)
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)) };
466
+ draft.signIns[key] = record;
467
+ return { record };
468
+ });
469
+ if ('status' in claim)
470
+ return json(200, claim);
471
+ const record = claim.record;
472
+ let decision = null, correct = false;
473
+ try {
474
+ if (challenge) {
475
+ correct = await config.verifyCode({ challengeId: record.challengeId, code }, context);
476
+ if (typeof correct !== 'boolean')
477
+ throw 0;
478
+ }
479
+ else
480
+ decision = await config.authenticate(credentials, context);
481
+ }
482
+ catch {
483
+ throw new TokenError('temporarily_unavailable');
484
+ }
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))))
487
+ 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))))
489
+ 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';
501
+ return { error: 'invalid_grant' };
502
+ }
503
+ if (current.expiresAt <= now()) {
504
+ current.status = 'expired';
505
+ return { status: 'expired' };
506
+ }
507
+ if ((!challenge && !decision) || (challenge && !correct && current.attempts >= maxCodeAttempts)) {
508
+ current.status = 'failed';
509
+ return { status: 'failed' };
510
+ }
511
+ if (decision)
512
+ current.accountId = decision.accountId;
513
+ if (decision?.scopes)
514
+ current.scopes = [...new Set(decision.scopes)];
515
+ 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;
519
+ }
520
+ 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() };
523
+ }
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 };
527
+ });
528
+ return json('error' in body ? 400 : 200, body);
529
+ }
530
+ 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 = '';
536
+ }
537
+ }
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');
547
+ 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)
578
+ 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
+ if (resource !== undefined && !resources.includes(resource))
585
+ throw new TokenError('invalid_target');
586
+ 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()))
593
+ 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.
615
+ 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)
619
+ 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
+ // Older hashes are forgotten: the previous one is enough to revoke after a lost rotation response.
665
+ delete draft.refreshTokens[lineage.previousHash];
666
+ lineage.previousHash = lineage.refreshHash;
667
+ lineage.refreshHash = tokenHash(successor);
668
+ draft.refreshTokens[lineage.refreshHash] = grantId;
669
+ record.accountId = lineage.accountId;
670
+ record.grantId = grantId;
671
+ return { ...grant(id, record, grantId, scopes), refresh_token: successor, refresh_token_expires_in: Math.floor((lineage.expiresAt - at) / 1000) };
672
+ };
673
+ if (grantType === JWT_BEARER) {
674
+ if (!session)
675
+ throw new TokenError('invalid_grant');
676
+ if (sessionId === undefined)
677
+ return grant(...create(session.sub), null, []);
678
+ const record = existing(session.sub);
679
+ // A signed-in Session is renewed with its Account Token instead (spec 4.2).
680
+ if (record.grantId !== null && grantLive(own(draft.grants, record.grantId), at))
681
+ throw new TokenError('invalid_grant');
682
+ return grant(sessionId, record, null, []);
683
+ }
684
+ if (grantType === 'authorization_code') {
685
+ const record = own(draft.codes, tokenHash(code));
686
+ if (!record || record.expiresAt <= at || record.clientId !== clientId)
687
+ throw new TokenError('invalid_grant');
688
+ if (record.grantId !== null) {
689
+ revokeGrant(draft, record.grantId);
690
+ return null;
691
+ }
692
+ if (record.redirectUri !== redirectUri || tokenHash(codeVerifier) !== record.codeChallenge)
693
+ throw new TokenError('invalid_grant');
694
+ if (!consentCurrent(draft, record.clientId, record.accountId, record.revision))
695
+ throw new TokenError('invalid_grant');
696
+ const target = existing();
697
+ const grantId = `grt_${random(16)}`;
698
+ draft.grants[grantId] = { clientId, userId: target.userId, accountId: record.accountId, scopes: record.scopes, expiresAt: at + accountLifetimeMs, revoked: false, refreshHash: '', previousHash: '' };
699
+ record.grantId = grantId;
700
+ return signIn(sessionId, target, grantId);
701
+ }
702
+ const presented = tokenHash(refreshToken);
703
+ const grantId = own(draft.refreshTokens, presented);
704
+ const lineage = grantId === undefined ? undefined : own(draft.grants, grantId);
705
+ // Reuse of the previous Account Token (e.g. a retry after a lost response) revokes the lineage, so its Sessions
706
+ // continue signed out instead of staying bound to a successor nobody holds (RFC 9700 4.14.2).
707
+ if (lineage && lineage.clientId === clientId && presented === lineage.previousHash) {
708
+ revokeGrant(draft, grantId);
709
+ return null;
710
+ }
711
+ // A rotated Account Token never issues another successor.
712
+ if (!lineage || !grantLive(lineage, at) || lineage.clientId !== clientId || lineage.refreshHash !== presented)
713
+ throw new TokenError('invalid_grant');
714
+ return sessionId === undefined ? signIn(...create(lineage.userId), grantId) : signIn(sessionId, existing(lineage.userId), grantId);
715
+ });
716
+ // A reused code or previous Account Token revoked its lineage in the commit above.
717
+ if (body === null)
718
+ throw new TokenError('invalid_grant');
719
+ return json(200, body);
720
+ }
721
+ // RFC 7009 with private_key_jwt client authentication; unknown and foreign tokens succeed without changing any grant.
722
+ async function revokeToken(request) {
723
+ const params = await readParams(request);
724
+ const { clientId, clientAssertion } = clientCredentials(params);
725
+ const token = params.get('token');
726
+ if (!token)
727
+ throw new TokenError('invalid_request');
728
+ const { generation } = await read();
729
+ const { client } = await authenticateClient(clientId, clientAssertion);
730
+ await commit({ kind: 'revoke', refreshHash: tokenHash(token), replayKey: `assertion ${clientId} ${client.jti}` }, draft => {
731
+ if (draft.generation !== generation)
732
+ return; // reset already removed every grant
733
+ const at = now();
734
+ if (client.exp * 1000 <= at)
735
+ throw new TokenError('invalid_client');
736
+ prune(draft, at);
737
+ const replayKey = `assertion ${clientId} ${client.jti}`;
738
+ if (own(draft.replay, replayKey) !== undefined)
739
+ throw new TokenError('invalid_client');
740
+ draft.replay[replayKey] = client.exp * 1000;
741
+ // A rotated token still names its lineage, so revoking it after a lost rotation response ends the successor too.
742
+ const grantId = own(draft.refreshTokens, tokenHash(token));
743
+ if (grantId !== undefined && own(draft.grants, grantId)?.clientId === clientId)
744
+ revokeGrant(draft, grantId);
745
+ });
746
+ return new Response(null, { status: 200, headers: { 'cache-control': 'no-store' } });
747
+ }
748
+ async function respond(request, handler) {
749
+ try {
750
+ return await handler(request);
751
+ }
752
+ catch (error) {
753
+ // Early rejects leave the body unread; release the stream instead of leaving it open.
754
+ if (!request.bodyUsed)
755
+ await request.body?.cancel().catch(() => { });
756
+ const code = error instanceof TokenError || error instanceof AuthorityError ? error.code : undefined;
757
+ if (!code)
758
+ throw error;
759
+ const status = code === 'invalid_client' || code === 'invalid_token' ? 401 : code === 'rate_limited' ? 429 : code === 'sign_in_not_found' ? 404 : code === 'temporarily_unavailable' ? 503 : 400;
760
+ return json(status, { error: code }, error instanceof TokenError ? error.headers : {});
761
+ }
762
+ }
763
+ // Plain checks over one issuer snapshot; only the policy wrappers below take the coordinator, once.
764
+ function liveGrant(state, principal) {
765
+ if (principal.accountId === undefined && principal.grantId === undefined)
766
+ return true;
767
+ const grant = principal.grantId === undefined ? undefined : own(state.grants, principal.grantId);
768
+ return grantLive(grant, now()) && grant.clientId === principal.clientId && grant.accountId === principal.accountId;
769
+ }
770
+ function requireScopes(granted, scopes) {
771
+ if (!scopes.every(scope => granted.includes(scope)))
772
+ throw new AuthorityError('insufficient_scope', { scope: scopes.join(' ') });
773
+ }
774
+ // Order: coordinator -> issuer snapshot -> caller's conversation transaction, released in run's finally.
775
+ const policy = {
776
+ withCurrent: (principal, scopes, action) => options.coordinator.run(async () => {
777
+ const state = await read({ kind: 'principal', sessionId: principal.sessionId, ...(principal.grantId ? { grantId: principal.grantId } : {}) });
778
+ const session = own(state.sessions, principal.sessionId);
779
+ if (principal.issuer !== issuer || principal.issuerGeneration !== state.generation || !session || session.clientId !== principal.clientId
780
+ || session.expiresAt <= now() || session.userId !== principal.userId || !liveGrant(state, principal))
781
+ throw new AuthorityError('invalid_token');
782
+ requireScopes(principal.scopes, scopes);
783
+ return action();
784
+ }),
785
+ withAccepted: (authority, scopes, action) => options.coordinator.run(async () => {
786
+ const { principal } = authority;
787
+ const state = await read({ kind: 'principal', sessionId: principal.sessionId, ...(principal.grantId ? { grantId: principal.grantId } : {}) });
788
+ // Admission token expiry is ignored: accepted work outlives its token. A reset (new generation) never is.
789
+ if (principal.issuer !== issuer || principal.issuerGeneration !== state.generation)
790
+ throw new AuthorityError('invalid_token');
791
+ if (authority.revocationPolicy !== 'allow-accepted-work-to-finish' && !liveGrant(state, principal))
792
+ throw new AuthorityError('invalid_token');
793
+ // The ceiling is the accepted scopes; a later, broader sign-in never widens old work.
794
+ requireScopes(principal.scopes, scopes);
795
+ return action();
796
+ }),
797
+ };
798
+ return {
799
+ browserSessions: {
800
+ async current(binding) { return browserSession(await read({ kind: 'browser', sessionId: binding.sessionId }), binding); },
801
+ join(binding, assertion) {
802
+ const replayKey = `assertion ${binding.clientId} ${assertion.jti}`;
803
+ return commit({ kind: 'browser', sessionId: binding.sessionId, replayKey }, draft => {
804
+ if (assertion.expiresAt <= now() || assertion.expiresAt > now() + 90000 || !assertion.jti || assertion.jti.length > 256 || (own(draft.replay, replayKey) ?? 0) > now())
805
+ throw new AuthorityError('invalid_token');
806
+ const session = browserSession(draft, binding);
807
+ draft.replay[replayKey] = assertion.expiresAt;
808
+ return session;
809
+ });
810
+ },
811
+ },
812
+ token: request => respond(request, issue),
813
+ revoke: request => respond(request, revokeToken),
814
+ deviceAuthorization: request => respond(request, startDevice),
815
+ mediatedSignIn: request => respond(request, mediatedSignIn),
816
+ async beginDeviceAuthorization(userCode, browserBinding) {
817
+ if (!options.device || typeof userCode !== 'string' || typeof browserBinding !== 'string' || browserBinding.length < 22)
818
+ throw new AuthorizationError('invalid_request');
819
+ const id = tokenHash(userCode), selection = { kind: 'sign_in', ids: [id], replayKeys: [], at: now() };
820
+ const state = await read(selection), record = own(state.signIns, id);
821
+ if (!record || record.kind !== 'device' || record.status !== 'pending' || record.expiresAt <= now())
822
+ throw new AuthorizationError('invalid_request');
823
+ const client = await metadata.resolveClient(record.clientId);
824
+ return commit(selection, draft => {
825
+ const current = own(draft.signIns, id);
826
+ if (!current || current.kind !== 'device' || current.status !== 'pending' || current.expiresAt <= now())
827
+ throw new AuthorizationError('invalid_request');
828
+ current.browserHash = tokenHash(browserBinding);
829
+ return { id: userCode, clientId: current.clientId, clientName: client.client_name, scopes: [...current.scopes], expiresAt: current.expiresAt };
830
+ });
831
+ },
832
+ async completeDeviceAuthorization(userCode, browserBinding, decision) {
833
+ if (!options.device || typeof userCode !== 'string' || typeof browserBinding !== 'string')
834
+ throw new AuthorizationError('invalid_request');
835
+ const id = tokenHash(userCode);
836
+ const snapshot = await read({ kind: 'sign_in', ids: [id], replayKeys: [], at: now() }), found = own(snapshot.signIns, id);
837
+ if (!found || found.kind !== 'device')
838
+ throw new AuthorizationError('invalid_request');
839
+ const accountId = 'accountId' in decision && typeof decision.accountId === 'string' ? decision.accountId : undefined;
840
+ await commit({ kind: 'sign_in', ids: [id, ...(accountId ? [accountRevisionKey(found.clientId, accountId)] : [])], replayKeys: [], at: now() }, draft => {
841
+ const record = own(draft.signIns, id);
842
+ if (!record || record.kind !== 'device' || record.status !== 'pending' || record.expiresAt <= now() || record.browserHash !== tokenHash(browserBinding))
843
+ throw new AuthorizationError('invalid_request');
844
+ if ('denied' in decision && decision.denied) {
845
+ record.status = 'denied';
846
+ return;
847
+ }
848
+ const { accountId, scopes } = decision;
849
+ if (typeof accountId !== 'string' || !accountId || !Array.isArray(scopes) || !scopes.length || !scopes.every(scope => record.scopes.includes(scope)))
850
+ throw new TypeError('decision');
851
+ if (!consentCurrent(draft, record.clientId, accountId, record.revision))
852
+ throw new AuthorizationError('invalid_request');
853
+ record.accountId = accountId;
854
+ record.scopes = [...new Set(scopes)];
855
+ record.status = 'approved';
856
+ });
857
+ },
858
+ // Validates the whole request before persisting anything; the host renders login and consent from the view.
859
+ // Unauthenticated: hosts must rate-limit their /authorize route; the pending cap only bounds state and coordinator time.
860
+ async beginAuthorization(request, browserBinding) {
861
+ if (typeof browserBinding !== 'string' || !browserBinding)
862
+ throw new TypeError('browserBinding');
863
+ const params = uniqueParams(new URL(request.url).searchParams, () => new AuthorizationError('invalid_request'));
864
+ const clientId = params.get('client_id');
865
+ const redirectUri = params.get('redirect_uri');
866
+ if (!clientId || !redirectUri)
867
+ throw new AuthorizationError('invalid_request');
868
+ const { generation } = await read();
869
+ let client;
870
+ try {
871
+ client = await metadata.resolveClient(clientId);
872
+ }
873
+ catch {
874
+ throw new AuthorizationError('invalid_client');
875
+ }
876
+ // Exact string match against the client's metadata (spec 4.5).
877
+ if (!client.redirect_uris.includes(redirectUri))
878
+ throw new AuthorizationError('invalid_request');
879
+ try {
880
+ if (secureUrl(redirectUri).hostname !== secureUrl(clientId).hostname)
881
+ throw 0;
882
+ }
883
+ catch {
884
+ throw new AuthorizationError('invalid_request');
885
+ }
886
+ const state = params.get('state');
887
+ const fail = (code) => new AuthorizationError(code, new URL(callback(redirectUri, { error: code, ...(state ? { state } : {}), iss: issuer })));
888
+ if (params.get('response_type') !== 'code')
889
+ throw fail('unsupported_response_type');
890
+ if (!state || state.length > 512)
891
+ throw fail('invalid_request');
892
+ const codeChallenge = params.get('code_challenge') ?? '';
893
+ if (params.get('code_challenge_method') !== 'S256' || !/^[A-Za-z0-9_-]{43}$/.test(codeChallenge))
894
+ throw fail('invalid_request');
895
+ const scopes = [...new Set((params.get('scope') ?? '').split(' ').filter(Boolean))];
896
+ if (!scopes.length || !scopes.every(scope => options.directScopes.includes(scope)))
897
+ throw fail('invalid_scope');
898
+ // resource is accepted and ignored here: grants cover every resource, and each token request selects one (spec 4.3).
899
+ const resource = params.get('resource');
900
+ if (resource !== undefined && !resources.includes(resource))
901
+ throw fail('invalid_target');
902
+ return commit({ kind: 'authorize', clientId, at: now() }, draft => {
903
+ if (draft.generation !== generation)
904
+ throw new AuthorizationError('invalid_request');
905
+ const at = now();
906
+ prune(draft, at);
907
+ // shortcut: an unauthenticated caller can fill one client's slots for AUTHORIZATION_MS (10 minutes); hosts must rate-limit /authorize.
908
+ const pending = Object.values(draft.authorizations);
909
+ if ((draft.limits?.pending ?? pending.length) >= maxPending || (draft.limits?.pendingForClient ?? pending.filter(record => record.clientId === clientId).length) >= maxPendingPerClient)
910
+ throw fail('temporarily_unavailable');
911
+ const id = random(16);
912
+ const expiresAt = at + AUTHORIZATION_MS;
913
+ draft.authorizations[id] = { clientId, redirectUri, state, codeChallenge, scopes, browserHash: tokenHash(browserBinding), expiresAt, revision: revision(draft, clientRevisionKey(clientId)) };
914
+ return { id, clientId, clientName: client.client_name, scopes, expiresAt };
915
+ });
916
+ },
917
+ // Consumes the authorization once; the browser binding is checked before deny as well as approve.
918
+ async completeAuthorization(id, browserBinding, decision) {
919
+ if (typeof id !== 'string' || typeof browserBinding !== 'string')
920
+ throw new AuthorizationError('invalid_request');
921
+ const snapshot = own((await read({ kind: 'authorization', id })).authorizations, id);
922
+ const accountId = 'accountId' in decision && typeof decision.accountId === 'string' ? decision.accountId : undefined;
923
+ const href = await commit({ kind: 'authorization', id, ...(snapshot && accountId ? { revocationKey: accountRevisionKey(snapshot.clientId, accountId) } : {}) }, draft => {
924
+ const record = own(draft.authorizations, id);
925
+ if (!record || record.expiresAt <= now() || record.browserHash !== tokenHash(browserBinding))
926
+ throw new AuthorizationError('invalid_request');
927
+ const fields = { state: record.state, iss: issuer };
928
+ if ('denied' in decision && decision.denied === true) {
929
+ delete draft.authorizations[id];
930
+ return callback(record.redirectUri, { error: 'access_denied', ...fields });
931
+ }
932
+ const { accountId, scopes } = decision;
933
+ // Partial consent narrows; the host may never grant more than was requested.
934
+ if (typeof accountId !== 'string' || !accountId || !Array.isArray(scopes) || !scopes.length || !scopes.every(scope => record.scopes.includes(scope)))
935
+ throw new TypeError('decision');
936
+ if (!consentCurrent(draft, record.clientId, accountId, record.revision))
937
+ throw new AuthorizationError('invalid_request');
938
+ delete draft.authorizations[id];
939
+ const code = random(32);
940
+ draft.codes[tokenHash(code)] = { clientId: record.clientId, redirectUri: record.redirectUri, codeChallenge: record.codeChallenge, accountId, scopes: [...new Set(scopes)], expiresAt: now() + CODE_MS, grantId: null, revision: record.revision };
941
+ return callback(record.redirectUri, { code, ...fields });
942
+ });
943
+ return new URL(href);
944
+ },
945
+ authorizationMetadata() {
946
+ return {
947
+ issuer, token_endpoint: options.tokenEndpoint, revocation_endpoint: options.revocationEndpoint,
948
+ ...(options.authorizationEndpoint ? { authorization_endpoint: options.authorizationEndpoint, response_types_supported: ['code'], code_challenge_methods_supported: ['S256'] } : {}),
949
+ ...(options.device ? { device_authorization_endpoint: options.device.endpoint } : {}),
950
+ dpop_signing_alg_values_supported: ['ES256'], client_id_metadata_document_supported: true,
951
+ poppy_domains: [...options.poppyDomains],
952
+ };
953
+ },
954
+ authority: {
955
+ issuer,
956
+ revocationVisibility: 'immediate: every introspection and policy check reads the shared issuer store; policy checks are ordered by the shared coordinator',
957
+ async introspect(token, resource) {
958
+ const state = await read({ kind: 'access', hash: tokenHash(token) });
959
+ const at = now();
960
+ const record = own(state.accessTokens, tokenHash(token));
961
+ const session = record && own(state.sessions, record.sessionId);
962
+ if (!record || !session || !(session.expiresAt > at) || record.expiresAt <= at || record.resource !== (resource ?? null))
963
+ throw new AuthorityError('invalid_token');
964
+ // A signed-in token stops working once its sign-in is revoked or expires; a signed-out one stays signed out.
965
+ const grant = record.grantId === null ? undefined : own(state.grants, record.grantId);
966
+ if (record.grantId !== null && !grantLive(grant, at))
967
+ throw new AuthorityError('invalid_token');
968
+ return {
969
+ principal: {
970
+ issuer, clientId: session.clientId, userId: session.userId, sessionId: record.sessionId,
971
+ ...(grant ? { accountId: grant.accountId, grantId: record.grantId } : {}),
972
+ scopes: [...record.scopes], tokenExpiresAt: record.expiresAt, sessionVersion: session.version, issuerGeneration: state.generation,
973
+ },
974
+ dpopJkt: record.jkt || null,
975
+ };
976
+ },
977
+ policy,
978
+ },
979
+ // The User disconnected this Personal Agent: revoke its grants for the account and sign out their Sessions (spec 4.9).
980
+ // Its codes for the account go too, so none consented before can sign in again; pending authorizations name no account until consent.
981
+ disconnect: (clientId, accountId) => commit({ kind: 'disconnect', clientId, accountId }, draft => {
982
+ for (const [grantId, grant] of Object.entries(draft.grants))
983
+ if (grant.clientId === clientId && grant.accountId === accountId)
984
+ revokeGrant(draft, grantId);
985
+ for (const [key, code] of Object.entries(draft.codes))
986
+ if (code.clientId === clientId && code.accountId === accountId)
987
+ delete draft.codes[key];
988
+ for (const [key, record] of Object.entries(draft.signIns))
989
+ if ((record.kind === 'device' || record.kind === 'mediated') && record.clientId === clientId && record.accountId === accountId)
990
+ delete draft.signIns[key];
991
+ const next = revision(draft, clientRevisionKey(clientId)) + 1;
992
+ if (!Number.isSafeInteger(next))
993
+ throw new TokenError('temporarily_unavailable');
994
+ // Retain account fences until explicit issuer reset; unrelated users sharing a client remain unaffected.
995
+ draft.signIns[clientRevisionKey(clientId)] = { kind: 'revision', clientId, revision: next, expiresAt: Number.MAX_SAFE_INTEGER };
996
+ draft.signIns[accountRevisionKey(clientId, accountId)] = { kind: 'revision', clientId, accountId, revision: next, expiresAt: Number.MAX_SAFE_INTEGER };
997
+ }),
998
+ };
999
+ }