@poppykit/client 0.5.0 → 0.6.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 +9 -1
- package/AUTH.md +97 -90
- package/CHANGELOG.md +13 -1
- package/CONFORMANCE.md +73 -0
- package/CONTRIBUTING.md +12 -11
- package/DELIVERY.md +19 -3
- package/INTEGRATIONS.md +35 -23
- package/OPERATIONS.md +120 -0
- package/PROVIDER.md +20 -12
- package/README.md +6 -17
- package/RELEASING.md +2 -0
- package/SECURITY.md +2 -0
- package/STORAGE.md +14 -7
- package/dist/adapter-contract.d.ts +6 -0
- package/dist/adapter-contract.js +30 -0
- package/dist/auth/challenge.d.ts +2 -0
- package/dist/auth/challenge.js +8 -8
- package/dist/auth/client-auth.d.ts +20 -7
- package/dist/auth/client-auth.js +386 -334
- package/dist/auth/sqlite-store.js +13 -4
- package/dist/auth/store.d.ts +6 -1
- package/dist/conversation.d.ts +6 -0
- package/dist/conversation.js +18 -14
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/integrations.d.ts +11 -4
- package/dist/integrations.js +30 -38
- package/dist/network/discovery.d.ts +31 -13
- package/dist/network/discovery.js +103 -66
- package/dist/operations.d.ts +103 -0
- package/dist/operations.js +261 -0
- package/dist/postgres.d.ts +11 -0
- package/dist/postgres.js +40 -3
- 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/provider-postgres.js +3 -3
- package/dist/provider-storage.js +4 -4
- package/dist/provider.d.ts +26 -3
- package/dist/provider.js +94 -13
- package/dist/sqlite.d.ts +12 -0
- package/dist/sqlite.js +42 -0
- package/examples/operations.mjs +25 -0
- package/examples/quickstart.mjs +1 -0
- package/package.json +5 -2
package/dist/auth/client-auth.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
+
import { supportsOperations } from '../protocol/operations.js';
|
|
1
2
|
import { parseAlternative } from '../network/tasks.js';
|
|
2
3
|
import { registeredRedirect } from '../network/redirect.js';
|
|
3
4
|
import { acquireOwner, releaseOwner, localOwnerAlive } from './ownership.js';
|
|
4
5
|
import { createHash } from 'node:crypto';
|
|
5
6
|
import { calculateJwkThumbprint } from 'jose';
|
|
6
7
|
import { parseJson, parseProtocolError, parseTokenResponse, retryAfterMilliseconds } from '../protocol/wire.js';
|
|
7
|
-
import { validateClientId, signInMethods } from '../network/discovery.js';
|
|
8
|
+
import { validateClientId, signInMethods, companyIdentifier, mediatedSignIn, MEDIATED_GRANT } from '../network/discovery.js';
|
|
8
9
|
import { dpopChallenge, schemeChallenge } from './challenge.js';
|
|
9
|
-
import { resolveApiTarget,
|
|
10
|
+
import { resolveApiTarget, serviceAuthorization, browserDestination, validateFeedback } from '../integrations.js';
|
|
10
11
|
export class AuthFailure extends Error {
|
|
11
12
|
code;
|
|
12
13
|
connectionId;
|
|
@@ -21,6 +22,13 @@ export class AuthFailure extends Error {
|
|
|
21
22
|
this.name = 'AuthFailure';
|
|
22
23
|
}
|
|
23
24
|
}
|
|
25
|
+
class MediatedCodeRequired extends AuthFailure {
|
|
26
|
+
challenge;
|
|
27
|
+
constructor(challenge) {
|
|
28
|
+
super('code_required');
|
|
29
|
+
this.challenge = challenge;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
24
32
|
const EC = { name: 'ECDSA', namedCurve: 'P-256' };
|
|
25
33
|
const ES256 = { name: 'ECDSA', hash: 'SHA-256' };
|
|
26
34
|
const SKEW_MS = 30_000;
|
|
@@ -86,13 +94,13 @@ function parseCallback(callbackUrl) {
|
|
|
86
94
|
return { params, base };
|
|
87
95
|
}
|
|
88
96
|
export function initialClientAuthState() {
|
|
89
|
-
return { version:
|
|
97
|
+
return { version: 3, epoch: randomId(), owner: null, identities: {}, connections: {}, grants: {}, operations: {}, cleanup: {} };
|
|
90
98
|
}
|
|
91
99
|
/** Upgrade a complete namespace under its writer lock; dispatched/staged work still goes through recover(). */
|
|
92
100
|
export function upgradeClientAuthState(state) {
|
|
93
|
-
if (state.version ===
|
|
101
|
+
if (state.version === 3)
|
|
94
102
|
return;
|
|
95
|
-
if (state.version !== 1)
|
|
103
|
+
if (state.version !== 1 && state.version !== 2)
|
|
96
104
|
throw new Error('unsupported auth state version');
|
|
97
105
|
for (const connection of Object.values(state.connections)) {
|
|
98
106
|
connection.needsSessionKey = true;
|
|
@@ -102,12 +110,15 @@ export function upgradeClientAuthState(state) {
|
|
|
102
110
|
if (operation.phase === 'prepared') {
|
|
103
111
|
operation.phase = 'terminal';
|
|
104
112
|
operation.status = 'cancelled';
|
|
113
|
+
const connection = own(state.connections, operation.connectionId);
|
|
114
|
+
if (connection)
|
|
115
|
+
(connection.signingCleanup ??= []).push(...(operation.direct ? [operation.direct.verifierRef] : []), ...(operation.device ? [operation.device.codeRef] : []));
|
|
105
116
|
operation.result = null;
|
|
106
117
|
const grant = operation.grantId ? own(state.grants, operation.grantId) : undefined;
|
|
107
118
|
if (grant?.operationId === operation.id)
|
|
108
119
|
grant.operationId = null;
|
|
109
120
|
}
|
|
110
|
-
state.version =
|
|
121
|
+
state.version = 3;
|
|
111
122
|
}
|
|
112
123
|
function publicEcJwk(value, code) {
|
|
113
124
|
const jwk = (value ?? {});
|
|
@@ -161,7 +172,7 @@ export function createClientAuth(options) {
|
|
|
161
172
|
function destinations(metadata) {
|
|
162
173
|
const auth = metadata.authorization;
|
|
163
174
|
return JSON.stringify([auth?.issuer, auth?.token_endpoint, auth?.revocation_endpoint, auth?.authorization_endpoint, auth?.device_authorization_endpoint,
|
|
164
|
-
metadata.document.
|
|
175
|
+
metadata.document.extensions?.['mediated-sign-in'], metadata.document.agent?.protocols, metadata.document.apis, metadata.document.web, metadata.document.feedback]);
|
|
165
176
|
}
|
|
166
177
|
async function verified(metadata) {
|
|
167
178
|
let fresh;
|
|
@@ -246,6 +257,100 @@ export function createClientAuth(options) {
|
|
|
246
257
|
payload.nonce = nonce;
|
|
247
258
|
return compact({ typ: 'dpop+jwt', alg: 'ES256', jwk: key.jwk }, payload, async (input) => new Uint8Array(await crypto.subtle.sign(ES256, key.privateKey, input)));
|
|
248
259
|
}
|
|
260
|
+
const signing = new Map();
|
|
261
|
+
async function cleanupSigning(connection) {
|
|
262
|
+
for (const ref of connection.signingCleanup ?? []) {
|
|
263
|
+
try {
|
|
264
|
+
await secrets.remove(ref);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
await transaction({ connectionId: connection.id }, state => {
|
|
270
|
+
const live = own(state.connections, connection.id);
|
|
271
|
+
live.signingCleanup = (live.signingCleanup ?? []).filter(value => value !== ref);
|
|
272
|
+
if (!live.signingCleanup.length)
|
|
273
|
+
delete live.signingCleanup;
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
async function signingConnection(connection) {
|
|
278
|
+
await cleanupSigning(connection);
|
|
279
|
+
connection = await record(connection.id);
|
|
280
|
+
if ((connection.signingCleanup?.length ?? 0) >= 128)
|
|
281
|
+
throw new AuthFailure('storage_cleanup_pending');
|
|
282
|
+
if (connection.sessionSigning && connection.sessionSigning.expiresAt > now() + SKEW_MS)
|
|
283
|
+
return connection;
|
|
284
|
+
let pending = signing.get(connection.id);
|
|
285
|
+
if (!pending) {
|
|
286
|
+
pending = (async () => {
|
|
287
|
+
const keyRef = await newSessionKey(), key = await loadDpopKey(keyRef);
|
|
288
|
+
const at = Math.floor(now() / 1800000) * 1800;
|
|
289
|
+
let expiresAt = (at + 3600) * 1000;
|
|
290
|
+
let delegation, committing = false;
|
|
291
|
+
try {
|
|
292
|
+
if (options.assertions && !connection.anonymous) {
|
|
293
|
+
if (!options.assertions.delegate)
|
|
294
|
+
throw new AuthFailure('provider_delegation_unavailable');
|
|
295
|
+
let result;
|
|
296
|
+
try {
|
|
297
|
+
result = await options.assertions.delegate({ operationId: randomId('dlg_'), jwk: key.jwk });
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
throw providerFailure(error);
|
|
301
|
+
}
|
|
302
|
+
if (!result || typeof result.delegation !== 'string' || !result.delegation || !Number.isFinite(result.expiresAt) || result.expiresAt * 1000 <= now() || result.expiresAt * 1000 > now() + 3600000 + SKEW_MS)
|
|
303
|
+
throw new AuthFailure('provider_invalid_response');
|
|
304
|
+
delegation = result.delegation;
|
|
305
|
+
expiresAt = result.expiresAt * 1000;
|
|
306
|
+
}
|
|
307
|
+
else
|
|
308
|
+
delegation = await signIdentity({ typ: 'pap-delegation+jwt' }, { iss: clientId, cnf: { jwk: key.jwk }, iat: at, exp: at + 3600, jti: randomId() }, key.thumbprint);
|
|
309
|
+
const delegationRef = await secrets.stage(delegation);
|
|
310
|
+
committing = true;
|
|
311
|
+
const current = await commit({ connectionId: connection.id }, [keyRef, delegationRef], (state, drop) => {
|
|
312
|
+
const live = own(state.connections, connection.id);
|
|
313
|
+
if (live.generation !== connection.generation || live.userId !== connection.userId || live.sessionId !== connection.sessionId)
|
|
314
|
+
throw new AuthFailure('state_changed');
|
|
315
|
+
if (live.sessionSigning && live.sessionSigning.expiresAt > now() + SKEW_MS)
|
|
316
|
+
drop.push(keyRef, delegationRef);
|
|
317
|
+
else {
|
|
318
|
+
if (live.sessionSigning)
|
|
319
|
+
(live.signingCleanup ??= []).push(live.sessionSigning.keyRef, live.sessionSigning.delegationRef);
|
|
320
|
+
live.sessionSigning = { keyRef, delegationRef, expiresAt };
|
|
321
|
+
}
|
|
322
|
+
return live;
|
|
323
|
+
});
|
|
324
|
+
await cleanupSigning(current);
|
|
325
|
+
return current;
|
|
326
|
+
}
|
|
327
|
+
catch (error) {
|
|
328
|
+
if (!committing)
|
|
329
|
+
await secrets.remove(keyRef);
|
|
330
|
+
throw error;
|
|
331
|
+
}
|
|
332
|
+
})().finally(() => signing.delete(connection.id));
|
|
333
|
+
signing.set(connection.id, pending);
|
|
334
|
+
}
|
|
335
|
+
return pending;
|
|
336
|
+
}
|
|
337
|
+
async function sessionHeader(connection, request, token) {
|
|
338
|
+
if (connection.anonymous && token !== undefined)
|
|
339
|
+
throw new AuthFailure('anonymous_account_access');
|
|
340
|
+
connection = await signingConnection(connection);
|
|
341
|
+
const material = connection.sessionSigning;
|
|
342
|
+
if (!connection.sessionId || !connection.userId || material.expiresAt <= now())
|
|
343
|
+
throw new AuthFailure('invalid_session');
|
|
344
|
+
const delegation = await secrets.read(material.delegationRef), key = await loadDpopKey(material.keyRef);
|
|
345
|
+
if (!delegation)
|
|
346
|
+
throw new AuthFailure('missing_key');
|
|
347
|
+
const url = new URL(request.url);
|
|
348
|
+
url.search = '';
|
|
349
|
+
url.hash = '';
|
|
350
|
+
const claims = { iss: clientId, aud: connection.issuer, sid: connection.sessionId, sub: connection.userId,
|
|
351
|
+
htm: request.method, htu: url.href, iat: Math.floor(now() / 1000), jti: randomId(), ...(token === undefined ? {} : { ath: sha256(token) }) };
|
|
352
|
+
return compact({ typ: 'poppy-session+jwt', alg: 'ES256', kid: key.thumbprint, pap_dlg: delegation }, claims, async (input) => new Uint8Array(await crypto.subtle.sign(ES256, key.privateKey, input)));
|
|
353
|
+
}
|
|
249
354
|
function rememberNonce(url, response) {
|
|
250
355
|
const nonce = response.headers.get('dpop-nonce');
|
|
251
356
|
if (nonce)
|
|
@@ -257,7 +362,7 @@ export function createClientAuth(options) {
|
|
|
257
362
|
}
|
|
258
363
|
async function assertion(purpose, issuer, key, sub = clientId, operationId = randomId('op_')) {
|
|
259
364
|
if (!options.assertions)
|
|
260
|
-
return signIdentity({ typ:
|
|
365
|
+
return signIdentity({ typ: 'client-authentication+jwt' }, assertionClaims(sub, issuer), key.thumbprint);
|
|
261
366
|
let result;
|
|
262
367
|
try {
|
|
263
368
|
result = await options.assertions.assert({ operationId, issuer, purpose });
|
|
@@ -285,21 +390,25 @@ export function createClientAuth(options) {
|
|
|
285
390
|
: delay === undefined ? undefined : String(Math.floor(now() + delay));
|
|
286
391
|
return new AuthFailure(code, undefined, delay, deadline);
|
|
287
392
|
}
|
|
288
|
-
//
|
|
393
|
+
// OAuth requests use client authentication and optional DPoP, independently of Sessions.
|
|
289
394
|
// Throws transport_error only when the request may have reached the issuer without a readable answer.
|
|
290
|
-
async function tokenRequest(metadata, dpopKeyRef, fields,
|
|
395
|
+
async function tokenRequest(metadata, dpopKeyRef, fields, operationId) {
|
|
291
396
|
const authorization = (await verified(metadata)).authorization;
|
|
292
397
|
const endpoint = authorization.token_endpoint;
|
|
293
398
|
const key = await loadDpopKey(dpopKeyRef);
|
|
294
399
|
const scheme = await resourceScheme(metadata, fields.resource ?? null);
|
|
295
400
|
for (let attempt = 0;; attempt++) {
|
|
401
|
+
if (attempt > 0 && operationId && own((await load({ operationId })).operations, operationId)?.cancelRequested)
|
|
402
|
+
throw new AuthFailure('cancelled');
|
|
403
|
+
const issuedAt = now();
|
|
296
404
|
const body = new URLSearchParams(fields);
|
|
297
|
-
if (sessionSub !== null)
|
|
298
|
-
body.set('assertion', localIdentity ? await signIdentity({ typ: 'JWT' }, assertionClaims(sessionSub, authorization.issuer), key.thumbprint) : await assertion('session', authorization.issuer, key, sessionSub));
|
|
299
405
|
body.set('client_id', clientId);
|
|
300
406
|
body.set('client_assertion_type', CLIENT_ASSERTION);
|
|
301
|
-
body.set('client_assertion',
|
|
302
|
-
const
|
|
407
|
+
body.set('client_assertion', await assertion('client-authentication', authorization.issuer, key));
|
|
408
|
+
const headers = new Headers({ accept: 'application/json' });
|
|
409
|
+
if (scheme === 'DPoP')
|
|
410
|
+
headers.set('dpop', await proof(key, 'POST', endpoint));
|
|
411
|
+
const request = new Request(endpoint, { method: 'POST', body, headers });
|
|
303
412
|
let response, text;
|
|
304
413
|
try {
|
|
305
414
|
response = await transport.fetch(request);
|
|
@@ -321,14 +430,21 @@ export function createClientAuth(options) {
|
|
|
321
430
|
catch { /* reported below */ }
|
|
322
431
|
if (response.status === 200) {
|
|
323
432
|
try {
|
|
324
|
-
|
|
433
|
+
const token = parseTokenResponse(json);
|
|
434
|
+
if (token.token_type === 'DPoP' && !headers.has('dpop'))
|
|
325
435
|
throw 0;
|
|
326
|
-
return { token
|
|
436
|
+
return { token, scheme: token.token_type };
|
|
327
437
|
}
|
|
328
438
|
catch {
|
|
329
439
|
throw new AuthFailure('invalid_response');
|
|
330
440
|
}
|
|
331
441
|
}
|
|
442
|
+
if (response.status === 400 && json && typeof json === 'object' && 'error' in json && json.error === 'code_required') {
|
|
443
|
+
const value = json;
|
|
444
|
+
if (typeof value.sign_in_id !== 'string' || !/^[A-Za-z0-9_-]{1,256}$/.test(value.sign_in_id) || typeof value.sent_to !== 'string' || value.sent_to.length > 512 || !Number.isSafeInteger(value.expires_in) || value.expires_in <= 0 || !Number.isSafeInteger(issuedAt + value.expires_in * 1000))
|
|
445
|
+
throw new AuthFailure('invalid_response');
|
|
446
|
+
throw new MediatedCodeRequired({ id: value.sign_in_id, sentTo: value.sent_to, expiresAt: issuedAt + value.expires_in * 1000 });
|
|
447
|
+
}
|
|
332
448
|
let error = response.status === 429 ? 'rate_limited' : 'token_error';
|
|
333
449
|
try {
|
|
334
450
|
if (response.status !== 429)
|
|
@@ -344,32 +460,14 @@ export function createClientAuth(options) {
|
|
|
344
460
|
async function resourceScheme(metadata, resource) {
|
|
345
461
|
const mcp = metadata.document.apis?.find(api => api.type === 'mcp' && api.url === resource);
|
|
346
462
|
if (!mcp)
|
|
347
|
-
return 'DPoP';
|
|
348
|
-
if (mcp.resource !== undefined && mcp.resource !== mcp.url)
|
|
349
|
-
throw new AuthFailure('invalid_target');
|
|
463
|
+
return metadata.authorization?.dpop_signing_alg_values_supported?.includes('ES256') ? 'DPoP' : 'Bearer';
|
|
350
464
|
try {
|
|
351
|
-
|
|
352
|
-
// Bearer MCP tokens cannot authorize a conversation, even when discovery reuses their resource identifier.
|
|
353
|
-
if (scheme === 'Bearer' && metadata.document.agent?.protocols.some(protocol => protocol.resource === resource))
|
|
354
|
-
throw 0;
|
|
355
|
-
return scheme;
|
|
465
|
+
return (await serviceAuthorization(metadata, options.metadata, transport, mcp.url, { mcp: true })).scheme;
|
|
356
466
|
}
|
|
357
467
|
catch {
|
|
358
468
|
throw new AuthFailure('invalid_target');
|
|
359
469
|
}
|
|
360
470
|
}
|
|
361
|
-
// The proof key names the Session; signed-out tokens have no account scopes.
|
|
362
|
-
async function signedOutToken(metadata, userId, dpopKeyRef, sessionId, resource, anonymous = false) {
|
|
363
|
-
const fields = { grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer' };
|
|
364
|
-
if (resource !== null)
|
|
365
|
-
fields.resource = resource;
|
|
366
|
-
const { token, scheme } = await tokenRequest(metadata, dpopKeyRef, fields, userId, anonymous);
|
|
367
|
-
if (token.token_type !== scheme)
|
|
368
|
-
throw new AuthFailure('unsupported_token_type');
|
|
369
|
-
if ((token.scope ?? '') !== '' || token.refresh_token !== undefined)
|
|
370
|
-
throw new AuthFailure('invalid_response');
|
|
371
|
-
return token;
|
|
372
|
-
}
|
|
373
471
|
async function newSessionKey() {
|
|
374
472
|
// shortcut: SecretStore stores a private JWK; use a host signer when non-extractable key persistence is available.
|
|
375
473
|
const pair = await crypto.subtle.generateKey(EC, true, ['sign', 'verify']);
|
|
@@ -413,7 +511,6 @@ export function createClientAuth(options) {
|
|
|
413
511
|
live.dpopKeyRef = ref;
|
|
414
512
|
delete live.needsSessionKey;
|
|
415
513
|
live.generation++;
|
|
416
|
-
switchSession(live, randomId('ses_'), drop);
|
|
417
514
|
if (operationId)
|
|
418
515
|
own(state.operations, operationId).connectionGeneration = live.generation;
|
|
419
516
|
return live;
|
|
@@ -475,30 +572,9 @@ export function createClientAuth(options) {
|
|
|
475
572
|
};
|
|
476
573
|
}
|
|
477
574
|
async function signedOutRenewal(connection, resource) {
|
|
478
|
-
|
|
479
|
-
const issuedAt = now(), operationId = randomId('op_');
|
|
480
|
-
if (connection.needsSessionKey)
|
|
481
|
-
connection = await freshSessionKey(connection);
|
|
482
|
-
let token;
|
|
483
|
-
try {
|
|
484
|
-
token = await signedOutToken(connection.metadata, connection.userId, connection.dpopKeyRef, connection.sessionId, resource, connection.anonymous);
|
|
485
|
-
}
|
|
486
|
-
catch (error) {
|
|
487
|
-
// The Session ended (spec 4.2): start a new signed-out one. Signed-out renewal never touches an account.
|
|
488
|
-
if (!(error instanceof AuthFailure && error.code === 'invalid_grant'))
|
|
489
|
-
throw error;
|
|
490
|
-
await transaction({ connectionId: connection.id }, state => { const live = own(state.connections, connection.id); if (live.dpopKeyRef === connection.dpopKeyRef)
|
|
491
|
-
live.needsSessionKey = true; });
|
|
492
|
-
connection = await freshSessionKey(connection);
|
|
493
|
-
token = await signedOutToken(connection.metadata, connection.userId, connection.dpopKeyRef, null, resource, connection.anonymous);
|
|
494
|
-
}
|
|
495
|
-
const secretRef = await stageSessionToken(token.access_token);
|
|
496
|
-
return commit({ connectionId: connection.id }, [secretRef], (state, drop) => {
|
|
575
|
+
return transaction({ connectionId: connection.id }, state => {
|
|
497
576
|
const current = own(state.connections, connection.id);
|
|
498
|
-
|
|
499
|
-
throw new AuthFailure('state_changed', connection.id);
|
|
500
|
-
replaceSlot(current, resource, { secretRef, tokenType: token.token_type, dpopKeyRef: current.dpopKeyRef, scopes: [], expiresAt: issuedAt + token.expires_in * 1000, generation: current.generation }, drop);
|
|
501
|
-
return finish(state, renewOperation(state, operationId, current, null, issuedAt, resource, []), 'completed');
|
|
577
|
+
return finish(state, renewOperation(state, randomId('op_'), current, null, now(), resource, []), 'completed');
|
|
502
578
|
});
|
|
503
579
|
}
|
|
504
580
|
// Account Token refresh (spec 4.8). The grant claim allows one rotation in flight per grant across processes,
|
|
@@ -554,31 +630,10 @@ export function createClientAuth(options) {
|
|
|
554
630
|
if (!dispatched)
|
|
555
631
|
return await endRefresh(operationId, 'failed', 'state_changed');
|
|
556
632
|
const issuedAt = now();
|
|
557
|
-
const send = () => tokenRequest(claim.connection.metadata, claim.connection.dpopKeyRef, fields
|
|
633
|
+
const send = () => tokenRequest(claim.connection.metadata, claim.connection.dpopKeyRef, fields);
|
|
558
634
|
let acquired;
|
|
559
635
|
try {
|
|
560
|
-
|
|
561
|
-
acquired = await send();
|
|
562
|
-
}
|
|
563
|
-
catch (error) {
|
|
564
|
-
if (!(error instanceof AuthFailure && error.code === 'invalid_grant'))
|
|
565
|
-
throw error;
|
|
566
|
-
// Diagnose with an assertion, never by resending a potentially consumed Account Token.
|
|
567
|
-
try {
|
|
568
|
-
await signedOutToken(claim.connection.metadata, claim.connection.userId, claim.connection.dpopKeyRef, claim.connection.sessionId, resource, claim.connection.anonymous);
|
|
569
|
-
}
|
|
570
|
-
catch (diagnosis) {
|
|
571
|
-
if (!(diagnosis instanceof AuthFailure && diagnosis.code === 'invalid_grant')) {
|
|
572
|
-
return await endRefresh(operationId, 'failed', diagnosis instanceof AuthFailure ? diagnosis.code : 'local_error');
|
|
573
|
-
}
|
|
574
|
-
await transaction({ connectionId: connection.id }, state => { const live = own(state.connections, connection.id); if (live.dpopKeyRef === claim.connection.dpopKeyRef)
|
|
575
|
-
live.needsSessionKey = true; });
|
|
576
|
-
claim.connection = await freshSessionKey(claim.connection, operationId);
|
|
577
|
-
acquired = await send();
|
|
578
|
-
}
|
|
579
|
-
if (!acquired)
|
|
580
|
-
return await endRefresh(operationId, 'failed', 'invalid_grant');
|
|
581
|
-
}
|
|
636
|
+
acquired = await send();
|
|
582
637
|
}
|
|
583
638
|
catch (error) {
|
|
584
639
|
if (error instanceof AuthFailure && error.retryAfterMs !== undefined)
|
|
@@ -614,7 +669,7 @@ export function createClientAuth(options) {
|
|
|
614
669
|
if (problem || accessRef === null)
|
|
615
670
|
return await endRefresh(operationId, 'failed', problem ?? 'storage_error', refreshRef);
|
|
616
671
|
const staged = {
|
|
617
|
-
accessRef, refreshRef, tokenType: token.token_type, sessionId: claim.connection.sessionId, scopes: granted, signedIn: true, expiresAt: issuedAt + token.expires_in * 1000,
|
|
672
|
+
accessRef, refreshRef, tokenType: token.token_type, sessionId: claim.connection.sessionId, scopes: granted, signedIn: true, expiresAt: issuedAt + (token.expires_in ?? 60) * 1000,
|
|
618
673
|
refreshExpiresAt: token.refresh_token_expires_in === undefined ? null : issuedAt + token.refresh_token_expires_in * 1000,
|
|
619
674
|
};
|
|
620
675
|
// Its own commit, so a crash before activation leaves the new Account Token referenced for recover (interface 4).
|
|
@@ -657,19 +712,28 @@ export function createClientAuth(options) {
|
|
|
657
712
|
const grant = operation.grantId === null ? undefined : own(state.grants, operation.grantId);
|
|
658
713
|
if (grant?.operationId === operation.id) {
|
|
659
714
|
grant.operationId = null;
|
|
660
|
-
if (
|
|
715
|
+
if (Object.values(state.connections).some(peer => peer.grantId === grant.id)) {
|
|
661
716
|
if (error === 'invalid_grant') {
|
|
662
717
|
// Expired or revoked: the Session continues signed out and the User signs in again only on request (spec 4.8, 4.9).
|
|
663
|
-
drop.push(grant.secretRef
|
|
718
|
+
drop.push(grant.secretRef);
|
|
664
719
|
delete state.grants[grant.id];
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
720
|
+
for (const peer of Object.values(state.connections))
|
|
721
|
+
if (peer.grantId === grant.id) {
|
|
722
|
+
drop.push(...Object.values(peer.slots).map(slot => slot.secretRef));
|
|
723
|
+
peer.grantId = null;
|
|
724
|
+
peer.status = 'signed_out';
|
|
725
|
+
peer.slots = {};
|
|
726
|
+
peer.generation++;
|
|
727
|
+
}
|
|
668
728
|
}
|
|
669
729
|
else if (status === 'uncertain' || rotated) {
|
|
670
730
|
// The Account Token we hold may already be replaced; only a fresh sign-in is safe.
|
|
671
731
|
grant.status = 'uncertain';
|
|
672
|
-
|
|
732
|
+
for (const peer of Object.values(state.connections))
|
|
733
|
+
if (peer.grantId === grant.id) {
|
|
734
|
+
peer.status = 'uncertain';
|
|
735
|
+
peer.generation++;
|
|
736
|
+
}
|
|
673
737
|
}
|
|
674
738
|
}
|
|
675
739
|
}
|
|
@@ -679,24 +743,25 @@ export function createClientAuth(options) {
|
|
|
679
743
|
const staged = operation.staged;
|
|
680
744
|
const connection = own(state.connections, operation.connectionId);
|
|
681
745
|
const grant = operation.grantId === null ? undefined : own(state.grants, operation.grantId);
|
|
682
|
-
if (!grant || grant.operationId !== operation.id || grant.generation !== operation.grantGeneration
|
|
683
|
-
// A sign-out or replacement won the race; the late Account Token cannot activate and awaits revocation.
|
|
746
|
+
if (!grant || grant.operationId !== operation.id || grant.generation !== operation.grantGeneration) {
|
|
684
747
|
if (staged.refreshRef !== null)
|
|
685
748
|
retire(state, `cln_${operation.id}`, connection, staged.refreshRef);
|
|
686
749
|
drop.push(staged.accessRef);
|
|
687
|
-
if (grant?.operationId === operation.id)
|
|
688
|
-
grant.operationId = null;
|
|
689
750
|
return finish(state, operation, 'failed', { error: 'state_changed' });
|
|
690
751
|
}
|
|
691
752
|
grant.operationId = null;
|
|
692
753
|
grant.generation++;
|
|
693
754
|
if (staged.refreshRef !== null) {
|
|
694
|
-
// The Company rotated the Account Token; the old one is dead and is never sent again (spec 4.8).
|
|
695
755
|
drop.push(grant.secretRef);
|
|
696
756
|
grant.secretRef = staged.refreshRef;
|
|
697
757
|
if (staged.refreshExpiresAt !== null)
|
|
698
758
|
grant.expiresAt = staged.refreshExpiresAt;
|
|
699
759
|
}
|
|
760
|
+
// Rotation belongs to the grant even if its initiating Session was replaced meanwhile.
|
|
761
|
+
if (connection.grantId !== grant.id || connection.generation !== operation.connectionGeneration) {
|
|
762
|
+
drop.push(staged.accessRef);
|
|
763
|
+
return finish(state, operation, 'failed', { error: 'state_changed' });
|
|
764
|
+
}
|
|
700
765
|
switchSession(connection, staged.sessionId, drop);
|
|
701
766
|
// Only this resource's credential changes; other resources' credentials survive (IMPLEMENTATION 3).
|
|
702
767
|
replaceSlot(connection, operation.resource, { secretRef: staged.accessRef, tokenType: staged.tokenType ?? 'DPoP', dpopKeyRef: connection.dpopKeyRef, scopes: staged.scopes, expiresAt: staged.expiresAt, generation: connection.generation }, drop);
|
|
@@ -717,56 +782,6 @@ export function createClientAuth(options) {
|
|
|
717
782
|
if (wait > 0n)
|
|
718
783
|
throw new AuthFailure('rate_limited', connection.id, Number(wait), connection.notBefore);
|
|
719
784
|
}
|
|
720
|
-
async function exchangeBearer(connection, resource, scopes) {
|
|
721
|
-
if (!connection.metadata.authorization?.grant_types_supported.includes('urn:ietf:params:oauth:grant-type:token-exchange'))
|
|
722
|
-
throw new AuthFailure('invalid_target', connection.id);
|
|
723
|
-
let subject = await credentialFor(connection.id, null, null);
|
|
724
|
-
connection = await currentConnection(connection.id);
|
|
725
|
-
const granted = publicConnection(await load({ connectionId: connection.id }), connection).scopes;
|
|
726
|
-
const requested = scopes ?? granted;
|
|
727
|
-
if (!requested.every(scope => granted.includes(scope)))
|
|
728
|
-
throw new AuthFailure('invalid_scope', connection.id);
|
|
729
|
-
if (!requested.every(scope => subject.scopes.includes(scope)))
|
|
730
|
-
subject = await renewShared(connection.id, null);
|
|
731
|
-
for (let attempt = 0;; attempt++) {
|
|
732
|
-
let value = await secrets.read(subject.secretRef);
|
|
733
|
-
if (!value) {
|
|
734
|
-
subject = await credentialFor(connection.id, null, subject.secretRef);
|
|
735
|
-
value = await secrets.read(subject.secretRef);
|
|
736
|
-
}
|
|
737
|
-
if (!value || subject.tokenType !== 'DPoP')
|
|
738
|
-
throw new AuthFailure('invalid_response', connection.id);
|
|
739
|
-
connection = await currentConnection(connection.id);
|
|
740
|
-
if (!requested.every(scope => subject.scopes.includes(scope)))
|
|
741
|
-
throw new AuthFailure('invalid_scope', connection.id);
|
|
742
|
-
const fields = { grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', subject_token: value, subject_token_type: 'urn:ietf:params:oauth:token-type:access_token', resource };
|
|
743
|
-
if (scopes !== null)
|
|
744
|
-
fields.scope = scopes.join(' ');
|
|
745
|
-
const issuedAt = now();
|
|
746
|
-
let token;
|
|
747
|
-
try {
|
|
748
|
-
({ token } = await tokenRequest(connection.metadata, connection.dpopKeyRef, fields, null, connection.anonymous));
|
|
749
|
-
}
|
|
750
|
-
catch (error) {
|
|
751
|
-
if (!(error instanceof AuthFailure && error.code === 'invalid_grant') || attempt !== 0)
|
|
752
|
-
throw error;
|
|
753
|
-
// A definite subject rejection uses normal renewal/diagnosis; ambiguous exchanges never replay credentials.
|
|
754
|
-
subject = await credentialFor(connection.id, null, subject.secretRef);
|
|
755
|
-
continue;
|
|
756
|
-
}
|
|
757
|
-
const allowed = token.scope === undefined ? requested : token.scope.split(' ').filter(Boolean);
|
|
758
|
-
if (token.token_type !== 'Bearer' || token.refresh_token !== undefined || !allowed.every(scope => requested.includes(scope)))
|
|
759
|
-
throw new AuthFailure('invalid_response', connection.id);
|
|
760
|
-
const secretRef = await stageSessionToken(token.access_token);
|
|
761
|
-
return commit({ connectionId: connection.id }, [secretRef], (state, drop) => {
|
|
762
|
-
const live = own(state.connections, connection.id);
|
|
763
|
-
if (!live || live.generation !== connection.generation || live.status !== connection.status || live.sessionId !== connection.sessionId || live.dpopKeyRef !== connection.dpopKeyRef || own(live.slots, slotKey(live.sessionId, null))?.secretRef !== subject.secretRef)
|
|
764
|
-
throw new AuthFailure('state_changed', connection.id);
|
|
765
|
-
replaceSlot(live, resource, { secretRef, tokenType: 'Bearer', dpopKeyRef: live.dpopKeyRef, scopes: allowed, expiresAt: issuedAt + token.expires_in * 1000, generation: live.generation }, drop);
|
|
766
|
-
return finish(state, renewOperation(state, randomId('op_'), live, null, issuedAt, resource, allowed), 'completed');
|
|
767
|
-
});
|
|
768
|
-
}
|
|
769
|
-
}
|
|
770
785
|
// Every token-request path (internal refresh and public renew) goes through here, so all honour Retry-After.
|
|
771
786
|
async function renewal(connection, resource, scopes) {
|
|
772
787
|
// Local failures first: a wait is only meaningful for a connection that can still send a token request.
|
|
@@ -777,10 +792,7 @@ export function createClientAuth(options) {
|
|
|
777
792
|
checkCooldown(await record(connection.id));
|
|
778
793
|
let outcome;
|
|
779
794
|
try {
|
|
780
|
-
|
|
781
|
-
outcome = await exchangeBearer(connection, resource, scopes);
|
|
782
|
-
else
|
|
783
|
-
outcome = connection.status === 'signed_out' ? await signedOutRenewal(connection, resource) : await refresh(connection, resource, scopes);
|
|
795
|
+
outcome = connection.status === 'signed_out' ? await signedOutRenewal(connection, resource) : await refresh(connection, resource, scopes);
|
|
784
796
|
}
|
|
785
797
|
catch (error) {
|
|
786
798
|
if (error instanceof AuthFailure && error.retryAfterMs !== undefined)
|
|
@@ -814,6 +826,8 @@ export function createClientAuth(options) {
|
|
|
814
826
|
const connection = await record(connectionId);
|
|
815
827
|
if (connection.status === 'disconnected')
|
|
816
828
|
throw new AuthFailure('disconnected', connectionId);
|
|
829
|
+
if (connection.status === 'uncertain')
|
|
830
|
+
throw new AuthFailure('grant_uncertain', connectionId);
|
|
817
831
|
const slot = own(connection.slots, slotKey(connection.sessionId, resource));
|
|
818
832
|
return slot && slot.secretRef !== stale && slot.expiresAt - SKEW_MS > now() ? slot : renewShared(connectionId, resource);
|
|
819
833
|
}
|
|
@@ -823,7 +837,7 @@ export function createClientAuth(options) {
|
|
|
823
837
|
// A lost exchange or rotation may have issued an Account Token we never saw, so it can never be claimed revoked.
|
|
824
838
|
if (operations.some(op => op.status === 'uncertain') || items.some(item => item.status === 'uncertain'))
|
|
825
839
|
return 'uncertain';
|
|
826
|
-
return items.length || operations.some(op => op.phase
|
|
840
|
+
return items.length || operations.some(op => op.phase !== 'terminal' && op.phase !== 'prepared') || (own(state.connections, connectionId)?.signingCleanup?.length ?? 0) ? 'pending' : 'complete';
|
|
827
841
|
}
|
|
828
842
|
// RFC 7009 revocation with a fresh client assertion (spec 4.9). Replay is safe, so anything but 200 stays pending.
|
|
829
843
|
async function revoke(connection, item, token) {
|
|
@@ -851,14 +865,17 @@ export function createClientAuth(options) {
|
|
|
851
865
|
}
|
|
852
866
|
}
|
|
853
867
|
async function cleanupNow(connectionId) {
|
|
854
|
-
const connection = await record(connectionId);
|
|
855
|
-
const
|
|
868
|
+
const connection = await record(connectionId), selection = connection.anonymous ? { connectionId } : { account: { issuer: connection.issuer, userId: connection.userId } };
|
|
869
|
+
const snapshot = await load(selection);
|
|
870
|
+
for (const peer of Object.values(snapshot.connections))
|
|
871
|
+
await cleanupSigning(peer);
|
|
872
|
+
const items = Object.values(snapshot.cleanup).filter(item => item.status === 'pending');
|
|
856
873
|
for (const item of items) {
|
|
857
874
|
const token = await secrets.read(item.secretRef);
|
|
858
|
-
const revoked = token !== null && await revoke(
|
|
875
|
+
const revoked = token !== null && await revoke(own(snapshot.connections, item.connectionId), item, token);
|
|
859
876
|
if (token !== null && !revoked)
|
|
860
877
|
continue;
|
|
861
|
-
await commit({ connectionId }, [], (state, drop) => {
|
|
878
|
+
await commit({ connectionId: item.connectionId }, [], (state, drop) => {
|
|
862
879
|
const current = own(state.cleanup, item.id);
|
|
863
880
|
if (current?.secretRef !== item.secretRef)
|
|
864
881
|
return;
|
|
@@ -870,14 +887,14 @@ export function createClientAuth(options) {
|
|
|
870
887
|
current.status = 'uncertain';
|
|
871
888
|
});
|
|
872
889
|
}
|
|
873
|
-
const state = await load(
|
|
874
|
-
return { connection: publicConnection(state, own(state.connections, connectionId)), cleanup:
|
|
890
|
+
const state = await load(selection), statuses = Object.keys(state.connections).map(id => cleanupStatus(state, id));
|
|
891
|
+
return { connection: publicConnection(state, own(state.connections, connectionId)), cleanup: statuses.includes('uncertain') ? 'uncertain' : statuses.includes('pending') ? 'pending' : 'complete' };
|
|
875
892
|
}
|
|
876
893
|
function targetOf(connection) {
|
|
877
894
|
const entry = connection.metadata.document.agent?.protocols.find(protocol => protocol.type === 'poppy');
|
|
878
895
|
if (!entry)
|
|
879
896
|
throw new AuthFailure('no_conversation', connection.id);
|
|
880
|
-
return { issuer: connection.issuer, endpoint: entry.endpoint
|
|
897
|
+
return { issuer: connection.issuer, endpoint: entry.endpoint };
|
|
881
898
|
}
|
|
882
899
|
function outcomeOf(state, operation) {
|
|
883
900
|
return operation.result ?? { operationId: operation.id, status: operation.status, connection: publicConnection(state, own(state.connections, operation.connectionId)), ...(operation.phase === 'terminal' ? {} : { nextAction: 'wait' }) };
|
|
@@ -933,18 +950,19 @@ export function createClientAuth(options) {
|
|
|
933
950
|
drop.push(staged.accessRef);
|
|
934
951
|
return finish(state, operation, problem === 'cancelled' ? 'cancelled' : 'failed', { error: problem, grantedScopes: [...granted], missingScopes });
|
|
935
952
|
}
|
|
936
|
-
if (previous) {
|
|
953
|
+
if (previous && !Object.values(state.connections).some(row => row.id !== target.id && row.grantId === previous.id)) {
|
|
937
954
|
retire(state, `cln_${previous.id}`, target, previous.secretRef);
|
|
938
955
|
delete state.grants[previous.id];
|
|
939
956
|
}
|
|
940
957
|
const grantId = randomId('grt_');
|
|
941
958
|
state.grants[grantId] = { id: grantId, issuer: target.issuer, userId: target.userId, secretRef: staged.refreshRef, scopes: granted,
|
|
942
959
|
expiresAt: staged.refreshExpiresAt, generation: 1, status: 'active', operationId: null };
|
|
960
|
+
switchSession(target, staged.sessionId, drop);
|
|
943
961
|
target.grantId = grantId;
|
|
944
962
|
target.status = 'signed_in';
|
|
945
963
|
target.generation++;
|
|
946
964
|
drop.push(...Object.values(target.slots).map(slot => slot.secretRef));
|
|
947
|
-
target.slots = { [slotKey(staged.sessionId, operation.resource)]: { secretRef: staged.accessRef, tokenType: 'DPoP', dpopKeyRef: target.dpopKeyRef,
|
|
965
|
+
target.slots = { [slotKey(staged.sessionId, operation.resource)]: { secretRef: staged.accessRef, tokenType: staged.tokenType ?? 'DPoP', dpopKeyRef: target.dpopKeyRef,
|
|
948
966
|
scopes: granted, expiresAt: staged.expiresAt, generation: target.generation } };
|
|
949
967
|
return finish(state, operation, 'completed', { grantedScopes: [...granted], missingScopes });
|
|
950
968
|
}
|
|
@@ -959,19 +977,19 @@ export function createClientAuth(options) {
|
|
|
959
977
|
catch {
|
|
960
978
|
return await terminate(operationId, 'uncertain', 'storage_error');
|
|
961
979
|
}
|
|
962
|
-
if (
|
|
980
|
+
if (!refreshRef ||
|
|
963
981
|
!granted.length || new Set(granted).size !== granted.length || granted.some(scope => !operation.scopes.includes(scope))) {
|
|
964
982
|
return commit({ operationId }, [], (state, drop) => {
|
|
965
983
|
const current = own(state.operations, operationId);
|
|
966
984
|
if (refreshRef)
|
|
967
985
|
retire(state, `cln_${operationId}`, own(state.connections, current.connectionId), refreshRef);
|
|
968
986
|
drop.push(...privateRefs(current));
|
|
969
|
-
return current.phase === 'terminal' ? outcomeOf(state, current) : finish(state, current, 'failed', { error:
|
|
987
|
+
return current.phase === 'terminal' ? outcomeOf(state, current) : finish(state, current, 'failed', { error: 'invalid_response' });
|
|
970
988
|
});
|
|
971
989
|
}
|
|
972
990
|
const accessRef = await stageSessionToken(token.access_token);
|
|
973
|
-
const staged = { accessRef, refreshRef,
|
|
974
|
-
expiresAt: issuedAt + token.expires_in * 1000, refreshExpiresAt: token.refresh_token_expires_in === undefined ? null : issuedAt + token.refresh_token_expires_in * 1000 };
|
|
991
|
+
const staged = { accessRef, refreshRef, tokenType: token.token_type, sessionId: randomId('ses_'), scopes: granted, signedIn: true,
|
|
992
|
+
expiresAt: issuedAt + (token.expires_in ?? 60) * 1000, refreshExpiresAt: token.refresh_token_expires_in === undefined ? null : issuedAt + token.refresh_token_expires_in * 1000 };
|
|
975
993
|
// Stage before activation: recovery can finish locally without exchanging a consumed credential again.
|
|
976
994
|
const recorded = await transaction({ operationId }, state => {
|
|
977
995
|
const current = own(state.operations, operationId);
|
|
@@ -1108,7 +1126,7 @@ export function createClientAuth(options) {
|
|
|
1108
1126
|
fields.resource = operation.resource;
|
|
1109
1127
|
let token;
|
|
1110
1128
|
try {
|
|
1111
|
-
token = (await tokenRequest(connection.metadata, connection.dpopKeyRef, fields
|
|
1129
|
+
token = (await tokenRequest(connection.metadata, connection.dpopKeyRef, fields)).token;
|
|
1112
1130
|
}
|
|
1113
1131
|
catch (error) {
|
|
1114
1132
|
if (error instanceof AuthFailure && ['authorization_pending', 'slow_down', 'rate_limited'].includes(error.code)) {
|
|
@@ -1144,41 +1162,26 @@ export function createClientAuth(options) {
|
|
|
1144
1162
|
const claim = await claimSignIn(operationId, executionOwner, true);
|
|
1145
1163
|
if (!('operation' in claim))
|
|
1146
1164
|
return claim;
|
|
1147
|
-
const { operation, connection } = claim, mediated = connection.metadata.document
|
|
1148
|
-
if (!options.trustedInput)
|
|
1149
|
-
return
|
|
1165
|
+
const { operation, connection } = claim, mediated = mediatedSignIn(connection.metadata.document);
|
|
1166
|
+
if (!mediated || !options.trustedInput)
|
|
1167
|
+
return terminate(operationId, 'failed', 'trusted_input_required');
|
|
1150
1168
|
const fields = operation.mediated ? [{ name: 'code', label: 'One-time code', secret: true }] : mediated.fields;
|
|
1151
|
-
let values;
|
|
1152
1169
|
try {
|
|
1153
|
-
|
|
1154
|
-
...(operation.mediated ? { challenge: { id: operation.mediated.challengeId, sentTo: operation.mediated.sentTo, expiresAt: operation.expiresAt } } : {}) });
|
|
1170
|
+
sensitive = await options.trustedInput.collect({ operationId, issuer: connection.issuer, organization: { ...connection.metadata.document.organization }, fields: structuredClone(fields), ...(operation.mediated ? { challenge: { id: operation.mediated.challengeId, sentTo: operation.mediated.sentTo, expiresAt: operation.expiresAt } } : {}) });
|
|
1155
1171
|
}
|
|
1156
1172
|
catch {
|
|
1157
|
-
return
|
|
1173
|
+
return terminate(operationId, 'failed', 'input_unavailable');
|
|
1158
1174
|
}
|
|
1159
|
-
|
|
1175
|
+
const values = sensitive;
|
|
1160
1176
|
if (values === null)
|
|
1161
|
-
return
|
|
1162
|
-
if (
|
|
1163
|
-
return
|
|
1164
|
-
const endpoint = operation.mediated ? `${mediated.endpoint.replace(/\/$/, '')}/${encodeURIComponent(operation.mediated.challengeId)}` : mediated.endpoint;
|
|
1165
|
-
let credential = await credentialFor(connection.id, operation.resource, null), token = await secrets.read(credential.secretRef);
|
|
1166
|
-
if (!token) {
|
|
1167
|
-
credential = await credentialFor(connection.id, operation.resource, credential.secretRef);
|
|
1168
|
-
token = await secrets.read(credential.secretRef);
|
|
1169
|
-
}
|
|
1170
|
-
if (!token)
|
|
1171
|
-
return await terminate(operationId, 'failed', 'missing_credential');
|
|
1172
|
-
if (connection.sessionId !== (await record(connection.id)).sessionId || now() >= operation.expiresAt)
|
|
1173
|
-
return await terminate(operationId, 'expired');
|
|
1174
|
-
let response, json;
|
|
1175
|
-
const issuedAt = now(), key = await loadDpopKey(connection.dpopKeyRef);
|
|
1177
|
+
return terminate(operationId, 'cancelled');
|
|
1178
|
+
if (Object.keys(values).length !== fields.length || !fields.every(field => Object.hasOwn(values, field.name) && typeof values[field.name] === 'string' && values[field.name].length > 0 && values[field.name].length <= 4096))
|
|
1179
|
+
return terminate(operationId, 'failed', 'invalid_input');
|
|
1176
1180
|
const dispatch = await transaction({ operationId }, state => {
|
|
1177
|
-
const current = own(state.operations, operationId);
|
|
1181
|
+
const current = own(state.operations, operationId), live = own(state.connections, current.connectionId);
|
|
1178
1182
|
if (current.phase !== 'prepared' || current.owner?.id !== executionOwner.id || current.cancelRequested)
|
|
1179
1183
|
return false;
|
|
1180
|
-
|
|
1181
|
-
if (live.generation !== current.connectionGeneration || live.sessionId !== current.signInSessionId || current.expiresAt <= now()) {
|
|
1184
|
+
if (live.generation !== current.connectionGeneration || current.expiresAt <= now()) {
|
|
1182
1185
|
finish(state, current, 'cancelled');
|
|
1183
1186
|
return false;
|
|
1184
1187
|
}
|
|
@@ -1187,69 +1190,48 @@ export function createClientAuth(options) {
|
|
|
1187
1190
|
});
|
|
1188
1191
|
if (!dispatch)
|
|
1189
1192
|
return (await api.operation(operationId));
|
|
1193
|
+
const params = { grant_type: MEDIATED_GRANT, ...(operation.mediated ? { sign_in_id: operation.mediated.challengeId, one_time_code: values.code } : { scope: operation.scopes.join(' '), ...values }) };
|
|
1194
|
+
if (operation.resource)
|
|
1195
|
+
params.resource = operation.resource;
|
|
1196
|
+
const issuedAt = now();
|
|
1190
1197
|
try {
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
if (attempt > 0 && own((await load({ operationId })).operations, operationId)?.cancelRequested)
|
|
1194
|
-
return await terminate(operationId, 'cancelled');
|
|
1195
|
-
response = await transport.fetch(new Request(endpoint, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `DPoP ${token}`, dpop: await proof(key, 'POST', endpoint, token) },
|
|
1196
|
-
body: JSON.stringify(operation.mediated ? { code: values.code } : { scope: operation.scopes.join(' '), credentials: values }) }));
|
|
1197
|
-
rememberNonce(endpoint, response);
|
|
1198
|
-
if (response.status === 401 && attempt === 0 && dpopChallenge(response.headers.get('www-authenticate'))?.error === 'use_dpop_nonce' && response.headers.has('dpop-nonce')) {
|
|
1199
|
-
await response.body?.cancel();
|
|
1200
|
-
continue;
|
|
1201
|
-
}
|
|
1202
|
-
break;
|
|
1203
|
-
}
|
|
1204
|
-
if (response.status === 429 || response.status === 503)
|
|
1205
|
-
await holdOff(connection.id, responseFailure(response, 'rate_limited'));
|
|
1206
|
-
json = parseJson(await response.text());
|
|
1207
|
-
}
|
|
1208
|
-
catch {
|
|
1209
|
-
return await terminate(operationId, response?.status === 429 ? 'failed' : 'uncertain', response?.status === 429 ? 'rate_limited' : 'lost_response');
|
|
1210
|
-
}
|
|
1211
|
-
const echoed = Object.values(values);
|
|
1212
|
-
for (const name of Object.keys(values))
|
|
1213
|
-
delete values[name];
|
|
1214
|
-
values = null;
|
|
1215
|
-
if (response.status !== 200) {
|
|
1216
|
-
const error = responseFailure(response, response.status === 429 ? 'rate_limited' : response.status >= 400 && response.status < 500 && typeof json?.error === 'string' && SAFE_ERRORS.has(json.error) ? json.error : 'token_error');
|
|
1217
|
-
return await remoteFailure(operationId, error);
|
|
1198
|
+
const { token } = await tokenRequest(connection.metadata, connection.dpopKeyRef, params, operationId);
|
|
1199
|
+
return await acceptSignIn(operationId, token, issuedAt);
|
|
1218
1200
|
}
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
return
|
|
1201
|
+
catch (error) {
|
|
1202
|
+
if (error instanceof MediatedCodeRequired) {
|
|
1203
|
+
const challenge = error.challenge;
|
|
1204
|
+
if (operation.mediated && operation.mediated.challengeId !== challenge.id)
|
|
1205
|
+
return terminate(operationId, 'failed', 'invalid_response');
|
|
1206
|
+
const sentTo = Object.values(values).some(value => challenge.sentTo.includes(value)) ? 'The company sent a one-time code.' : challenge.sentTo;
|
|
1207
|
+
return transaction({ operationId }, state => {
|
|
1208
|
+
const current = own(state.operations, operationId);
|
|
1209
|
+
if (current.phase !== 'dispatched')
|
|
1210
|
+
return outcomeOf(state, current);
|
|
1211
|
+
if (current.cancelRequested)
|
|
1212
|
+
return finish(state, current, 'cancelled');
|
|
1213
|
+
current.phase = 'prepared';
|
|
1214
|
+
delete current.owner;
|
|
1215
|
+
current.expiresAt = Math.min(current.expiresAt, challenge.expiresAt);
|
|
1216
|
+
current.mediated = { challengeId: challenge.id, sentTo };
|
|
1217
|
+
return outcomeOf(state, current);
|
|
1218
|
+
});
|
|
1226
1219
|
}
|
|
1227
|
-
|
|
1220
|
+
if (!(error instanceof AuthFailure))
|
|
1221
|
+
throw error;
|
|
1222
|
+
if (['access_denied', 'invalid_grant', 'expired_token', 'cancelled'].includes(error.code))
|
|
1223
|
+
return terminate(operationId, error.code === 'expired_token' ? 'expired' : error.code === 'cancelled' ? 'cancelled' : 'denied', error.code);
|
|
1224
|
+
return remoteFailure(operationId, error);
|
|
1225
|
+
}
|
|
1226
|
+
finally {
|
|
1227
|
+
for (const name of Object.keys(params))
|
|
1228
|
+
delete params[name];
|
|
1228
1229
|
}
|
|
1229
|
-
if (json?.status === 'failed' || json?.status === 'expired')
|
|
1230
|
-
return await terminate(operationId, json.status === 'expired' ? 'expired' : 'denied');
|
|
1231
|
-
const code = json?.code, expiresAt = typeof json?.expires_at === 'string' ? Date.parse(json.expires_at) : NaN;
|
|
1232
|
-
if (json?.status !== 'code_required' || typeof json.sign_in_id !== 'string' || !/^[A-Za-z0-9_-]{1,256}$/.test(json.sign_in_id) ||
|
|
1233
|
-
(operation.mediated && operation.mediated.challengeId !== json.sign_in_id) || typeof code?.sent_to !== 'string' || code.sent_to.length > 512 || !Number.isFinite(expiresAt) || expiresAt <= now())
|
|
1234
|
-
return await terminate(operationId, 'failed', 'invalid_response');
|
|
1235
|
-
const sentTo = echoed.some(value => value.length > 2 && code.sent_to.includes(value)) ? 'The company sent a one-time code.' : code.sent_to;
|
|
1236
|
-
return await transaction({ operationId }, state => {
|
|
1237
|
-
const current = own(state.operations, operationId);
|
|
1238
|
-
if (current.phase !== 'dispatched')
|
|
1239
|
-
return outcomeOf(state, current);
|
|
1240
|
-
if (current.cancelRequested)
|
|
1241
|
-
return finish(state, current, 'cancelled');
|
|
1242
|
-
current.phase = 'prepared';
|
|
1243
|
-
delete current.owner;
|
|
1244
|
-
current.expiresAt = Math.min(current.expiresAt, expiresAt);
|
|
1245
|
-
current.mediated = { challengeId: json.sign_in_id, sentTo };
|
|
1246
|
-
return outcomeOf(state, current);
|
|
1247
|
-
});
|
|
1248
1230
|
}
|
|
1249
1231
|
catch (error) {
|
|
1250
|
-
if (
|
|
1232
|
+
if (own((await load({ operationId })).operations, operationId)?.phase === 'staged')
|
|
1251
1233
|
throw error;
|
|
1252
|
-
return
|
|
1234
|
+
return remoteFailure(operationId, error);
|
|
1253
1235
|
}
|
|
1254
1236
|
finally {
|
|
1255
1237
|
if (sensitive)
|
|
@@ -1258,61 +1240,120 @@ export function createClientAuth(options) {
|
|
|
1258
1240
|
releaseOwner(executionOwner);
|
|
1259
1241
|
}
|
|
1260
1242
|
}
|
|
1261
|
-
async function sendAuthorized(connectionId, request, target, requiredScopes, scheme = 'DPoP', retryRejected = true) {
|
|
1262
|
-
const connection = await record(connectionId);
|
|
1243
|
+
async function sendAuthorized(connectionId, request, target, requiredScopes, scheme = 'DPoP', retryRejected = true, service, expectedContext) {
|
|
1263
1244
|
const url = new URL(request.url), endpoint = new URL(target.endpoint), base = endpoint.pathname.replace(/\/$/, '');
|
|
1264
1245
|
if (!safeHttps(request.url) || url.origin !== endpoint.origin || (url.pathname !== endpoint.pathname && !url.pathname.startsWith(`${base}/`)))
|
|
1265
1246
|
throw new AuthFailure('invalid_target', connectionId);
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
checkCooldown(connection);
|
|
1269
|
-
const resource = target.resource ?? null, wait = url.searchParams.get('wait');
|
|
1247
|
+
let resource = target.resource ?? null;
|
|
1248
|
+
const wait = url.searchParams.get('wait');
|
|
1270
1249
|
const waitMs = wait !== null && /^\d+$/.test(wait) ? Math.min(Number(wait), MAX_WAIT_SECONDS) * 1000 : 0;
|
|
1271
|
-
let
|
|
1250
|
+
let restarted = false, nonceRetried = false, renewed = false, discovered = false;
|
|
1272
1251
|
for (;;) {
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1252
|
+
const connection = await record(connectionId);
|
|
1253
|
+
if (expectedContext && (expectedContext.companyId !== connection.issuer || expectedContext.clientId !== clientId || expectedContext.userId !== connection.userId || expectedContext.authority !== (connection.status === 'signed_in' && connection.grantId ? 'grant:' + connection.grantId : 'session:' + connection.sessionId)))
|
|
1254
|
+
throw new AuthFailure('business_owner_changed', connectionId);
|
|
1255
|
+
if (connection.status === 'disconnected')
|
|
1256
|
+
throw new AuthFailure('disconnected', connectionId);
|
|
1257
|
+
if (connection.status === 'uncertain')
|
|
1258
|
+
throw new AuthFailure('grant_uncertain', connectionId);
|
|
1259
|
+
checkCooldown(connection);
|
|
1260
|
+
let credential, token;
|
|
1261
|
+
if (connection.status === 'signed_in') {
|
|
1262
|
+
if (service && !discovered) {
|
|
1263
|
+
try {
|
|
1264
|
+
resource = (await serviceAuthorization(connection.metadata, options.metadata, transport, target.endpoint, { mcp: service === 'mcp' })).resource ?? null;
|
|
1265
|
+
}
|
|
1266
|
+
catch {
|
|
1267
|
+
throw new AuthFailure('invalid_target', connectionId);
|
|
1268
|
+
}
|
|
1269
|
+
discovered = true;
|
|
1270
|
+
}
|
|
1271
|
+
try {
|
|
1272
|
+
credential = await credentialFor(connectionId, resource, null);
|
|
1273
|
+
}
|
|
1274
|
+
catch (error) {
|
|
1275
|
+
if ((await record(connectionId)).status === 'signed_out')
|
|
1276
|
+
continue;
|
|
1277
|
+
throw error;
|
|
1278
|
+
}
|
|
1279
|
+
const value = await secrets.read(credential.secretRef);
|
|
1280
|
+
if (value === null) {
|
|
1281
|
+
if (renewed)
|
|
1282
|
+
throw new AuthFailure('missing_credential', connectionId);
|
|
1283
|
+
renewed = true;
|
|
1284
|
+
await credentialFor(connectionId, resource, credential.secretRef);
|
|
1285
|
+
continue;
|
|
1286
|
+
}
|
|
1287
|
+
token = value;
|
|
1288
|
+
}
|
|
1289
|
+
if (!requiredScopes.every(scope => credential?.scopes.includes(scope))) {
|
|
1276
1290
|
const requirement = await api.authorization(connectionId, requiredScopes);
|
|
1277
1291
|
if (!renewed && requirement.sufficient) {
|
|
1278
1292
|
renewed = true;
|
|
1279
|
-
|
|
1293
|
+
await renewShared(connectionId, resource);
|
|
1280
1294
|
continue;
|
|
1281
1295
|
}
|
|
1282
1296
|
throw new AuthFailure('insufficient_scope', connectionId);
|
|
1283
1297
|
}
|
|
1284
|
-
const token = await secrets.read(credential.secretRef);
|
|
1285
|
-
if (token === null) {
|
|
1286
|
-
if (renewed)
|
|
1287
|
-
throw new AuthFailure('missing_credential', connectionId);
|
|
1288
|
-
renewed = true;
|
|
1289
|
-
credential = await credentialFor(connectionId, resource, credential.secretRef);
|
|
1290
|
-
continue;
|
|
1291
|
-
}
|
|
1292
1298
|
const headers = new Headers(request.headers);
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1299
|
+
for (const name of ['cookie', 'authorization', 'dpop', 'poppy-session'])
|
|
1300
|
+
headers.delete(name);
|
|
1301
|
+
if (token !== undefined) {
|
|
1302
|
+
headers.set('authorization', `${credential.tokenType} ${token}`);
|
|
1303
|
+
if (credential.tokenType === 'DPoP')
|
|
1304
|
+
headers.set('dpop', await proof(await loadDpopKey(credential.dpopKeyRef), request.method, request.url, token));
|
|
1305
|
+
}
|
|
1306
|
+
headers.set('poppy-session', await sessionHeader(connection, request, token));
|
|
1307
|
+
if ((await record(connectionId)).generation !== connection.generation)
|
|
1308
|
+
continue;
|
|
1298
1309
|
const authorized = new Request(request.clone(), { headers, credentials: 'omit', redirect: 'error' });
|
|
1299
1310
|
const streaming = request.headers.get('accept')?.split(',').some(value => value.trim().split(';')[0] === 'text/event-stream');
|
|
1300
1311
|
const response = await (streaming && transport.stream ? transport.stream(authorized) : transport.fetch(authorized, { waitMs }));
|
|
1301
1312
|
rememberNonce(request.url, response);
|
|
1302
1313
|
if (response.status === 429 || response.status === 503)
|
|
1303
1314
|
await holdOff(connectionId, responseFailure(response, 'rate_limited'));
|
|
1315
|
+
if (response.status === 400) {
|
|
1316
|
+
let invalid = false;
|
|
1317
|
+
try {
|
|
1318
|
+
invalid = parseProtocolError(parseJson(await response.clone().text())).error === 'invalid_session';
|
|
1319
|
+
}
|
|
1320
|
+
catch { /* Not a Session refusal. */ }
|
|
1321
|
+
if (invalid && !restarted) {
|
|
1322
|
+
await response.body?.cancel().catch(() => { });
|
|
1323
|
+
restarted = true;
|
|
1324
|
+
await transaction({ connectionId }, state => {
|
|
1325
|
+
const live = own(state.connections, connectionId);
|
|
1326
|
+
if (live.generation !== connection.generation)
|
|
1327
|
+
return;
|
|
1328
|
+
live.sessionId = randomId('ses_');
|
|
1329
|
+
live.generation++;
|
|
1330
|
+
live.slots = Object.fromEntries(Object.entries(live.slots).map(([key, slot]) => [slotKey(live.sessionId, JSON.parse(key)[1]), slot]));
|
|
1331
|
+
});
|
|
1332
|
+
if (!retryRejected)
|
|
1333
|
+
return response;
|
|
1334
|
+
continue;
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1304
1337
|
if (response.status !== 401 || !retryRejected)
|
|
1305
1338
|
return response;
|
|
1306
|
-
const
|
|
1339
|
+
const challenge = schemeChallenge(response.headers.get('www-authenticate'), credential?.tokenType ?? scheme), error = challenge?.error;
|
|
1307
1340
|
if (error === 'use_dpop_nonce' && !nonceRetried && response.headers.has('dpop-nonce')) {
|
|
1308
1341
|
await response.body?.cancel().catch(() => { });
|
|
1309
1342
|
nonceRetried = true;
|
|
1310
1343
|
continue;
|
|
1311
1344
|
}
|
|
1312
|
-
if (error === 'invalid_token' && !renewed) {
|
|
1345
|
+
if (error === 'invalid_token' && credential && !renewed) {
|
|
1313
1346
|
await response.body?.cancel().catch(() => { });
|
|
1314
1347
|
renewed = true;
|
|
1315
|
-
|
|
1348
|
+
if (service && challenge?.resource_metadata) {
|
|
1349
|
+
try {
|
|
1350
|
+
resource = (await serviceAuthorization(connection.metadata, options.metadata, transport, target.endpoint, { mcp: service === 'mcp', metadataUrl: challenge.resource_metadata, refresh: true })).resource ?? null;
|
|
1351
|
+
}
|
|
1352
|
+
catch {
|
|
1353
|
+
throw new AuthFailure('invalid_target', connectionId);
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
await credentialFor(connectionId, resource, credential.secretRef);
|
|
1316
1357
|
continue;
|
|
1317
1358
|
}
|
|
1318
1359
|
return response;
|
|
@@ -1327,26 +1368,31 @@ export function createClientAuth(options) {
|
|
|
1327
1368
|
catch {
|
|
1328
1369
|
throw new AuthFailure('discovery_failed');
|
|
1329
1370
|
}
|
|
1330
|
-
|
|
1331
|
-
throw new AuthFailure('no_auth');
|
|
1332
|
-
const issuer = metadata.authorization.issuer;
|
|
1371
|
+
const issuer = companyIdentifier(metadata);
|
|
1333
1372
|
const anonymous = input?.anonymous === true;
|
|
1373
|
+
if (input?.accountFrom !== undefined && (anonymous || typeof input.accountFrom !== 'string'))
|
|
1374
|
+
throw new AuthFailure('invalid_account_source');
|
|
1334
1375
|
const { userId } = await identityFor(issuer, anonymous);
|
|
1335
1376
|
const epoch = (await load({ issuer })).epoch;
|
|
1336
1377
|
const dpopKeyRef = await newSessionKey();
|
|
1337
1378
|
const createdAt = now();
|
|
1338
|
-
const token = await signedOutToken(metadata, userId, dpopKeyRef, null, null, anonymous);
|
|
1339
|
-
const secretRef = await stageSessionToken(token.access_token);
|
|
1340
1379
|
const id = randomId('con_'), operationId = randomId('op_'), sessionId = randomId('ses_');
|
|
1341
|
-
return commit({ issuer, history: true },
|
|
1380
|
+
return commit(input?.accountFrom ? { account: { issuer, userId }, history: true } : { issuer, history: true }, [dpopKeyRef], state => {
|
|
1342
1381
|
// A reset or competing identity since we signed means this session belongs to no current identity.
|
|
1343
1382
|
if (state.epoch !== epoch || (!anonymous && own(state.identities, issuer)?.userId !== userId))
|
|
1344
1383
|
throw new AuthFailure('state_changed');
|
|
1345
1384
|
const connection = {
|
|
1346
1385
|
id, domain, issuer, metadata, userId, dpopKeyRef, sessionId, generation: 1, status: 'signed_out', grantId: null,
|
|
1347
|
-
slots: {
|
|
1386
|
+
slots: {},
|
|
1348
1387
|
...(anonymous ? { anonymous: true } : {}),
|
|
1349
1388
|
};
|
|
1389
|
+
if (input?.accountFrom) {
|
|
1390
|
+
const source = own(state.connections, input.accountFrom), grant = source?.grantId ? own(state.grants, source.grantId) : undefined;
|
|
1391
|
+
if (!source || source.anonymous || source.issuer !== issuer || source.userId !== userId || source.status !== 'signed_in' || !grant || grant.status !== 'active' || (grant.expiresAt !== null && grant.expiresAt <= now()))
|
|
1392
|
+
throw new AuthFailure('state_changed');
|
|
1393
|
+
connection.grantId = grant.id;
|
|
1394
|
+
connection.status = 'signed_in';
|
|
1395
|
+
}
|
|
1350
1396
|
state.connections[id] = connection;
|
|
1351
1397
|
pruneHistory(state);
|
|
1352
1398
|
const result = { operationId, status: 'completed', connection: publicConnection(state, connection) };
|
|
@@ -1395,7 +1441,7 @@ export function createClientAuth(options) {
|
|
|
1395
1441
|
if (!supported || (method === 'direct' && !authorizationEndpoint) || (method === 'device' && !found.metadata.authorization?.device_authorization_endpoint))
|
|
1396
1442
|
throw new AuthFailure(`${method}_unavailable`, connectionId);
|
|
1397
1443
|
const { scopes, redirectUri, browserBinding, requiredExistingScopes } = input ?? {};
|
|
1398
|
-
if (!Array.isArray(scopes) || !scopes.length || new Set(scopes).size !== scopes.length || !scopes.every(scope => typeof scope === 'string' && supported.scopes.includes(scope)))
|
|
1444
|
+
if (!Array.isArray(scopes) || !scopes.length || new Set(scopes).size !== scopes.length || !scopes.every(scope => typeof scope === 'string' && (/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope) && (method !== 'mediated' || supported.scopes.includes(scope)))))
|
|
1399
1445
|
throw new AuthFailure('invalid_scope', connectionId);
|
|
1400
1446
|
const requirement = await api.authorization(connectionId, scopes);
|
|
1401
1447
|
if (requiredExistingScopes !== undefined && (!Array.isArray(requiredExistingScopes) || !requiredExistingScopes.every(scope => requirement.grantedScopes.includes(scope))))
|
|
@@ -1410,32 +1456,17 @@ export function createClientAuth(options) {
|
|
|
1410
1456
|
// The host derives this from its authenticated browser session; a short value would be guessable.
|
|
1411
1457
|
if (method === 'direct' && (typeof browserBinding !== 'string' || browserBinding.length < 22))
|
|
1412
1458
|
throw new AuthFailure('invalid_binding', connectionId);
|
|
1413
|
-
//
|
|
1414
|
-
const
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
if (found.needsSessionKey && found.status === 'uncertain') {
|
|
1418
|
-
found = await freshSessionKey(found);
|
|
1419
|
-
await signedOutToken(found.metadata, found.userId, found.dpopKeyRef, found.sessionId, resource, found.anonymous);
|
|
1420
|
-
}
|
|
1421
|
-
// The code exchange names this Session (spec 4.5). A stale credential means it may have ended, so renew first,
|
|
1422
|
-
// which replaces an ended Session; a failed renewal leaves the Session as it was for the issuer to judge.
|
|
1423
|
-
const renew = () => credentialFor(connectionId, resource, null).catch(error => { if (!(error instanceof AuthFailure))
|
|
1424
|
-
throw error; });
|
|
1425
|
-
if (found.status === 'signed_out' || found.status === 'signed_in')
|
|
1426
|
-
await renew();
|
|
1427
|
-
let connection = await record(connectionId);
|
|
1428
|
-
// An expired Account Token ends the refresh as invalid_grant, leaving the connection signed out on a possibly ended Session.
|
|
1429
|
-
if (found.status === 'signed_in' && connection.status === 'signed_out') {
|
|
1430
|
-
await renew();
|
|
1431
|
-
connection = await record(connectionId);
|
|
1432
|
-
}
|
|
1459
|
+
// Service metadata, rather than an obsolete discovery field, selects narrower tokens.
|
|
1460
|
+
const conversation = found.metadata.document.agent?.protocols.find(protocol => protocol.type === 'poppy');
|
|
1461
|
+
const resource = conversation ? (await serviceAuthorization(found.metadata, options.metadata, transport, conversation.endpoint)).resource ?? null : null;
|
|
1462
|
+
const connection = await record(connectionId);
|
|
1433
1463
|
// An uncertain grant recovers only through a fresh sign-in; a disconnected connection never signs in again (spec 4.9).
|
|
1434
1464
|
if (connection.status === 'disconnected' || connection.sessionId === null)
|
|
1435
1465
|
throw new AuthFailure('sign_in_unavailable', connectionId);
|
|
1436
1466
|
const state = b64(crypto.getRandomValues(new Uint8Array(32)));
|
|
1437
1467
|
const verifier = b64(crypto.getRandomValues(new Uint8Array(32)));
|
|
1438
1468
|
const sessionKey = (await loadDpopKey(connection.dpopKeyRef)).thumbprint;
|
|
1469
|
+
const proofBound = method === 'direct' && await resourceScheme(found.metadata, resource) === 'DPoP';
|
|
1439
1470
|
const verifierRef = method === 'direct' ? await secrets.stage(verifier) : null;
|
|
1440
1471
|
const createdAt = now(), operationId = randomId('op_');
|
|
1441
1472
|
const outcome = await commit({ connectionId, history: true }, [verifierRef], draft => {
|
|
@@ -1457,9 +1488,11 @@ export function createClientAuth(options) {
|
|
|
1457
1488
|
if (method === 'mediated')
|
|
1458
1489
|
return { outcome: await continueMediated(outcome.operationId) };
|
|
1459
1490
|
const url = new URL(authorizationEndpoint);
|
|
1460
|
-
const query = { response_type: 'code', client_id: clientId, redirect_uri: redirectUri, scope: scopes.join(' '), state, code_challenge: sha256(verifier), code_challenge_method: 'S256'
|
|
1491
|
+
const query = { response_type: 'code', client_id: clientId, redirect_uri: redirectUri, scope: scopes.join(' '), state, code_challenge: sha256(verifier), code_challenge_method: 'S256' };
|
|
1461
1492
|
for (const [name, value] of Object.entries(query))
|
|
1462
1493
|
url.searchParams.set(name, value);
|
|
1494
|
+
if (proofBound)
|
|
1495
|
+
url.searchParams.set('dpop_jkt', sessionKey);
|
|
1463
1496
|
if (resource !== null)
|
|
1464
1497
|
url.searchParams.set('resource', resource);
|
|
1465
1498
|
return { outcome, authorizationUrl: url.href };
|
|
@@ -1516,7 +1549,7 @@ export function createClientAuth(options) {
|
|
|
1516
1549
|
const issuedAt = now();
|
|
1517
1550
|
let token;
|
|
1518
1551
|
try {
|
|
1519
|
-
token = (await tokenRequest(connection.metadata, connection.dpopKeyRef, fields
|
|
1552
|
+
token = (await tokenRequest(connection.metadata, connection.dpopKeyRef, fields)).token;
|
|
1520
1553
|
}
|
|
1521
1554
|
catch (error) {
|
|
1522
1555
|
if (error instanceof AuthFailure)
|
|
@@ -1616,38 +1649,52 @@ export function createClientAuth(options) {
|
|
|
1616
1649
|
throw new AuthFailure('invalid_scope', connectionId);
|
|
1617
1650
|
return renewal(await currentConnection(connectionId), resource, scopes ?? null);
|
|
1618
1651
|
},
|
|
1619
|
-
//
|
|
1652
|
+
// Opaque grants have no standard account identifier; revoke all grants for this local User and Company.
|
|
1620
1653
|
async signOut(connectionId) {
|
|
1621
|
-
await record(connectionId);
|
|
1622
|
-
await commit(
|
|
1623
|
-
const connection
|
|
1624
|
-
|
|
1625
|
-
return;
|
|
1626
|
-
connection.generation++;
|
|
1627
|
-
connection.status = 'disconnected';
|
|
1628
|
-
drop.push(...Object.values(connection.slots).map(slot => slot.secretRef));
|
|
1629
|
-
connection.slots = {};
|
|
1630
|
-
if (connection.anonymous) {
|
|
1631
|
-
drop.push(connection.dpopKeyRef);
|
|
1632
|
-
connection.userId = '';
|
|
1633
|
-
connection.sessionId = null;
|
|
1634
|
-
}
|
|
1635
|
-
const grant = connection.grantId === null ? undefined : own(state.grants, connection.grantId);
|
|
1636
|
-
if (grant) {
|
|
1637
|
-
retire(state, `cln_${grant.id}`, connection, grant.secretRef);
|
|
1638
|
-
delete state.grants[grant.id];
|
|
1639
|
-
}
|
|
1640
|
-
connection.grantId = null;
|
|
1641
|
-
for (const operation of Object.values(state.operations)) {
|
|
1642
|
-
if (operation.connectionId !== connectionId || operation.epoch !== state.epoch || operation.phase !== 'prepared')
|
|
1654
|
+
const selected = await record(connectionId), selection = selected.anonymous ? { connectionId } : { account: { issuer: selected.issuer, userId: selected.userId } };
|
|
1655
|
+
await commit(selection, [], (state, drop) => {
|
|
1656
|
+
for (const connection of Object.values(state.connections)) {
|
|
1657
|
+
if (connection.status === 'disconnected')
|
|
1643
1658
|
continue;
|
|
1644
|
-
|
|
1645
|
-
|
|
1659
|
+
connection.generation++;
|
|
1660
|
+
connection.status = connection.anonymous ? 'disconnected' : 'signed_out';
|
|
1661
|
+
drop.push(...Object.values(connection.slots).map(slot => slot.secretRef));
|
|
1662
|
+
connection.slots = {};
|
|
1663
|
+
if (connection.anonymous) {
|
|
1664
|
+
if (connection.sessionSigning)
|
|
1665
|
+
(connection.signingCleanup ??= []).push(connection.sessionSigning.keyRef, connection.sessionSigning.delegationRef);
|
|
1666
|
+
delete connection.sessionSigning;
|
|
1667
|
+
(connection.signingCleanup ??= []).push(connection.dpopKeyRef);
|
|
1668
|
+
connection.userId = '';
|
|
1669
|
+
connection.sessionId = null;
|
|
1670
|
+
}
|
|
1671
|
+
const grant = connection.grantId === null ? undefined : own(state.grants, connection.grantId);
|
|
1672
|
+
if (grant) {
|
|
1673
|
+
retire(state, `cln_${grant.id}`, connection, grant.secretRef);
|
|
1674
|
+
delete state.grants[grant.id];
|
|
1675
|
+
}
|
|
1676
|
+
connection.grantId = null;
|
|
1677
|
+
for (const operation of Object.values(state.operations)) {
|
|
1678
|
+
if (operation.connectionId !== connection.id || operation.epoch !== state.epoch || operation.phase !== 'prepared')
|
|
1679
|
+
continue;
|
|
1680
|
+
finish(state, operation, 'cancelled');
|
|
1681
|
+
drop.push(...privateRefs(operation));
|
|
1682
|
+
}
|
|
1646
1683
|
}
|
|
1647
1684
|
});
|
|
1648
1685
|
return cleanupNow(connectionId);
|
|
1649
1686
|
},
|
|
1650
1687
|
retryCleanup: cleanupNow,
|
|
1688
|
+
async businessContext(connectionId) {
|
|
1689
|
+
const connection = await currentConnection(connectionId);
|
|
1690
|
+
if (!connection.sessionId || !['signed_out', 'signed_in'].includes(connection.status))
|
|
1691
|
+
throw new AuthFailure('invalid_state', connectionId);
|
|
1692
|
+
const client = await options.metadata.resolveClient(options.clientId);
|
|
1693
|
+
return { companyId: connection.issuer, companyName: connection.metadata.document.organization.name,
|
|
1694
|
+
clientId: options.clientId, userId: connection.userId,
|
|
1695
|
+
authority: connection.status === 'signed_in' && connection.grantId ? 'grant:' + connection.grantId : 'session:' + connection.sessionId,
|
|
1696
|
+
operations: supportsOperations(client) && supportsOperations(connection.metadata.document) };
|
|
1697
|
+
},
|
|
1651
1698
|
async apiTarget(connectionId, apiUrl, destination) {
|
|
1652
1699
|
const connection = await currentConnection(connectionId);
|
|
1653
1700
|
try {
|
|
@@ -1657,13 +1704,13 @@ export function createClientAuth(options) {
|
|
|
1657
1704
|
throw new AuthFailure('invalid_target', connectionId);
|
|
1658
1705
|
}
|
|
1659
1706
|
},
|
|
1660
|
-
async apiFetch(connectionId, request, target, requiredScopes = []) {
|
|
1707
|
+
async apiFetch(connectionId, request, target, requiredScopes = [], expectedContext) {
|
|
1661
1708
|
const expected = await api.apiTarget(connectionId, target.apiUrl, target.endpoint);
|
|
1662
1709
|
if (expected.kind !== target.kind || expected.issuer !== target.issuer || expected.endpoint !== target.endpoint || expected.resource !== target.resource || expected.scheme !== target.scheme)
|
|
1663
1710
|
throw new AuthFailure('invalid_target', connectionId);
|
|
1664
1711
|
if (expected.kind === 'mcp' && request.url !== expected.endpoint)
|
|
1665
1712
|
throw new AuthFailure('invalid_target', connectionId);
|
|
1666
|
-
return sendAuthorized(connectionId, request, expected, requiredScopes, expected.scheme);
|
|
1713
|
+
return sendAuthorized(connectionId, request, expected, requiredScopes, expected.scheme, true, expected.kind, expectedContext);
|
|
1667
1714
|
},
|
|
1668
1715
|
async joinBrowser(connectionId, returnTo, controller) {
|
|
1669
1716
|
const connection = await currentConnection(connectionId), endpoint = connection.metadata.document.web?.browser_session_endpoint;
|
|
@@ -1675,7 +1722,6 @@ export function createClientAuth(options) {
|
|
|
1675
1722
|
catch {
|
|
1676
1723
|
throw new AuthFailure('invalid_target', connectionId);
|
|
1677
1724
|
}
|
|
1678
|
-
const key = await loadDpopKey(connection.dpopKeyRef);
|
|
1679
1725
|
try {
|
|
1680
1726
|
const { code } = await controller.loadJoinForm(endpoint);
|
|
1681
1727
|
if (typeof code !== 'string' || !code || code.length > 256)
|
|
@@ -1683,7 +1729,13 @@ export function createClientAuth(options) {
|
|
|
1683
1729
|
const live = await currentConnection(connectionId);
|
|
1684
1730
|
if (live.generation !== connection.generation || live.dpopKeyRef !== connection.dpopKeyRef || live.status !== connection.status)
|
|
1685
1731
|
throw new Error('state changed');
|
|
1686
|
-
|
|
1732
|
+
const authorize = new URL(endpoint);
|
|
1733
|
+
authorize.pathname = authorize.pathname.replace(/\/$/, '') + '/authorize';
|
|
1734
|
+
const response = await sendAuthorized(connectionId, new Request(authorize, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ code }) }), { issuer: connection.issuer, endpoint }, []);
|
|
1735
|
+
if (response.status !== 204)
|
|
1736
|
+
throw new AuthFailure('browser_authorization_failed', connectionId);
|
|
1737
|
+
await response.body?.cancel();
|
|
1738
|
+
await controller.postForm({ url: endpoint, fields: { code }, validateRedirect(status, location) {
|
|
1687
1739
|
if (status !== 303)
|
|
1688
1740
|
throw new AuthFailure('invalid_browser_redirect', connectionId);
|
|
1689
1741
|
const destination = new URL(location, endpoint).href;
|
|
@@ -1720,11 +1772,11 @@ export function createClientAuth(options) {
|
|
|
1720
1772
|
throw new AuthFailure('excluded_tasks_unavailable', connectionId);
|
|
1721
1773
|
return options.metadata.resolveExcludedTasks(connection.metadata.document);
|
|
1722
1774
|
},
|
|
1723
|
-
async authorizedFetch(connectionId, request, target, requiredScopes = []) {
|
|
1775
|
+
async authorizedFetch(connectionId, request, target, requiredScopes = [], expectedContext) {
|
|
1724
1776
|
const connection = await currentConnection(connectionId), expected = targetOf(connection);
|
|
1725
1777
|
if (target.issuer !== expected.issuer || target.endpoint !== expected.endpoint || target.resource !== expected.resource)
|
|
1726
1778
|
throw new AuthFailure('invalid_target', connectionId);
|
|
1727
|
-
return sendAuthorized(connectionId, request, expected, requiredScopes);
|
|
1779
|
+
return sendAuthorized(connectionId, request, expected, requiredScopes, 'DPoP', true, 'conversation', expectedContext);
|
|
1728
1780
|
},
|
|
1729
1781
|
};
|
|
1730
1782
|
return api;
|