@poppykit/server 0.3.0 → 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.
- package/API.md +1 -0
- package/AUTH.md +36 -7
- package/CHANGELOG.md +11 -0
- package/CONTRIBUTING.md +6 -4
- package/DELIVERY.md +19 -1
- package/INTEGRATIONS.md +21 -11
- package/README.md +12 -4
- package/RELEASING.md +1 -1
- package/STORAGE.md +19 -3
- package/dist/adapter-contract.js +11 -11
- package/dist/browser.d.ts +9 -6
- package/dist/browser.js +32 -30
- package/dist/conversation/service.d.ts +7 -7
- package/dist/conversation/service.js +26 -24
- package/dist/index.d.ts +1 -0
- package/dist/network/discovery.d.ts +20 -9
- package/dist/network/discovery.js +74 -21
- package/dist/network/redirect.d.ts +3 -0
- package/dist/network/redirect.js +38 -0
- package/dist/network/tasks.d.ts +23 -0
- package/dist/network/tasks.js +45 -0
- package/dist/postgres.js +22 -4
- package/dist/protocol/wire.d.ts +12 -11
- package/dist/protocol/wire.js +33 -7
- package/dist/reference-auth/index.d.ts +16 -5
- package/dist/reference-auth/index.js +130 -59
- package/dist/reference-auth/store.d.ts +2 -1
- package/dist/sqlite.js +22 -4
- package/examples/quickstart.mjs +2 -1
- package/package.json +2 -2
package/dist/protocol/wire.js
CHANGED
|
@@ -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
|
-
|
|
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, ['
|
|
130
|
-
if (event.
|
|
131
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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
|
-
|
|
281
|
-
|
|
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.
|
|
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'),
|
|
326
|
-
if (!code ||
|
|
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()
|
|
331
|
-
const proof =
|
|
332
|
-
if (
|
|
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],
|
|
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
|
-
|
|
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
|
-
|
|
363
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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,
|
|
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 (
|
|
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
|
-
|
|
633
|
-
|
|
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('
|
|
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
|
-
|
|
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:
|
|
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(
|
|
801
|
-
const
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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;
|