@poppykit/server 0.3.1 → 0.4.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.
@@ -50,7 +50,26 @@ function positive(value, field) {
50
50
  export function parseMessage(value, options = {}) {
51
51
  const message = { ...object(value, 'message') };
52
52
  id(message.id, 'message.id');
53
- member(message.sender, ['agent', 'human'], 'message.sender');
53
+ const author = object(message.author, 'message.author');
54
+ member(author.type, ['agent', 'human'], 'message.author.type');
55
+ if ('sender' in message)
56
+ throw new ValidationError('message.sender');
57
+ for (const key of ['id', 'name', 'image'])
58
+ if (key in author) {
59
+ if (!options.company && !(options.recorded && message.role === 'company'))
60
+ throw new ValidationError('message.author.' + key);
61
+ string(author[key], 'message.author.' + key);
62
+ }
63
+ if ('image' in author) {
64
+ try {
65
+ const url = new URL(author.image);
66
+ if (url.protocol !== 'https:' || url.username || url.password)
67
+ throw 0;
68
+ }
69
+ catch {
70
+ throw new ValidationError('message.author.image');
71
+ }
72
+ }
54
73
  if (!['text', 'data', 'context'].some(key => key in message))
55
74
  throw new ValidationError('message.content');
56
75
  if ('text' in message)
@@ -126,9 +145,9 @@ export function parseEvent(value) {
126
145
  member(event.responder, ['agent', 'human'], 'event.responder');
127
146
  break;
128
147
  case 'authorization':
129
- member(event.error, ['sign_in_required', 'insufficient_scope'], 'event.error');
130
- if (event.error === 'insufficient_scope' || 'scope' in event)
131
- string(event.scope, 'event.scope', false);
148
+ member(event.error, ['insufficient_scope'], 'event.error');
149
+ if (!/^[\x21\x23-\x5B\x5D-\x7E]+(?: [\x21\x23-\x5B\x5D-\x7E]+)*$/.test(string(event.scope, 'event.scope')))
150
+ throw new ValidationError('event.scope');
132
151
  break;
133
152
  case 'user_requested':
134
153
  string(event.reason, 'event.reason', false);
@@ -161,10 +180,12 @@ export function parseEventPage(value, requestCursor) {
161
180
  export function parseTokenResponse(value) {
162
181
  const token = object(value);
163
182
  string(token.access_token, 'access_token');
164
- string(token.session_id, 'session_id');
165
- string(token.scope, 'scope', false);
183
+ if ('scope' in token) {
184
+ const scope = string(token.scope, 'scope', false);
185
+ if (scope !== '' && (!/^[\x21\x23-\x5B\x5D-\x7E]+(?: [\x21\x23-\x5B\x5D-\x7E]+)*$/.test(scope) || new Set(scope.split(' ')).size !== scope.split(' ').length))
186
+ throw new ValidationError('scope');
187
+ }
166
188
  member(token.token_type, ['DPoP', 'Bearer'], 'token_type');
167
- member(token.signed_in, [true, false], 'signed_in');
168
189
  positive(token.expires_in, 'expires_in');
169
190
  if ('refresh_token' in token)
170
191
  string(token.refresh_token, 'refresh_token');
@@ -215,6 +236,11 @@ export function parseAssertionClaims(value) {
215
236
  const claims = object(value, 'claims');
216
237
  for (const key of ['iss', 'sub', 'aud', 'jti'])
217
238
  string(claims[key], key);
239
+ if ('agent_id' in claims) {
240
+ string(claims.agent_id, 'agent_id');
241
+ if (claims.agent_id.length > 256)
242
+ throw new ValidationError('agent_id');
243
+ }
218
244
  // 128 random bits is at least 16 bytes; cap length since jti lands in the replay store. Encoding is not constrained.
219
245
  if (claims.jti.length < 16 || claims.jti.length > 256)
220
246
  throw new ValidationError('jti');
@@ -1,13 +1,14 @@
1
1
  import type { MetadataResolver } from '../network/discovery.js';
2
2
  import type { IssuerStore, IssuerSelection } from './store.js';
3
3
  import type { AuthorityCoordinator, ExternalAuthorityAdapter } from '../auth/authority.js';
4
- import type { BrowserSessionAuthority } from '../browser.js';
4
+ import type { BrowserSession, BrowserSessionAuthority } from '../browser.js';
5
5
  export type { IssuerStore, IssuerSelection };
6
6
  export { createMetadataResolver } from '../network/discovery.js';
7
7
  export { createNodeTransport } from '../network/transport.js';
8
8
  interface SessionRecord {
9
9
  clientId: string;
10
10
  userId: string;
11
+ agentId?: string;
11
12
  version: number;
12
13
  createdAt: number;
13
14
  expiresAt: number;
@@ -33,6 +34,7 @@ interface AccessTokenRecord {
33
34
  expiresAt: number;
34
35
  }
35
36
  interface AuthorizationRecord {
37
+ sessionKey: string;
36
38
  clientId: string;
37
39
  redirectUri: string;
38
40
  state: string;
@@ -47,6 +49,7 @@ interface AuthorizationRecord {
47
49
  };
48
50
  }
49
51
  interface CodeRecord {
52
+ sessionKey: string;
50
53
  clientId: string;
51
54
  redirectUri: string;
52
55
  codeChallenge: string;
@@ -58,6 +61,7 @@ interface CodeRecord {
58
61
  }
59
62
  export type SignInRecord = {
60
63
  kind: 'device';
64
+ sessionKey: string | null;
61
65
  clientId: string;
62
66
  scopes: string[];
63
67
  expiresAt: number;
@@ -132,9 +136,10 @@ export interface IssuerState {
132
136
  pending: number;
133
137
  pendingForClient: number;
134
138
  };
135
- version: 3;
139
+ version: 3 | 4;
136
140
  generation: string;
137
141
  sessions: Record<string, SessionRecord>;
142
+ sessionKeys: Record<string, string>;
138
143
  grants: Record<string, GrantRecord>;
139
144
  refreshTokens: Record<string, string>;
140
145
  authorizations: Record<string, AuthorizationRecord>;
@@ -152,8 +157,8 @@ export interface ReferenceIssuerOptions {
152
157
  tokenEndpoint: string;
153
158
  revocationEndpoint: string;
154
159
  authorizationEndpoint?: string;
155
- poppyDomains: string[];
156
- directScopes: string[];
160
+ protectedResources: string[];
161
+ supportedScopes: string[];
157
162
  resources?: string[];
158
163
  mcpResources?: {
159
164
  url: string;
@@ -177,7 +182,6 @@ export interface ReferenceIssuerOptions {
177
182
  device?: {
178
183
  endpoint: string;
179
184
  verificationUri: string;
180
- scopes: string[];
181
185
  intervalSeconds?: number;
182
186
  };
183
187
  mediated?: MediatedOptions;
@@ -197,6 +201,11 @@ export type AuthorizationDecision = {
197
201
  };
198
202
  export interface ReferenceIssuer {
199
203
  browserSessions: BrowserSessionAuthority;
204
+ sessionForKey(binding: {
205
+ clientId: string;
206
+ userId: string;
207
+ sessionKey: string;
208
+ }): Promise<BrowserSession>;
200
209
  token(request: Request): Promise<Response>;
201
210
  revoke(request: Request): Promise<Response>;
202
211
  beginAuthorization(request: Request, browserBinding: string): Promise<AuthorizationView>;
@@ -214,5 +223,7 @@ export declare class AuthorizationError extends Error {
214
223
  readonly redirectTo?: URL | undefined;
215
224
  constructor(code: string, redirectTo?: URL | undefined);
216
225
  }
226
+ /** Run once over the complete namespace, under the adapter's writer lock. */
227
+ export declare function upgradeIssuerState(state: IssuerState): void;
217
228
  export declare function initialIssuerState(): IssuerState;
218
229
  export declare function createReferenceIssuer(options: ReferenceIssuerOptions): ReferenceIssuer;
@@ -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 } 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';
@@ -41,12 +43,31 @@ function callback(redirectUri, fields) {
41
43
  }
42
44
  // Shape changes bump the version; other versions are refused instead of misread.
43
45
  function checked(state) {
46
+ upgradeIssuerState(state);
47
+ return state;
48
+ }
49
+ /** Run once over the complete namespace, under the adapter's writer lock. */
50
+ export function upgradeIssuerState(state) {
51
+ if (state.version === 4)
52
+ return;
44
53
  if (state.version !== 3)
45
54
  throw new Error('unsupported issuer state version');
46
- return state;
55
+ // Old keys could name several Sessions. Never guess ownership; end those Sessions, keeping IDs and grant lineage.
56
+ state.sessionKeys = {};
57
+ for (const token of Object.values(state.accessTokens))
58
+ if (token.jkt)
59
+ state.sessionKeys[token.jkt] = '';
60
+ for (const session of Object.values(state.sessions))
61
+ session.expiresAt = 0;
62
+ state.authorizations = {};
63
+ state.codes = {};
64
+ for (const [id, row] of Object.entries(state.signIns))
65
+ if (row.kind === 'device' || row.kind === 'mediated')
66
+ delete state.signIns[id];
67
+ state.version = 4;
47
68
  }
48
69
  export function initialIssuerState() {
49
- return { version: 3, generation: random(16), sessions: {}, grants: {}, refreshTokens: {}, authorizations: {}, codes: {}, signIns: {}, accessTokens: {}, replay: {}, nonces: [] };
70
+ return { version: 4, generation: random(16), sessions: {}, sessionKeys: {}, grants: {}, refreshTokens: {}, authorizations: {}, codes: {}, signIns: {}, accessTokens: {}, replay: {}, nonces: [] };
50
71
  }
51
72
  class TokenError extends Error {
52
73
  code;
@@ -107,6 +128,14 @@ export function createReferenceIssuer(options) {
107
128
  throw new TypeError('issuer');
108
129
  secureUrl(options.tokenEndpoint);
109
130
  secureUrl(options.revocationEndpoint);
131
+ if (!options.protectedResources.length)
132
+ throw new TypeError('protectedResources');
133
+ options.protectedResources.forEach(value => secureUrl(value));
134
+ companyScopes(options.supportedScopes, true);
135
+ if (options.authorizationEndpoint)
136
+ secureUrl(options.authorizationEndpoint);
137
+ if (options.mediated && companyScopes(options.mediated.scopes).some(scope => !options.supportedScopes.includes(scope)))
138
+ throw new TypeError('mediated.scopes');
110
139
  const positive = (value, fallback, name) => {
111
140
  const result = value ?? fallback;
112
141
  if (!Number.isSafeInteger(result) || result <= 0)
@@ -124,7 +153,7 @@ export function createReferenceIssuer(options) {
124
153
  if (options.device) {
125
154
  secureUrl(options.device.endpoint);
126
155
  const url = secureUrl(options.device.verificationUri);
127
- const hosts = [new URL(issuer).hostname, ...options.poppyDomains];
156
+ const hosts = [new URL(issuer).hostname, ...options.protectedResources.map(resource => new URL(resource).hostname)];
128
157
  if (!hosts.some(host => url.hostname === host || url.hostname.endsWith(`.${host}`)))
129
158
  throw new TypeError('verificationUri');
130
159
  }
@@ -277,9 +306,8 @@ export function createReferenceIssuer(options) {
277
306
  target.accountId = accountId;
278
307
  target.grantId = grantId;
279
308
  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 } };
309
+ draft.accessTokens[tokenHash(accessToken)] = { sessionId, grantId, scopes: [...scopes], resource: resource ?? null, jkt, expiresAt: at + lifetimeMs };
310
+ return { grantId, body: { access_token: accessToken, token_type: 'DPoP', expires_in: lifetimeSeconds, refresh_token: refreshToken, refresh_token_expires_in: Math.floor(accountLifetimeMs / 1000), scope: scopes.join(' ') } };
283
311
  }
284
312
  function signInScopes(scope, allowed) {
285
313
  const scopes = scope?.split(' ') ?? [];
@@ -303,7 +331,7 @@ export function createReferenceIssuer(options) {
303
331
  if (!options.device)
304
332
  throw new TokenError('unsupported_grant_type');
305
333
  const params = await readParams(request), { clientId, clientAssertion } = clientCredentials(params);
306
- const scopes = signInScopes(params.get('scope'), options.device.scopes), { client } = await authenticateClient(clientId, clientAssertion);
334
+ const scopes = signInScopes(params.get('scope'), options.supportedScopes), { client } = await authenticateClient(clientId, clientAssertion);
307
335
  const { generation } = await read(), userCode = random(8).toUpperCase(), deviceCode = `${userCode}.${random(32)}`, id = tokenHash(userCode);
308
336
  const replayKey = `assertion ${clientId} ${client.jti}`;
309
337
  await commit({ kind: 'sign_in', ids: [id, clientRevisionKey(clientId)], clientId, replayKeys: [replayKey], at: now() }, draft => {
@@ -313,7 +341,7 @@ export function createReferenceIssuer(options) {
313
341
  if ((draft.limits?.pending ?? pending.length) >= maxPending || (draft.limits?.pendingForClient ?? pending.filter(row => row.clientId === clientId).length) >= maxPendingPerClient)
314
342
  throw new TokenError('rate_limited', { 'Retry-After': '60' });
315
343
  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)) };
344
+ 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
345
  });
318
346
  const complete = new URL(options.device.verificationUri);
319
347
  complete.searchParams.set('user_code', userCode);
@@ -322,29 +350,34 @@ export function createReferenceIssuer(options) {
322
350
  async function pollDevice(request, params) {
323
351
  if (!options.device)
324
352
  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'))
353
+ const { clientId, clientAssertion } = clientCredentials(params), code = params.get('device_code'), resource = params.get('resource');
354
+ if (!code || params.has('session_id') || params.has('assertion'))
327
355
  throw new TokenError('invalid_request');
328
- if (resource !== undefined && !resources.includes(resource))
356
+ if (resource !== undefined && (!resources.includes(resource) || isBearerResource(resource)))
329
357
  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()))
358
+ const state = await read();
359
+ const proof = await verifyDpopProof(request.headers.get('dpop'), { method: 'POST', url: options.tokenEndpoint, nowMs: now() });
360
+ if (requireNonce && !state.nonces.some(nonce => nonce.value === proof.claims.nonce && nonce.expiresAt > now()))
333
361
  throw new TokenError('use_dpop_nonce', { 'DPoP-Nonce': await currentNonce(state) });
334
362
  const { client, keys } = await authenticateClient(clientId, clientAssertion);
335
363
  for (const key of keys().keys)
336
364
  if (await calculateJwkThumbprint(key).catch(() => null) === proof.jkt)
337
365
  throw new TokenError('invalid_dpop_proof');
338
366
  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 => {
367
+ const body = await commit({ kind: 'sign_in', ids: [id], sessionKey: proof.jkt, clientId, replayKeys: [replayKey, proofKey], at: now() }, draft => {
340
368
  if (draft.generation !== state.generation || !current(client))
341
369
  throw new TokenError('invalid_client');
342
370
  consumeReplay(draft, replayKey, client.exp * 1000);
343
- if (!bearer)
344
- consumeReplay(draft, proofKey, (proof.claims.iat + PROOF_WINDOW_SECONDS) * 1000);
371
+ consumeReplay(draft, proofKey, (proof.claims.iat + PROOF_WINDOW_SECONDS) * 1000);
345
372
  const record = own(draft.signIns, id);
346
373
  if (!record || record.kind !== 'device' || record.clientId !== clientId || record.deviceHash !== tokenHash(code))
347
374
  return { error: 'invalid_grant' };
375
+ if (record.sessionKey === undefined || (record.sessionKey !== null && record.sessionKey !== proof.jkt))
376
+ return { error: 'invalid_grant' };
377
+ const sessionId = own(draft.sessionKeys, proof.jkt), session = sessionId ? own(draft.sessions, sessionId) : undefined;
378
+ if (!session || session.clientId !== clientId || session.expiresAt <= now())
379
+ return { error: 'invalid_grant' };
380
+ record.sessionKey ??= proof.jkt;
348
381
  if (record.expiresAt <= now())
349
382
  return { error: 'expired_token' };
350
383
  if (record.status === 'denied')
@@ -359,9 +392,8 @@ export function createReferenceIssuer(options) {
359
392
  record.nextPollAt = now() + record.intervalMs;
360
393
  if (record.status === 'pending')
361
394
  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');
395
+ if (session.accountId !== null && session.accountId !== record.accountId)
396
+ return { error: 'account_mismatch' };
365
397
  const granted = issueSignIn(draft, sessionId, session, record.accountId, record.scopes, proof.jkt, resource);
366
398
  record.status = 'consumed';
367
399
  return granted.body;
@@ -535,11 +567,48 @@ export function createReferenceIssuer(options) {
535
567
  code = '';
536
568
  }
537
569
  }
570
+ async function exchangeToken(request, params) {
571
+ const { clientId, clientAssertion } = clientCredentials(params);
572
+ const subject = params.get('subject_token'), resource = params.get('resource');
573
+ if (!subject || params.get('subject_token_type') !== 'urn:ietf:params:oauth:token-type:access_token' || ['assertion', 'session_id', 'refresh_token', 'code', 'actor_token', 'actor_token_type'].some(key => params.has(key)) || (params.has('requested_token_type') && params.get('requested_token_type') !== 'urn:ietf:params:oauth:token-type:access_token'))
574
+ throw new TokenError('invalid_request');
575
+ if (!resource || !isBearerResource(resource))
576
+ throw new TokenError('invalid_target');
577
+ const state = await read();
578
+ const proof = await verifyDpopProof(request.headers.get('dpop'), { method: 'POST', url: options.tokenEndpoint, nowMs: now() });
579
+ if (requireNonce && !state.nonces.some(nonce => nonce.value === proof.claims.nonce && nonce.expiresAt > now()))
580
+ throw new TokenError('use_dpop_nonce', { 'DPoP-Nonce': await currentNonce(state) });
581
+ const { client, keys } = await authenticateClient(clientId, clientAssertion);
582
+ for (const key of keys().keys)
583
+ if (await calculateJwkThumbprint(key).catch(() => null) === proof.jkt)
584
+ throw new TokenError('invalid_dpop_proof');
585
+ const replayKey = 'assertion ' + clientId + ' ' + client.jti, proofKey = 'proof ' + proof.jkt + ' ' + proof.claims.jti;
586
+ const body = await commit({ kind: 'sign_in', ids: [], sessionKey: proof.jkt, clientId, accessHash: tokenHash(subject), replayKeys: [replayKey, proofKey], at: now() }, draft => {
587
+ const at = now(), token = own(draft.accessTokens, tokenHash(subject)), session = token && own(draft.sessions, token.sessionId);
588
+ if (draft.generation !== state.generation || !current(client))
589
+ throw new TokenError('invalid_client');
590
+ if ((proof.claims.iat + PROOF_WINDOW_SECONDS) * 1000 <= at)
591
+ throw new TokenError('invalid_dpop_proof');
592
+ if (!token || !session || !token.jkt || token.jkt !== proof.jkt || own(draft.sessionKeys, proof.jkt) !== token.sessionId || session.clientId !== clientId || token.expiresAt <= at || session.expiresAt <= at || session.grantId !== token.grantId || (token.grantId !== null && !grantLive(own(draft.grants, token.grantId), at)))
593
+ throw new TokenError('invalid_grant');
594
+ const scopes = params.has('scope') ? signInScopes(params.get('scope'), token.scopes) : [...token.scopes];
595
+ if ((own(draft.replay, proofKey) ?? 0) > at)
596
+ throw new TokenError('invalid_dpop_proof');
597
+ consumeReplay(draft, replayKey, client.exp * 1000);
598
+ consumeReplay(draft, proofKey, (proof.claims.iat + PROOF_WINDOW_SECONDS) * 1000);
599
+ const access = random(32), expiresAt = Math.min(at + lifetimeMs, session.expiresAt);
600
+ draft.accessTokens[tokenHash(access)] = { sessionId: token.sessionId, grantId: token.grantId, scopes, resource, jkt: '', expiresAt };
601
+ return { access_token: access, issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', token_type: 'Bearer', expires_in: Math.floor((expiresAt - at) / 1000), scope: scopes.join(' ') };
602
+ });
603
+ return json(200, body);
604
+ }
538
605
  async function issue(request) {
539
606
  const params = await readParams(request);
540
607
  const grantType = params.get('grant_type');
541
608
  if (!grantType)
542
609
  throw new TokenError('invalid_request');
610
+ if (grantType === 'urn:ietf:params:oauth:grant-type:token-exchange')
611
+ return exchangeToken(request, params);
543
612
  if (grantType === DEVICE_GRANT)
544
613
  return pollDevice(request, params);
545
614
  if (grantType !== JWT_BEARER && grantType !== 'authorization_code' && grantType !== 'refresh_token')
@@ -550,10 +619,9 @@ export function createReferenceIssuer(options) {
550
619
  const redirectUri = params.get('redirect_uri');
551
620
  const codeVerifier = params.get('code_verifier');
552
621
  const refreshToken = params.get('refresh_token');
553
- const sessionId = params.get('session_id');
554
622
  const resource = params.get('resource');
555
623
  const scope = params.get('scope');
556
- if (sessionId === '')
624
+ if (params.has('session_id'))
557
625
  throw new TokenError('invalid_request');
558
626
  // Each grant takes only its own credential, so a stray assertion can never stand in for a code or Account Token.
559
627
  if (grantType !== JWT_BEARER && assertion !== undefined)
@@ -567,8 +635,7 @@ export function createReferenceIssuer(options) {
567
635
  throw new TokenError('invalid_scope');
568
636
  }
569
637
  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)
638
+ if (!code || !redirectUri || !codeVerifier)
572
639
  throw new TokenError('invalid_request');
573
640
  // RFC 7636 4.1: 43-128 unreserved characters.
574
641
  if (!/^[A-Za-z0-9._~-]{43,128}$/.test(codeVerifier))
@@ -581,15 +648,14 @@ export function createReferenceIssuer(options) {
581
648
  if (requested?.length === 0)
582
649
  throw new TokenError('invalid_scope');
583
650
  // Resources are exact configured identifiers, chosen per token request; grants are never narrowed to one.
584
- if (resource !== undefined && !resources.includes(resource))
651
+ if (resource !== undefined && (!resources.includes(resource) || isBearerResource(resource)))
585
652
  throw new TokenError('invalid_target');
586
653
  const state = await read();
587
654
  const generation = state.generation;
588
655
  // 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() });
656
+ const proof = await verifyDpopProof(request.headers.get('dpop'), { method: 'POST', url: options.tokenEndpoint, nowMs: now() });
591
657
  // 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()))
658
+ if (requireNonce && !state.nonces.some(nonce => nonce.value === proof.claims.nonce && nonce.expiresAt > now()))
593
659
  throw new TokenError('use_dpop_nonce', { 'DPoP-Nonce': await currentNonce(state) });
594
660
  const { client, verify, keys } = await authenticateClient(clientId, clientAssertion);
595
661
  let session;
@@ -607,7 +673,7 @@ export function createReferenceIssuer(options) {
607
673
  for (const key of keys().keys)
608
674
  if (await calculateJwkThumbprint(key).catch(() => null) === proof.jkt)
609
675
  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 => {
676
+ const body = await commit({ kind: 'token', clientId, sessionKey: proof.jkt, ...(code ? { codeHash: tokenHash(code) } : {}), ...(refreshToken ? { refreshHash: tokenHash(refreshToken) } : {}), replayKeys: [...(session ? [client, session] : [client]).map(claims => `assertion ${clientId} ${claims.jti}`), `proof ${proof.jkt} ${proof.claims.jti}`], at: now() }, draft => {
611
677
  // A reset between verification and commit must not recreate state from a stale verification.
612
678
  if (draft.generation !== generation)
613
679
  throw new TokenError('invalid_grant');
@@ -626,15 +692,15 @@ export function createReferenceIssuer(options) {
626
692
  const proofKey = `proof ${proof.jkt} ${proof.claims.jti}`;
627
693
  if (assertionKeys.some(key => own(draft.replay, key) !== undefined))
628
694
  throw new TokenError('invalid_grant');
629
- if (!bearerResource && own(draft.replay, proofKey) !== undefined)
695
+ if (own(draft.replay, proofKey) !== undefined)
630
696
  throw new TokenError('invalid_dpop_proof');
631
697
  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;
698
+ draft.replay[proofKey] = (proof.claims.iat + PROOF_WINDOW_SECONDS) * 1000;
699
+ const sessionId = own(draft.sessionKeys, proof.jkt);
634
700
  const existing = (userId) => {
635
701
  const found = sessionId === undefined ? undefined : own(draft.sessions, sessionId);
636
702
  if (!found || !(found.expiresAt > at) || found.clientId !== clientId || (userId !== undefined && found.userId !== userId))
637
- throw new TokenError('invalid_session');
703
+ throw new TokenError('invalid_grant');
638
704
  return found;
639
705
  };
640
706
  const create = (userId) => {
@@ -643,13 +709,14 @@ export function createReferenceIssuer(options) {
643
709
  if ((draft.limits?.sessions ?? live.length) >= maxSessionsPerClient)
644
710
  throw new TokenError('rate_limited', { 'Retry-After': String(Math.ceil(((draft.limits?.earliestSessionExpiry ?? Math.min(...live.map(record => record.expiresAt))) - at) / 1000)) });
645
711
  const id = `ses_${random(16)}`;
646
- return [id, draft.sessions[id] = { clientId, userId, version: 1, createdAt: at, expiresAt: at, accountId: null, grantId: null }];
712
+ draft.sessionKeys[proof.jkt] = id;
713
+ return [id, draft.sessions[id] = { clientId, userId, ...(session?.agent_id === undefined ? {} : { agentId: session.agent_id }), version: 1, createdAt: at, expiresAt: at, accountId: null, grantId: null }];
647
714
  };
648
715
  const accessToken = random(32);
649
716
  const grant = (id, record, grantId, scopes) => {
650
717
  record.expiresAt = at + sessionMs;
651
718
  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 };
719
+ return { access_token: accessToken, token_type: 'DPoP', expires_in: lifetimeSeconds, ...(grantId === null ? {} : { scope: scopes.join(' ') }) };
653
720
  };
654
721
  // Signs the Session in to the lineage and rotates its Account Token: consume, successor and session update in this one commit.
655
722
  const signIn = (id, record, grantId) => {
@@ -675,14 +742,17 @@ export function createReferenceIssuer(options) {
675
742
  if (sessionId === undefined)
676
743
  return grant(...create(session.sub), null, []);
677
744
  const record = existing(session.sub);
745
+ if (record.agentId !== session.agent_id)
746
+ throw new TokenError('invalid_grant');
678
747
  // A signed-in Session is renewed with its Account Token instead (spec 4.2).
679
748
  if (record.grantId !== null && grantLive(own(draft.grants, record.grantId), at))
680
749
  throw new TokenError('invalid_grant');
750
+ record.grantId = null;
681
751
  return grant(sessionId, record, null, []);
682
752
  }
683
753
  if (grantType === 'authorization_code') {
684
754
  const record = own(draft.codes, tokenHash(code));
685
- if (!record || record.expiresAt <= at || record.clientId !== clientId)
755
+ if (!record || record.expiresAt <= at || record.clientId !== clientId || record.sessionKey !== proof.jkt)
686
756
  throw new TokenError('invalid_grant');
687
757
  if (record.grantId !== null) {
688
758
  revokeGrant(draft, record.grantId);
@@ -797,19 +867,23 @@ export function createReferenceIssuer(options) {
797
867
  return {
798
868
  browserSessions: {
799
869
  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
- });
870
+ async join(sessionKey) {
871
+ const state = await read({ kind: 'sign_in', ids: [], sessionKey, replayKeys: [], at: now() });
872
+ const id = own(state.sessionKeys, sessionKey), session = id && own(state.sessions, id);
873
+ if (!id || !session)
874
+ throw new AuthorityError('invalid_token');
875
+ return browserSession(state, { clientId: session.clientId, userId: session.userId, sessionId: id });
809
876
  },
810
877
  },
811
878
  token: request => respond(request, issue),
812
879
  revoke: request => respond(request, revokeToken),
880
+ async sessionForKey(binding) {
881
+ const state = await read({ kind: 'sign_in', ids: [], sessionKey: binding.sessionKey, replayKeys: [], at: now() });
882
+ const sessionId = own(state.sessionKeys, binding.sessionKey);
883
+ if (!sessionId)
884
+ throw new AuthorityError('invalid_token');
885
+ return browserSession(state, { clientId: binding.clientId, userId: binding.userId, sessionId });
886
+ },
813
887
  deviceAuthorization: request => respond(request, startDevice),
814
888
  mediatedSignIn: request => respond(request, mediatedSignIn),
815
889
  async beginDeviceAuthorization(userCode, browserBinding) {
@@ -874,16 +948,8 @@ export function createReferenceIssuer(options) {
874
948
  catch {
875
949
  throw new AuthorizationError('invalid_client');
876
950
  }
877
- // Exact string match against the client's metadata (spec 4.5).
878
- if (!client.redirect_uris.includes(redirectUri))
951
+ if (!registeredRedirect(redirectUri, client.redirect_uris, clientId))
879
952
  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
953
  const state = params.get('state');
888
954
  const fail = (code) => new AuthorizationError(code, new URL(callback(redirectUri, { error: code, ...(state ? { state } : {}), iss: issuer })));
889
955
  if (params.get('response_type') !== 'code')
@@ -893,8 +959,11 @@ export function createReferenceIssuer(options) {
893
959
  const codeChallenge = params.get('code_challenge') ?? '';
894
960
  if (params.get('code_challenge_method') !== 'S256' || !/^[A-Za-z0-9_-]{43}$/.test(codeChallenge))
895
961
  throw fail('invalid_request');
962
+ const sessionKey = params.get('dpop_jkt');
963
+ if (!sessionKey || !/^[A-Za-z0-9_-]{43}$/.test(sessionKey))
964
+ throw fail('invalid_request');
896
965
  const scopes = [...new Set((params.get('scope') ?? '').split(' ').filter(Boolean))];
897
- if (!scopes.length || !scopes.every(scope => options.directScopes.includes(scope)))
966
+ if (!scopes.length || !scopes.every(scope => options.supportedScopes.includes(scope)))
898
967
  throw fail('invalid_scope');
899
968
  // resource is accepted and ignored here: grants cover every resource, and each token request selects one (spec 4.3).
900
969
  const resource = params.get('resource');
@@ -911,7 +980,7 @@ export function createReferenceIssuer(options) {
911
980
  throw fail('temporarily_unavailable');
912
981
  const id = random(16);
913
982
  const expiresAt = at + AUTHORIZATION_MS;
914
- draft.authorizations[id] = { clientId, redirectUri, state, codeChallenge, scopes, browserHash: tokenHash(browserBinding), expiresAt, revision: revision(draft, clientRevisionKey(clientId)) };
983
+ draft.authorizations[id] = { sessionKey, clientId, redirectUri, state, codeChallenge, scopes, browserHash: tokenHash(browserBinding), expiresAt, revision: revision(draft, clientRevisionKey(clientId)) };
915
984
  return { id, clientId, clientName: client.client_name, scopes, expiresAt };
916
985
  });
917
986
  },
@@ -926,7 +995,7 @@ export function createReferenceIssuer(options) {
926
995
  const accountId = 'accountId' in decision && typeof decision.accountId === 'string' ? decision.accountId : undefined;
927
996
  const href = await commit({ kind: 'authorization', id, ...(snapshot && accountId ? { revocationKey: accountRevisionKey(snapshot.clientId, accountId) } : {}) }, draft => {
928
997
  const record = own(draft.authorizations, id);
929
- if (!record || record.expiresAt <= now() || record.browserHash !== tokenHash(browserBinding))
998
+ if (!record || !record.sessionKey || record.expiresAt <= now() || record.browserHash !== tokenHash(browserBinding))
930
999
  throw new AuthorizationError('invalid_request');
931
1000
  const denied = 'denied' in decision && decision.denied === true;
932
1001
  const { accountId, scopes } = decision;
@@ -950,7 +1019,7 @@ export function createReferenceIssuer(options) {
950
1019
  }
951
1020
  const code = random(32);
952
1021
  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 };
1022
+ 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
1023
  const href = callback(record.redirectUri, { code, ...fields });
955
1024
  record.completion = { decisionHash, callback: cipher.seal(href) };
956
1025
  return href;
@@ -963,7 +1032,9 @@ export function createReferenceIssuer(options) {
963
1032
  ...(options.authorizationEndpoint ? { authorization_endpoint: options.authorizationEndpoint, response_types_supported: ['code'], code_challenge_methods_supported: ['S256'] } : {}),
964
1033
  ...(options.device ? { device_authorization_endpoint: options.device.endpoint } : {}),
965
1034
  dpop_signing_alg_values_supported: ['ES256'], client_id_metadata_document_supported: true,
966
- poppy_domains: [...options.poppyDomains],
1035
+ protected_resources: [...options.protectedResources],
1036
+ grant_types_supported: [JWT_BEARER, 'refresh_token', ...(mcpResources.some(entry => !entry.dpop) ? ['urn:ietf:params:oauth:grant-type:token-exchange'] : []), ...(options.authorizationEndpoint ? ['authorization_code'] : []), ...(options.device ? [DEVICE_GRANT] : [])],
1037
+ scopes_supported: [...options.supportedScopes],
967
1038
  };
968
1039
  },
969
1040
  authority: {
@@ -6,7 +6,7 @@ export type IssuerSelection = {
6
6
  } | {
7
7
  kind: 'token';
8
8
  clientId: string;
9
- sessionId?: string;
9
+ sessionKey: string;
10
10
  codeHash?: string;
11
11
  refreshHash?: string;
12
12
  replayKeys: string[];
@@ -43,6 +43,7 @@ export type IssuerSelection = {
43
43
  ids: string[];
44
44
  clientId?: string;
45
45
  sessionId?: string;
46
+ sessionKey?: string;
46
47
  accessHash?: string;
47
48
  replayKeys: string[];
48
49
  at: number;