@poppykit/server 0.4.0 → 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 +10 -3
- package/AUTH.md +120 -124
- package/CHANGELOG.md +13 -1
- package/CONFORMANCE.md +73 -0
- package/CONTRIBUTING.md +12 -11
- package/DELIVERY.md +2 -0
- package/INTEGRATIONS.md +74 -81
- package/OPERATIONS.md +142 -0
- package/README.md +11 -20
- package/RELEASING.md +2 -0
- package/SECURITY.md +2 -0
- package/STORAGE.md +56 -17
- package/dist/adapter-contract.d.ts +18 -0
- package/dist/adapter-contract.js +79 -0
- 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 -16
- package/dist/browser.js +65 -33
- package/dist/conversation/service.d.ts +11 -1
- package/dist/conversation/service.js +39 -9
- package/dist/feedback.d.ts +1 -1
- package/dist/feedback.js +5 -5
- package/dist/index.d.ts +5 -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 +31 -13
- package/dist/network/discovery.js +103 -66
- package/dist/operations.d.ts +115 -0
- package/dist/operations.js +212 -0
- package/dist/postgres.d.ts +6 -0
- package/dist/postgres.js +72 -4
- 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 +8 -4
- package/dist/protocol/wire.js +37 -15
- package/dist/reference-auth/index.d.ts +10 -16
- package/dist/reference-auth/index.js +225 -430
- package/dist/sqlite.d.ts +11 -0
- package/dist/sqlite.js +84 -4
- package/examples/operations.mjs +18 -0
- package/examples/quickstart.mjs +8 -5
- package/migrations/RETIREMENT.md +77 -0
- package/package.json +5 -2
|
@@ -1,7 +1,7 @@
|
|
|
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';
|
|
4
|
+
import { companyScopes, MEDIATED_GRANT, parseMediatedSignIn } from '../network/discovery.js';
|
|
5
5
|
import { registeredRedirect } from '../network/redirect.js';
|
|
6
6
|
import { secureUrl } from '../network/transport.js';
|
|
7
7
|
import { own } from '../store.js';
|
|
@@ -22,16 +22,14 @@ export class AuthorizationError extends Error {
|
|
|
22
22
|
this.name = 'AuthorizationError';
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
|
-
const JWT_BEARER = 'urn:ietf:params:oauth:grant-type:jwt-bearer';
|
|
26
25
|
const CLIENT_ASSERTION_TYPE = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
|
|
27
26
|
const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
28
|
-
const ASSERTION_ALGORITHMS = ['ES256', 'RS256'];
|
|
27
|
+
const ASSERTION_ALGORITHMS = ['ES256', 'RS256', 'PS384'];
|
|
29
28
|
const NONCE_SECONDS = 300;
|
|
30
29
|
const MAX_FORM_BYTES = 65536;
|
|
31
30
|
const AUTHORIZATION_MS = 600_000;
|
|
32
31
|
const CODE_MS = 60_000;
|
|
33
32
|
// A Session ends a day after the last token issued for it (spec 4.2: the Company decides how long a Session lasts).
|
|
34
|
-
const SESSION_IDLE_SECONDS = 86_400;
|
|
35
33
|
const random = (bytes) => randomBytes(bytes).toString('base64url');
|
|
36
34
|
const tokenHash = (token) => createHash('sha256').update(token).digest('base64url');
|
|
37
35
|
const grantLive = (grant, at) => !!grant && !grant.revoked && grant.expiresAt > at;
|
|
@@ -48,26 +46,27 @@ function checked(state) {
|
|
|
48
46
|
}
|
|
49
47
|
/** Run once over the complete namespace, under the adapter's writer lock. */
|
|
50
48
|
export function upgradeIssuerState(state) {
|
|
51
|
-
if (state.version ===
|
|
49
|
+
if (state.version === 5)
|
|
52
50
|
return;
|
|
53
|
-
if (state.version !== 3)
|
|
51
|
+
if (state.version !== 3 && state.version !== 4)
|
|
54
52
|
throw new Error('unsupported issuer state version');
|
|
55
|
-
|
|
56
|
-
state.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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;
|
|
68
67
|
}
|
|
69
68
|
export function initialIssuerState() {
|
|
70
|
-
return { version:
|
|
69
|
+
return { version: 5, generation: random(16), sessions: {}, sessionKeys: {}, grants: {}, refreshTokens: {}, authorizations: {}, codes: {}, signIns: {}, accessTokens: {}, replay: {}, nonces: [] };
|
|
71
70
|
}
|
|
72
71
|
class TokenError extends Error {
|
|
73
72
|
code;
|
|
@@ -147,8 +146,8 @@ export function createReferenceIssuer(options) {
|
|
|
147
146
|
const accountLifetimeMs = positive(options.accountLifetimeSeconds, 2_592_000, 'accountLifetimeSeconds') * 1000;
|
|
148
147
|
const maxPending = positive(options.maxPendingAuthorizations, 1000, 'maxPendingAuthorizations');
|
|
149
148
|
const maxPendingPerClient = positive(options.maxPendingAuthorizationsPerClient, 100, 'maxPendingAuthorizationsPerClient');
|
|
150
|
-
|
|
151
|
-
|
|
149
|
+
if (options.maxSessionsPerClient !== undefined)
|
|
150
|
+
throw new TypeError('maxSessionsPerClient moved to createSessionAuthority');
|
|
152
151
|
const requireNonce = options.requireDpopNonce ?? true;
|
|
153
152
|
if (options.device) {
|
|
154
153
|
secureUrl(options.device.endpoint);
|
|
@@ -157,11 +156,8 @@ export function createReferenceIssuer(options) {
|
|
|
157
156
|
if (!hosts.some(host => url.hostname === host || url.hostname.endsWith(`.${host}`)))
|
|
158
157
|
throw new TypeError('verificationUri');
|
|
159
158
|
}
|
|
160
|
-
if (options.mediated)
|
|
161
|
-
|
|
162
|
-
if (!options.mediated.fields.length || new Set(options.mediated.fields.map(field => field.name)).size !== options.mediated.fields.length)
|
|
163
|
-
throw new TypeError('mediated.fields');
|
|
164
|
-
}
|
|
159
|
+
if (options.mediated)
|
|
160
|
+
parseMediatedSignIn({ version: '1', fields: options.mediated.fields, scopes: options.mediated.scopes });
|
|
165
161
|
const deviceIntervalMs = positive(options.device?.intervalSeconds, 5, 'intervalSeconds') * 1000;
|
|
166
162
|
const maxCodeAttempts = positive(options.mediated?.maxCodeAttempts, 5, 'maxCodeAttempts');
|
|
167
163
|
const maxAttempts = positive(options.mediated?.maxAttemptsPerMinute, 5, 'maxAttemptsPerMinute');
|
|
@@ -169,7 +165,7 @@ export function createReferenceIssuer(options) {
|
|
|
169
165
|
const mcpResources = options.mcpResources ?? [];
|
|
170
166
|
for (const entry of mcpResources) {
|
|
171
167
|
const url = secureUrl(entry.url);
|
|
172
|
-
if (
|
|
168
|
+
if (typeof entry.dpop !== 'boolean' || mcpResources.filter(item => item.url === entry.url).length !== 1)
|
|
173
169
|
throw new TypeError('mcpResources');
|
|
174
170
|
}
|
|
175
171
|
const resources = [...new Set([...(options.resources ?? []), ...mcpResources.map(entry => entry.url)])];
|
|
@@ -177,16 +173,6 @@ export function createReferenceIssuer(options) {
|
|
|
177
173
|
// Every issuer mutation is ordered with conversation work by the shared coordinator.
|
|
178
174
|
const read = async (selection = { kind: 'metadata' }) => checked(await store.read(selection));
|
|
179
175
|
const commit = (selection, change) => options.coordinator.run(() => store.transaction(selection, draft => change(checked(draft))));
|
|
180
|
-
function browserSession(state, binding) {
|
|
181
|
-
const session = own(state.sessions, binding.sessionId), at = now();
|
|
182
|
-
if (!session || session.expiresAt <= at || session.clientId !== binding.clientId || session.userId !== binding.userId || (binding.issuerGeneration !== undefined && binding.issuerGeneration !== state.generation))
|
|
183
|
-
throw new AuthorityError('invalid_token');
|
|
184
|
-
const grant = session.grantId === null ? undefined : own(state.grants, session.grantId);
|
|
185
|
-
const signedIn = grantLive(grant, at);
|
|
186
|
-
return { clientId: session.clientId, userId: session.userId, sessionId: binding.sessionId, issuerGeneration: state.generation, expiresAt: session.expiresAt,
|
|
187
|
-
principal: { issuer, clientId: session.clientId, userId: session.userId, sessionId: binding.sessionId, issuerGeneration: state.generation, sessionVersion: session.version, tokenExpiresAt: session.expiresAt,
|
|
188
|
-
scopes: signedIn ? [...grant.scopes] : [], ...(signedIn ? { accountId: grant.accountId, grantId: session.grantId } : {}) } };
|
|
189
|
-
}
|
|
190
176
|
// Reuse the current nonce while it has more than half its life left, so challenges cannot grow state.
|
|
191
177
|
async function currentNonce(state) {
|
|
192
178
|
const fresh = state.nonces.find(nonce => nonce.expiresAt - now() > NONCE_SECONDS * 500);
|
|
@@ -283,7 +269,7 @@ export function createReferenceIssuer(options) {
|
|
|
283
269
|
if (record.expiresAt <= at)
|
|
284
270
|
delete draft.authorizations[key];
|
|
285
271
|
for (const [key, record] of Object.entries(draft.signIns))
|
|
286
|
-
if (record.expiresAt + CLOCK_SKEW_SECONDS * 1000 < at)
|
|
272
|
+
if (!(record.kind === 'mediated' && record.status === 'verifying') && record.expiresAt + CLOCK_SKEW_SECONDS * 1000 < at)
|
|
287
273
|
delete draft.signIns[key];
|
|
288
274
|
}
|
|
289
275
|
// Revoking a grant signs out every Session using it; they continue signed out and stay bound to the account (spec 4.9).
|
|
@@ -296,18 +282,12 @@ export function createReferenceIssuer(options) {
|
|
|
296
282
|
if (session.grantId === grantId)
|
|
297
283
|
session.grantId = null;
|
|
298
284
|
}
|
|
299
|
-
function issueSignIn(draft,
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
const grantId = `grt_${random(16)}`, refreshToken = random(32), accessToken = random(32), at = now();
|
|
303
|
-
const refreshHash = tokenHash(refreshToken);
|
|
304
|
-
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: '' };
|
|
305
288
|
draft.refreshTokens[refreshHash] = grantId;
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
target.expiresAt = at + sessionMs;
|
|
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(' ') } };
|
|
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(' ') } };
|
|
311
291
|
}
|
|
312
292
|
function signInScopes(scope, allowed) {
|
|
313
293
|
const scopes = scope?.split(' ') ?? [];
|
|
@@ -337,7 +317,7 @@ export function createReferenceIssuer(options) {
|
|
|
337
317
|
await commit({ kind: 'sign_in', ids: [id, clientRevisionKey(clientId)], clientId, replayKeys: [replayKey], at: now() }, draft => {
|
|
338
318
|
if (draft.generation !== generation || !current(client))
|
|
339
319
|
throw new TokenError('invalid_client');
|
|
340
|
-
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')));
|
|
341
321
|
if ((draft.limits?.pending ?? pending.length) >= maxPending || (draft.limits?.pendingForClient ?? pending.filter(row => row.clientId === clientId).length) >= maxPendingPerClient)
|
|
342
322
|
throw new TokenError('rate_limited', { 'Retry-After': '60' });
|
|
343
323
|
consumeReplay(draft, replayKey, client.exp * 1000);
|
|
@@ -353,31 +333,27 @@ export function createReferenceIssuer(options) {
|
|
|
353
333
|
const { clientId, clientAssertion } = clientCredentials(params), code = params.get('device_code'), resource = params.get('resource');
|
|
354
334
|
if (!code || params.has('session_id') || params.has('assertion'))
|
|
355
335
|
throw new TokenError('invalid_request');
|
|
356
|
-
if (resource !== undefined &&
|
|
336
|
+
if (resource !== undefined && !resources.includes(resource))
|
|
357
337
|
throw new TokenError('invalid_target');
|
|
358
338
|
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()))
|
|
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()))
|
|
361
341
|
throw new TokenError('use_dpop_nonce', { 'DPoP-Nonce': await currentNonce(state) });
|
|
362
342
|
const { client, keys } = await authenticateClient(clientId, clientAssertion);
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
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 => {
|
|
368
349
|
if (draft.generation !== state.generation || !current(client))
|
|
369
350
|
throw new TokenError('invalid_client');
|
|
370
351
|
consumeReplay(draft, replayKey, client.exp * 1000);
|
|
371
|
-
|
|
352
|
+
if (proof && proofKey)
|
|
353
|
+
consumeReplay(draft, proofKey, (proof.claims.iat + PROOF_WINDOW_SECONDS) * 1000);
|
|
372
354
|
const record = own(draft.signIns, id);
|
|
373
355
|
if (!record || record.kind !== 'device' || record.clientId !== clientId || record.deviceHash !== tokenHash(code))
|
|
374
356
|
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;
|
|
381
357
|
if (record.expiresAt <= now())
|
|
382
358
|
return { error: 'expired_token' };
|
|
383
359
|
if (record.status === 'denied')
|
|
@@ -392,401 +368,251 @@ export function createReferenceIssuer(options) {
|
|
|
392
368
|
record.nextPollAt = now() + record.intervalMs;
|
|
393
369
|
if (record.status === 'pending')
|
|
394
370
|
return { error: 'authorization_pending' };
|
|
395
|
-
if (
|
|
396
|
-
return { error: '
|
|
397
|
-
const
|
|
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);
|
|
398
374
|
record.status = 'consumed';
|
|
399
|
-
return
|
|
375
|
+
return result.body;
|
|
400
376
|
});
|
|
401
377
|
return json('error' in body ? 400 : 200, body);
|
|
402
378
|
}
|
|
403
|
-
function
|
|
404
|
-
const token = own(draft.accessTokens, accessHash), session = token && own(draft.sessions, token.sessionId);
|
|
405
|
-
if (!token || !session || token.expiresAt <= now() || session.expiresAt <= now() || token.jkt !== jkt || (token.grantId !== null && !grantLive(own(draft.grants, token.grantId), now())))
|
|
406
|
-
throw new TokenError('invalid_token');
|
|
407
|
-
return { token, session };
|
|
408
|
-
}
|
|
409
|
-
async function mediatedSignIn(request) {
|
|
379
|
+
async function mediatedSignIn(request, supplied) {
|
|
410
380
|
const config = options.mediated;
|
|
411
381
|
if (!config)
|
|
412
382
|
throw new TokenError('unsupported_grant_type');
|
|
413
|
-
|
|
414
|
-
if (request.method !== 'POST' || url.origin !== endpoint.origin || url.search || url.hash ||
|
|
415
|
-
(url.pathname !== endpoint.pathname && !url.pathname.startsWith(`${endpoint.pathname.replace(/\/$/, '')}/`)))
|
|
416
|
-
throw new TokenError('invalid_request');
|
|
417
|
-
const id = url.pathname === endpoint.pathname ? random(32) : url.pathname.slice(endpoint.pathname.replace(/\/$/, '').length + 1);
|
|
418
|
-
if (!/^[A-Za-z0-9_-]{1,256}$/.test(id))
|
|
419
|
-
throw new TokenError('sign_in_not_found');
|
|
420
|
-
const challenge = url.pathname !== endpoint.pathname;
|
|
421
|
-
if (!/^application\/json(?:;|$)/i.test(request.headers.get('content-type') ?? ''))
|
|
383
|
+
if (request.url !== options.tokenEndpoint)
|
|
422
384
|
throw new TokenError('invalid_request');
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
const parsed = parseJson(await readForm(request));
|
|
426
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
427
|
-
throw 0;
|
|
428
|
-
input = parsed;
|
|
429
|
-
}
|
|
430
|
-
catch {
|
|
385
|
+
const params = supplied ?? await readParams(request), { clientId, clientAssertion } = clientCredentials(params);
|
|
386
|
+
if (params.get('grant_type') !== MEDIATED_GRANT)
|
|
431
387
|
throw new TokenError('invalid_request');
|
|
432
|
-
}
|
|
433
|
-
|
|
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 = '';
|
|
434
409
|
try {
|
|
435
|
-
let scopes = [];
|
|
436
410
|
if (challenge) {
|
|
437
|
-
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)
|
|
438
415
|
throw new TokenError('invalid_request');
|
|
439
|
-
code = input.code;
|
|
440
416
|
}
|
|
441
417
|
else {
|
|
442
|
-
|
|
443
|
-
const fields = input.credentials;
|
|
444
|
-
if (!fields || typeof fields !== 'object' || Array.isArray(fields) || Object.keys(fields).length !== config.fields.length ||
|
|
445
|
-
!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'))
|
|
446
419
|
throw new TokenError('invalid_request');
|
|
447
|
-
|
|
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
|
+
}
|
|
448
426
|
}
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
const proof = await verifyDpopProof(request.headers.get('dpop'), { method: 'POST', url: request.url, nowMs: now(), accessToken: access });
|
|
453
|
-
const accessHash = tokenHash(access), proofKey = `proof ${proof.jkt} ${proof.claims.jti}`, state = await read({ kind: 'access', hash: accessHash });
|
|
454
|
-
const authenticated = mediatedToken(state, accessHash, proof.jkt), context = { issuer, clientId: authenticated.session.clientId, userId: authenticated.session.userId, sessionId: authenticated.token.sessionId };
|
|
455
|
-
const key = tokenHash(id), limitId = tokenHash(`attempts session ${context.clientId} ${context.sessionId}`), clientLimitId = tokenHash(`attempts client ${context.clientId}`), executionId = random(16);
|
|
456
|
-
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() };
|
|
457
430
|
const claim = await commit(selection, draft => {
|
|
458
|
-
if (draft.generation !== state.generation)
|
|
459
|
-
throw new TokenError('
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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);
|
|
463
437
|
if (challenge) {
|
|
464
|
-
if (!
|
|
465
|
-
throw new TokenError('
|
|
466
|
-
if (
|
|
467
|
-
|
|
468
|
-
if (
|
|
469
|
-
|
|
470
|
-
if (
|
|
471
|
-
|
|
472
|
-
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' };
|
|
473
447
|
}
|
|
474
|
-
if (
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
record.status = 'failed';
|
|
478
|
-
return { status: 'failed' };
|
|
448
|
+
if (row.attempts >= maxCodeAttempts) {
|
|
449
|
+
row.status = 'failed';
|
|
450
|
+
return { error: 'invalid_grant' };
|
|
479
451
|
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
return { record };
|
|
484
|
-
}
|
|
485
|
-
// Count attempts before calling the host's credential backend, including failures.
|
|
486
|
-
for (const [limitKey, maximum, sessionId] of [[limitId, maxAttempts, context.sessionId], [clientLimitId, maxClientAttempts, undefined]]) {
|
|
487
|
-
const old = own(draft.signIns, limitKey);
|
|
488
|
-
const window = old?.kind === 'attempts' && old.expiresAt > now() ? old : { kind: 'attempts', clientId: context.clientId, ...(sessionId ? { sessionId } : {}), count: 0, expiresAt: now() + 60_000 };
|
|
489
|
-
if (window.count >= maximum)
|
|
490
|
-
throw new TokenError('rate_limited', { 'Retry-After': String(Math.ceil((window.expiresAt - now()) / 1000)) });
|
|
491
|
-
window.count++;
|
|
492
|
-
draft.signIns[limitKey] = window;
|
|
452
|
+
row.attempts++;
|
|
453
|
+
row.status = 'verifying';
|
|
454
|
+
row.executionId = executionId;
|
|
455
|
+
return { record: row };
|
|
493
456
|
}
|
|
494
|
-
const
|
|
495
|
-
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)
|
|
496
464
|
throw new TokenError('rate_limited', { 'Retry-After': '60' });
|
|
497
|
-
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)) };
|
|
498
466
|
draft.signIns[key] = record;
|
|
499
467
|
return { record };
|
|
500
468
|
});
|
|
501
|
-
if ('
|
|
502
|
-
return json(
|
|
503
|
-
const
|
|
469
|
+
if ('error' in claim)
|
|
470
|
+
return json(400, claim);
|
|
471
|
+
const row = claim.record;
|
|
504
472
|
let decision = null, correct = false;
|
|
505
473
|
try {
|
|
506
|
-
if (challenge)
|
|
507
|
-
correct = await config.verifyCode({ challengeId:
|
|
508
|
-
if (typeof correct !== 'boolean')
|
|
509
|
-
throw 0;
|
|
510
|
-
}
|
|
474
|
+
if (challenge)
|
|
475
|
+
correct = await config.verifyCode({ challengeId: row.challengeId, code: oneTimeCode }, context);
|
|
511
476
|
else
|
|
512
477
|
decision = await config.authenticate(credentials, context);
|
|
513
478
|
}
|
|
514
479
|
catch {
|
|
515
|
-
throw new TokenError('temporarily_unavailable');
|
|
480
|
+
throw new TokenError('temporarily_unavailable', { 'Retry-After': '5' });
|
|
516
481
|
}
|
|
517
|
-
|
|
518
|
-
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')
|
|
519
483
|
throw new TokenError('temporarily_unavailable');
|
|
520
|
-
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))))
|
|
521
485
|
throw new TokenError('temporarily_unavailable');
|
|
522
|
-
const accountId = decision?.accountId ??
|
|
523
|
-
const
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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';
|
|
533
497
|
return { error: 'invalid_grant' };
|
|
534
498
|
}
|
|
535
|
-
if (
|
|
536
|
-
|
|
537
|
-
return {
|
|
499
|
+
if (completed.expiresAt <= now()) {
|
|
500
|
+
completed.status = 'expired';
|
|
501
|
+
return { error: 'expired_token' };
|
|
538
502
|
}
|
|
539
|
-
if ((!challenge && !decision) || (challenge && !correct &&
|
|
540
|
-
|
|
541
|
-
return {
|
|
503
|
+
if ((!challenge && !decision) || (challenge && !correct && completed.attempts >= maxCodeAttempts)) {
|
|
504
|
+
completed.status = 'failed';
|
|
505
|
+
return { error: 'invalid_grant' };
|
|
542
506
|
}
|
|
543
|
-
if (decision)
|
|
544
|
-
current.accountId = decision.accountId;
|
|
545
507
|
if (decision?.scopes)
|
|
546
|
-
|
|
508
|
+
completed.scopes = [...new Set(decision.scopes)];
|
|
547
509
|
if (decision?.challenge) {
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
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;
|
|
551
512
|
}
|
|
552
513
|
if ((!challenge && decision?.challenge) || (challenge && !correct)) {
|
|
553
|
-
|
|
554
|
-
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) };
|
|
555
516
|
}
|
|
556
|
-
const result = issueSignIn(draft,
|
|
557
|
-
|
|
558
|
-
|
|
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;
|
|
559
521
|
});
|
|
560
522
|
return json('error' in body ? 400 : 200, body);
|
|
561
523
|
}
|
|
562
524
|
finally {
|
|
563
|
-
for (const
|
|
564
|
-
delete credentials[
|
|
565
|
-
for (const
|
|
566
|
-
delete
|
|
567
|
-
|
|
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 = '';
|
|
568
531
|
}
|
|
569
532
|
}
|
|
570
|
-
async function
|
|
533
|
+
async function accountToken(request, params) {
|
|
571
534
|
const { clientId, clientAssertion } = clientCredentials(params);
|
|
572
|
-
const
|
|
573
|
-
|
|
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'))
|
|
574
538
|
throw new TokenError('invalid_request');
|
|
575
|
-
if (
|
|
539
|
+
if (resource !== undefined && !resources.includes(resource))
|
|
576
540
|
throw new TokenError('invalid_target');
|
|
577
|
-
|
|
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
|
-
}
|
|
605
|
-
async function issue(request) {
|
|
606
|
-
const params = await readParams(request);
|
|
607
|
-
const grantType = params.get('grant_type');
|
|
608
|
-
if (!grantType)
|
|
541
|
+
if (grantType === 'authorization_code' && (!code || !verifier || !redirect || !/^[A-Za-z0-9._~-]{43,128}$/.test(verifier)))
|
|
609
542
|
throw new TokenError('invalid_request');
|
|
610
|
-
if (grantType === '
|
|
611
|
-
return exchangeToken(request, params);
|
|
612
|
-
if (grantType === DEVICE_GRANT)
|
|
613
|
-
return pollDevice(request, params);
|
|
614
|
-
if (grantType !== JWT_BEARER && grantType !== 'authorization_code' && grantType !== 'refresh_token')
|
|
615
|
-
throw new TokenError('unsupported_grant_type');
|
|
616
|
-
const { clientId, clientAssertion } = clientCredentials(params);
|
|
617
|
-
const assertion = params.get('assertion');
|
|
618
|
-
const code = params.get('code');
|
|
619
|
-
const redirectUri = params.get('redirect_uri');
|
|
620
|
-
const codeVerifier = params.get('code_verifier');
|
|
621
|
-
const refreshToken = params.get('refresh_token');
|
|
622
|
-
const resource = params.get('resource');
|
|
623
|
-
const scope = params.get('scope');
|
|
624
|
-
if (params.has('session_id'))
|
|
625
|
-
throw new TokenError('invalid_request');
|
|
626
|
-
// Each grant takes only its own credential, so a stray assertion can never stand in for a code or Account Token.
|
|
627
|
-
if (grantType !== JWT_BEARER && assertion !== undefined)
|
|
543
|
+
if (grantType === 'refresh_token' && !refresh)
|
|
628
544
|
throw new TokenError('invalid_request');
|
|
629
|
-
if (grantType === JWT_BEARER) {
|
|
630
|
-
// A missing assertion is invalid_grant (spec 4.2 error table).
|
|
631
|
-
if (!assertion)
|
|
632
|
-
throw new TokenError('invalid_grant');
|
|
633
|
-
// Signed-out sessions hold no account scopes.
|
|
634
|
-
if (scope?.trim())
|
|
635
|
-
throw new TokenError('invalid_scope');
|
|
636
|
-
}
|
|
637
|
-
else if (grantType === 'authorization_code') {
|
|
638
|
-
if (!code || !redirectUri || !codeVerifier)
|
|
639
|
-
throw new TokenError('invalid_request');
|
|
640
|
-
// RFC 7636 4.1: 43-128 unreserved characters.
|
|
641
|
-
if (!/^[A-Za-z0-9._~-]{43,128}$/.test(codeVerifier))
|
|
642
|
-
throw new TokenError('invalid_request');
|
|
643
|
-
}
|
|
644
|
-
else if (!refreshToken)
|
|
645
|
-
throw new TokenError('invalid_request');
|
|
646
|
-
// scope narrows only (spec 4.3, 4.8); an empty request asks for nothing and is refused.
|
|
647
|
-
const requested = scope === undefined || grantType === JWT_BEARER ? undefined : [...new Set(scope.split(' ').filter(Boolean))];
|
|
648
|
-
if (requested?.length === 0)
|
|
649
|
-
throw new TokenError('invalid_scope');
|
|
650
|
-
// Resources are exact configured identifiers, chosen per token request; grants are never narrowed to one.
|
|
651
|
-
if (resource !== undefined && (!resources.includes(resource) || isBearerResource(resource)))
|
|
652
|
-
throw new TokenError('invalid_target');
|
|
653
545
|
const state = await read();
|
|
654
|
-
const
|
|
655
|
-
|
|
656
|
-
const proof = await verifyDpopProof(request.headers.get('dpop'), { method: 'POST', url: options.tokenEndpoint, nowMs: now() });
|
|
657
|
-
// Nonce challenge precedes any network fetch and any replay consumption.
|
|
658
|
-
if (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()))
|
|
659
548
|
throw new TokenError('use_dpop_nonce', { 'DPoP-Nonce': await currentNonce(state) });
|
|
660
|
-
const { client,
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
throw error instanceof TokenError ? error : new TokenError('invalid_grant');
|
|
668
|
-
}
|
|
669
|
-
if (session.iss !== clientId || session.aud !== issuer || session.sub.length > 256 || !current(session) || session.jti === client.jti)
|
|
670
|
-
throw new TokenError('invalid_grant');
|
|
671
|
-
}
|
|
672
|
-
// Identity keys and DPoP keys must differ (spec 4.1).
|
|
673
|
-
for (const key of keys().keys)
|
|
674
|
-
if (await calculateJwkThumbprint(key).catch(() => null) === proof.jkt)
|
|
675
|
-
throw new TokenError('invalid_dpop_proof');
|
|
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 => {
|
|
677
|
-
// A reset between verification and commit must not recreate state from a stale verification.
|
|
678
|
-
if (draft.generation !== generation)
|
|
679
|
-
throw new TokenError('invalid_grant');
|
|
680
|
-
// 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 => {
|
|
681
556
|
const at = now();
|
|
682
|
-
if (
|
|
683
|
-
throw new TokenError('invalid_dpop_proof');
|
|
684
|
-
if (client.exp * 1000 <= at)
|
|
557
|
+
if (draft.generation !== state.generation || !current(client))
|
|
685
558
|
throw new TokenError('invalid_client');
|
|
686
|
-
if (
|
|
687
|
-
throw new TokenError('
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
if (assertionKeys.some(key => own(draft.replay, key) !== undefined))
|
|
694
|
-
throw new TokenError('invalid_grant');
|
|
695
|
-
if (own(draft.replay, proofKey) !== undefined)
|
|
696
|
-
throw new TokenError('invalid_dpop_proof');
|
|
697
|
-
assertions.forEach((claims, index) => { draft.replay[assertionKeys[index]] = claims.exp * 1000; });
|
|
698
|
-
draft.replay[proofKey] = (proof.claims.iat + PROOF_WINDOW_SECONDS) * 1000;
|
|
699
|
-
const sessionId = own(draft.sessionKeys, proof.jkt);
|
|
700
|
-
const existing = (userId) => {
|
|
701
|
-
const found = sessionId === undefined ? undefined : own(draft.sessions, sessionId);
|
|
702
|
-
if (!found || !(found.expiresAt > at) || found.clientId !== clientId || (userId !== undefined && found.userId !== userId))
|
|
703
|
-
throw new TokenError('invalid_grant');
|
|
704
|
-
return found;
|
|
705
|
-
};
|
|
706
|
-
const create = (userId) => {
|
|
707
|
-
// Pruned above, so these are live; a slot frees when the soonest of them ends (spec 4.2 rate_limited).
|
|
708
|
-
const live = Object.values(draft.sessions).filter(record => record.clientId === clientId);
|
|
709
|
-
if ((draft.limits?.sessions ?? live.length) >= maxSessionsPerClient)
|
|
710
|
-
throw new TokenError('rate_limited', { 'Retry-After': String(Math.ceil(((draft.limits?.earliestSessionExpiry ?? Math.min(...live.map(record => record.expiresAt))) - at) / 1000)) });
|
|
711
|
-
const id = `ses_${random(16)}`;
|
|
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 }];
|
|
714
|
-
};
|
|
715
|
-
const accessToken = random(32);
|
|
716
|
-
const grant = (id, record, grantId, scopes) => {
|
|
717
|
-
record.expiresAt = at + sessionMs;
|
|
718
|
-
draft.accessTokens[tokenHash(accessToken)] = { sessionId: id, grantId, scopes, resource: resource ?? null, jkt: proof.jkt, expiresAt: at + lifetimeMs };
|
|
719
|
-
return { access_token: accessToken, token_type: 'DPoP', expires_in: lifetimeSeconds, ...(grantId === null ? {} : { scope: scopes.join(' ') }) };
|
|
720
|
-
};
|
|
721
|
-
// Signs the Session in to the lineage and rotates its Account Token: consume, successor and session update in this one commit.
|
|
722
|
-
const signIn = (id, record, grantId) => {
|
|
723
|
-
const lineage = draft.grants[grantId];
|
|
724
|
-
// Sticky account (spec 4.4): a Session once signed in to another account never switches, and no tokens are issued.
|
|
725
|
-
if (record.accountId !== null && record.accountId !== lineage.accountId)
|
|
726
|
-
throw new TokenError('account_mismatch');
|
|
727
|
-
const scopes = requested ?? lineage.scopes;
|
|
728
|
-
if (!scopes.every(item => lineage.scopes.includes(item)))
|
|
729
|
-
throw new TokenError('invalid_scope');
|
|
730
|
-
const successor = random(32);
|
|
731
|
-
// Retain ancestors until the fixed grant expiry so every replay can revoke its descendants.
|
|
732
|
-
lineage.previousHash = lineage.refreshHash;
|
|
733
|
-
lineage.refreshHash = tokenHash(successor);
|
|
734
|
-
draft.refreshTokens[lineage.refreshHash] = grantId;
|
|
735
|
-
record.accountId = lineage.accountId;
|
|
736
|
-
record.grantId = grantId;
|
|
737
|
-
return { ...grant(id, record, grantId, scopes), refresh_token: successor, refresh_token_expires_in: Math.floor((lineage.expiresAt - at) / 1000) };
|
|
738
|
-
};
|
|
739
|
-
if (grantType === JWT_BEARER) {
|
|
740
|
-
if (!session)
|
|
741
|
-
throw new TokenError('invalid_grant');
|
|
742
|
-
if (sessionId === undefined)
|
|
743
|
-
return grant(...create(session.sub), null, []);
|
|
744
|
-
const record = existing(session.sub);
|
|
745
|
-
if (record.agentId !== session.agent_id)
|
|
746
|
-
throw new TokenError('invalid_grant');
|
|
747
|
-
// A signed-in Session is renewed with its Account Token instead (spec 4.2).
|
|
748
|
-
if (record.grantId !== null && grantLive(own(draft.grants, record.grantId), at))
|
|
749
|
-
throw new TokenError('invalid_grant');
|
|
750
|
-
record.grantId = null;
|
|
751
|
-
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);
|
|
752
566
|
}
|
|
753
567
|
if (grantType === 'authorization_code') {
|
|
754
|
-
const
|
|
755
|
-
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))
|
|
756
570
|
throw new TokenError('invalid_grant');
|
|
757
|
-
if (
|
|
758
|
-
revokeGrant(draft,
|
|
571
|
+
if (row.grantId !== null) {
|
|
572
|
+
revokeGrant(draft, row.grantId);
|
|
759
573
|
return null;
|
|
760
574
|
}
|
|
761
|
-
if (
|
|
762
|
-
throw new TokenError('invalid_grant');
|
|
763
|
-
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))
|
|
764
576
|
throw new TokenError('invalid_grant');
|
|
765
|
-
const
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
record.grantId = grantId;
|
|
769
|
-
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;
|
|
770
580
|
}
|
|
771
|
-
const
|
|
772
|
-
|
|
773
|
-
const lineage = grantId === undefined ? undefined : own(draft.grants, grantId);
|
|
774
|
-
// Reuse of any ancestor Account Token (e.g. a retry after a lost response) revokes the lineage, so its Sessions
|
|
775
|
-
// continue signed out instead of staying bound to a successor nobody holds (RFC 9700 4.14.2).
|
|
776
|
-
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) {
|
|
777
583
|
revokeGrant(draft, grantId);
|
|
778
584
|
return null;
|
|
779
585
|
}
|
|
780
|
-
|
|
781
|
-
if (!lineage || !grantLive(lineage, at) || lineage.clientId !== clientId || lineage.refreshHash !== presented)
|
|
586
|
+
if (!grant || !grantLive(grant, at) || grant.clientId !== clientId)
|
|
782
587
|
throw new TokenError('invalid_grant');
|
|
783
|
-
|
|
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(' ') };
|
|
784
598
|
});
|
|
785
|
-
// A reused code or previous Account Token revoked its lineage in the commit above.
|
|
786
599
|
if (body === null)
|
|
787
600
|
throw new TokenError('invalid_grant');
|
|
788
601
|
return json(200, body);
|
|
789
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
|
+
}
|
|
790
616
|
// RFC 7009 with private_key_jwt client authentication; unknown and foreign tokens succeed without changing any grant.
|
|
791
617
|
async function revokeToken(request) {
|
|
792
618
|
const params = await readParams(request);
|
|
@@ -826,7 +652,7 @@ export function createReferenceIssuer(options) {
|
|
|
826
652
|
if (!code)
|
|
827
653
|
throw error;
|
|
828
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;
|
|
829
|
-
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 : {}) });
|
|
830
656
|
}
|
|
831
657
|
}
|
|
832
658
|
// Plain checks over one issuer snapshot; only the policy wrappers below take the coordinator, once.
|
|
@@ -844,9 +670,7 @@ export function createReferenceIssuer(options) {
|
|
|
844
670
|
const policy = {
|
|
845
671
|
withCurrent: (principal, scopes, action) => options.coordinator.run(async () => {
|
|
846
672
|
const state = await read({ kind: 'principal', sessionId: principal.sessionId, ...(principal.grantId ? { grantId: principal.grantId } : {}) });
|
|
847
|
-
|
|
848
|
-
if (principal.issuer !== issuer || principal.issuerGeneration !== state.generation || !session || session.clientId !== principal.clientId
|
|
849
|
-
|| 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))
|
|
850
674
|
throw new AuthorityError('invalid_token');
|
|
851
675
|
requireScopes(principal.scopes, scopes);
|
|
852
676
|
return action();
|
|
@@ -865,27 +689,9 @@ export function createReferenceIssuer(options) {
|
|
|
865
689
|
}),
|
|
866
690
|
};
|
|
867
691
|
return {
|
|
868
|
-
browserSessions: {
|
|
869
|
-
async current(binding) { return browserSession(await read({ kind: 'browser', sessionId: binding.sessionId }), binding); },
|
|
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 });
|
|
876
|
-
},
|
|
877
|
-
},
|
|
878
692
|
token: request => respond(request, issue),
|
|
879
693
|
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
|
-
},
|
|
887
694
|
deviceAuthorization: request => respond(request, startDevice),
|
|
888
|
-
mediatedSignIn: request => respond(request, mediatedSignIn),
|
|
889
695
|
async beginDeviceAuthorization(userCode, browserBinding) {
|
|
890
696
|
if (!options.device || typeof userCode !== 'string' || typeof browserBinding !== 'string' || browserBinding.length < 22)
|
|
891
697
|
throw new AuthorizationError('invalid_request');
|
|
@@ -959,8 +765,8 @@ export function createReferenceIssuer(options) {
|
|
|
959
765
|
const codeChallenge = params.get('code_challenge') ?? '';
|
|
960
766
|
if (params.get('code_challenge_method') !== 'S256' || !/^[A-Za-z0-9_-]{43}$/.test(codeChallenge))
|
|
961
767
|
throw fail('invalid_request');
|
|
962
|
-
const sessionKey = params.get('dpop_jkt');
|
|
963
|
-
if (
|
|
768
|
+
const sessionKey = params.get('dpop_jkt') ?? null;
|
|
769
|
+
if (sessionKey !== null && !/^[A-Za-z0-9_-]{43}$/.test(sessionKey))
|
|
964
770
|
throw fail('invalid_request');
|
|
965
771
|
const scopes = [...new Set((params.get('scope') ?? '').split(' ').filter(Boolean))];
|
|
966
772
|
if (!scopes.length || !scopes.every(scope => options.supportedScopes.includes(scope)))
|
|
@@ -995,7 +801,7 @@ export function createReferenceIssuer(options) {
|
|
|
995
801
|
const accountId = 'accountId' in decision && typeof decision.accountId === 'string' ? decision.accountId : undefined;
|
|
996
802
|
const href = await commit({ kind: 'authorization', id, ...(snapshot && accountId ? { revocationKey: accountRevisionKey(snapshot.clientId, accountId) } : {}) }, draft => {
|
|
997
803
|
const record = own(draft.authorizations, id);
|
|
998
|
-
if (!record ||
|
|
804
|
+
if (!record || record.sessionKey === undefined || record.expiresAt <= now() || record.browserHash !== tokenHash(browserBinding))
|
|
999
805
|
throw new AuthorizationError('invalid_request');
|
|
1000
806
|
const denied = 'denied' in decision && decision.denied === true;
|
|
1001
807
|
const { accountId, scopes } = decision;
|
|
@@ -1031,9 +837,9 @@ export function createReferenceIssuer(options) {
|
|
|
1031
837
|
issuer, token_endpoint: options.tokenEndpoint, revocation_endpoint: options.revocationEndpoint,
|
|
1032
838
|
...(options.authorizationEndpoint ? { authorization_endpoint: options.authorizationEndpoint, response_types_supported: ['code'], code_challenge_methods_supported: ['S256'] } : {}),
|
|
1033
839
|
...(options.device ? { device_authorization_endpoint: options.device.endpoint } : {}),
|
|
1034
|
-
dpop_signing_alg_values_supported: ['ES256'], client_id_metadata_document_supported: true,
|
|
840
|
+
dpop_signing_alg_values_supported: ['ES256'], token_endpoint_auth_signing_alg_values_supported: [...ASSERTION_ALGORITHMS], client_id_metadata_document_supported: true,
|
|
1035
841
|
protected_resources: [...options.protectedResources],
|
|
1036
|
-
grant_types_supported: [
|
|
842
|
+
grant_types_supported: ['refresh_token', ...(options.mediated ? [MEDIATED_GRANT] : []), ...(options.authorizationEndpoint ? ['authorization_code'] : []), ...(options.device ? [DEVICE_GRANT] : [])],
|
|
1037
843
|
scopes_supported: [...options.supportedScopes],
|
|
1038
844
|
};
|
|
1039
845
|
},
|
|
@@ -1041,24 +847,13 @@ export function createReferenceIssuer(options) {
|
|
|
1041
847
|
issuer,
|
|
1042
848
|
revocationVisibility: 'immediate: every introspection and policy check reads the shared issuer store; policy checks are ordered by the shared coordinator',
|
|
1043
849
|
async introspect(token, resource) {
|
|
1044
|
-
const state = await read({ kind: 'access', hash: tokenHash(token) });
|
|
1045
|
-
|
|
1046
|
-
const record = own(state.accessTokens, tokenHash(token));
|
|
1047
|
-
const session = record && own(state.sessions, record.sessionId);
|
|
1048
|
-
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))
|
|
1049
852
|
throw new AuthorityError('invalid_token');
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
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))
|
|
1053
855
|
throw new AuthorityError('invalid_token');
|
|
1054
|
-
return {
|
|
1055
|
-
principal: {
|
|
1056
|
-
issuer, clientId: session.clientId, userId: session.userId, sessionId: record.sessionId,
|
|
1057
|
-
...(grant ? { accountId: grant.accountId, grantId: record.grantId } : {}),
|
|
1058
|
-
scopes: [...record.scopes], tokenExpiresAt: record.expiresAt, sessionVersion: session.version, issuerGeneration: state.generation,
|
|
1059
|
-
},
|
|
1060
|
-
dpopJkt: record.jkt || null,
|
|
1061
|
-
};
|
|
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 };
|
|
1062
857
|
},
|
|
1063
858
|
policy,
|
|
1064
859
|
},
|