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