@poppykit/client 0.0.0-stage → 0.1.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 +25 -0
- package/AUTH.md +51 -0
- package/CHANGELOG.md +22 -0
- package/CONTRIBUTING.md +50 -0
- package/DELIVERY.md +113 -0
- package/INTEGRATIONS.md +87 -0
- package/LICENSE +21 -0
- package/PROVIDER.md +99 -0
- package/README.md +123 -2
- package/RELEASING.md +193 -0
- package/SECURITY.md +13 -0
- package/STORAGE.md +99 -0
- package/dist/auth/challenge.d.ts +19 -0
- package/dist/auth/challenge.js +68 -0
- package/dist/auth/client-auth.d.ts +297 -0
- package/dist/auth/client-auth.js +1583 -0
- package/dist/auth/ownership.d.ts +9 -0
- package/dist/auth/ownership.js +23 -0
- package/dist/auth/sqlite-store.d.ts +14 -0
- package/dist/auth/sqlite-store.js +123 -0
- package/dist/auth/store.d.ts +19 -0
- package/dist/auth/store.js +1 -0
- package/dist/conversation-store.d.ts +15 -0
- package/dist/conversation-store.js +1 -0
- package/dist/conversation.d.ts +118 -0
- package/dist/conversation.js +404 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +7 -0
- package/dist/integrations.d.ts +30 -0
- package/dist/integrations.js +58 -0
- package/dist/memory.d.ts +15 -0
- package/dist/memory.js +32 -0
- package/dist/network/discovery.d.ts +101 -0
- package/dist/network/discovery.js +276 -0
- package/dist/network/transport.d.ts +31 -0
- package/dist/network/transport.js +305 -0
- package/dist/protocol/sse.d.ts +15 -0
- package/dist/protocol/sse.js +90 -0
- package/dist/protocol/wire.d.ts +128 -0
- package/dist/protocol/wire.js +278 -0
- package/dist/provider-sqlite.d.ts +8 -0
- package/dist/provider-sqlite.js +30 -0
- package/dist/provider.d.ts +121 -0
- package/dist/provider.js +342 -0
- package/dist/sqlite-init.d.ts +2 -0
- package/dist/sqlite-init.js +17 -0
- package/dist/sqlite.d.ts +20 -0
- package/dist/sqlite.js +80 -0
- package/examples/quickstart.mjs +19 -0
- package/package.json +72 -4
|
@@ -0,0 +1,1583 @@
|
|
|
1
|
+
import { acquireOwner, releaseOwner, localOwnerAlive } from './ownership.js';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { calculateJwkThumbprint } from 'jose';
|
|
4
|
+
import { parseJson, parseProtocolError, parseTokenResponse, retryAfterMilliseconds } from '../protocol/wire.js';
|
|
5
|
+
import { validateClientId } from '../network/discovery.js';
|
|
6
|
+
import { dpopChallenge, schemeChallenge } from './challenge.js';
|
|
7
|
+
import { resolveApiTarget, mcpScheme, browserDestination, validateFeedback } from '../integrations.js';
|
|
8
|
+
export class AuthFailure extends Error {
|
|
9
|
+
code;
|
|
10
|
+
connectionId;
|
|
11
|
+
retryAfterMs;
|
|
12
|
+
retryAt;
|
|
13
|
+
constructor(code, connectionId, retryAfterMs, retryAt) {
|
|
14
|
+
super(`Auth ${code}`);
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.connectionId = connectionId;
|
|
17
|
+
this.retryAfterMs = retryAfterMs;
|
|
18
|
+
this.retryAt = retryAt;
|
|
19
|
+
this.name = 'AuthFailure';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const EC = { name: 'ECDSA', namedCurve: 'P-256' };
|
|
23
|
+
const ES256 = { name: 'ECDSA', hash: 'SHA-256' };
|
|
24
|
+
const SKEW_MS = 30_000;
|
|
25
|
+
// Token endpoint failures after which the Company may have acted: lost, unreadable or non-OAuth answers.
|
|
26
|
+
const AMBIGUOUS = new Set(['transport_error', 'invalid_response', 'token_error']);
|
|
27
|
+
const CLIENT_ASSERTION = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
|
|
28
|
+
const ASSERTION_SECONDS = 60;
|
|
29
|
+
// OAuth codes the token endpoint may return (spec 4.2, RFC 6749/8707); anything else is reported as token_error.
|
|
30
|
+
const SAFE_ERRORS = new Set(['invalid_request', 'invalid_client', 'invalid_grant', 'unauthorized_client', 'unsupported_grant_type', 'invalid_scope', 'invalid_target', 'invalid_session', 'account_mismatch', 'invalid_dpop_proof', 'use_dpop_nonce', 'extension_required', 'rate_limited']);
|
|
31
|
+
const b64 = (data) => Buffer.from(data).toString('base64url');
|
|
32
|
+
const randomId = (prefix = '') => prefix + b64(crypto.getRandomValues(new Uint8Array(16)));
|
|
33
|
+
const own = (record, key) => Object.hasOwn(record, key) ? record[key] : undefined;
|
|
34
|
+
const slotKey = (sessionId, resource) => JSON.stringify([sessionId, resource]);
|
|
35
|
+
const sha256 = (text) => createHash('sha256').update(text).digest('base64url');
|
|
36
|
+
const SIGN_IN_MS = 10 * 60_000;
|
|
37
|
+
const KEEP_OPERATIONS = 100;
|
|
38
|
+
// shortcut: a longer wait is still sent as asked, but we stop waiting after this hold; raise it if Companies honour longer polls.
|
|
39
|
+
const MAX_WAIT_SECONDS = 300;
|
|
40
|
+
// OAuth response parameters an authorization redirect may add to the redirect URI (RFC 6749 4.1.2, RFC 9207).
|
|
41
|
+
const CALLBACK_PARAMS = ['code', 'state', 'iss', 'error', 'error_description', 'error_uri'];
|
|
42
|
+
const AUTHORIZE_ERRORS = new Set(['invalid_request', 'unauthorized_client', 'unsupported_response_type', 'invalid_scope', 'invalid_target', 'server_error', 'temporarily_unavailable']);
|
|
43
|
+
function safeHttps(value) {
|
|
44
|
+
try {
|
|
45
|
+
const url = new URL(value);
|
|
46
|
+
return url.protocol === 'https:' && !url.username && !url.password && !url.hash && !/[\\\s\x00-\x1f]/.test(value);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function allowedHost(value, hosts) {
|
|
53
|
+
if (!safeHttps(value))
|
|
54
|
+
return false;
|
|
55
|
+
const hostname = new URL(value).hostname.toLowerCase();
|
|
56
|
+
return hosts.some(host => hostname === host.toLowerCase() || hostname.endsWith(`.${host.toLowerCase()}`));
|
|
57
|
+
}
|
|
58
|
+
// Redirect identity without the response parameters; origin alone would hide userinfo.
|
|
59
|
+
const redirectShape = (url) => JSON.stringify([url.origin, url.username, url.password, url.pathname, [...url.searchParams]]);
|
|
60
|
+
function parseCallback(callbackUrl) {
|
|
61
|
+
if (typeof callbackUrl !== 'string' || !URL.canParse(callbackUrl))
|
|
62
|
+
throw new AuthFailure('invalid_callback');
|
|
63
|
+
const base = new URL(callbackUrl);
|
|
64
|
+
if (base.hash)
|
|
65
|
+
throw new AuthFailure('invalid_callback');
|
|
66
|
+
const params = {};
|
|
67
|
+
for (const name of CALLBACK_PARAMS) {
|
|
68
|
+
const values = base.searchParams.getAll(name);
|
|
69
|
+
if (values.length > 1)
|
|
70
|
+
throw new AuthFailure('invalid_callback');
|
|
71
|
+
if (values.length)
|
|
72
|
+
params[name] = values[0];
|
|
73
|
+
base.searchParams.delete(name);
|
|
74
|
+
}
|
|
75
|
+
return { params, base };
|
|
76
|
+
}
|
|
77
|
+
export function initialClientAuthState() {
|
|
78
|
+
return { version: 1, epoch: randomId(), owner: null, identities: {}, connections: {}, grants: {}, operations: {}, cleanup: {} };
|
|
79
|
+
}
|
|
80
|
+
function publicEcJwk(value, code) {
|
|
81
|
+
const jwk = (value ?? {});
|
|
82
|
+
if (jwk.kty !== 'EC' || jwk.crv !== 'P-256' || typeof jwk.x !== 'string' || typeof jwk.y !== 'string' || 'd' in jwk)
|
|
83
|
+
throw new AuthFailure(code);
|
|
84
|
+
return { kty: 'EC', crv: 'P-256', x: jwk.x, y: jwk.y };
|
|
85
|
+
}
|
|
86
|
+
async function compact(header, payload, sign) {
|
|
87
|
+
const input = `${b64(JSON.stringify(header))}.${b64(JSON.stringify(payload))}`;
|
|
88
|
+
return `${input}.${b64(await sign(new TextEncoder().encode(input)))}`;
|
|
89
|
+
}
|
|
90
|
+
export function createClientAuth(options) {
|
|
91
|
+
const { namespace, localUserId, clientId, identity, store, transport } = options;
|
|
92
|
+
// Session Tokens live in memory only (guide "Storing tokens"); after a restart the missing token triggers a renewal.
|
|
93
|
+
const sessionTokens = new Map(); // keyed by refs this closure created, so a host ref can never match
|
|
94
|
+
const secrets = {
|
|
95
|
+
stage: secret => options.secrets.stage(secret),
|
|
96
|
+
read: async (ref) => sessionTokens.has(ref) ? sessionTokens.get(ref) : options.secrets.read(ref),
|
|
97
|
+
remove: async (ref) => { if (!sessionTokens.delete(ref))
|
|
98
|
+
await options.secrets.remove(ref); },
|
|
99
|
+
};
|
|
100
|
+
async function stageSessionToken(token) { const ref = `mem:${randomId()}`; sessionTokens.set(ref, token); return ref; }
|
|
101
|
+
const now = options.now ?? Date.now;
|
|
102
|
+
if (typeof namespace !== 'string' || !namespace || typeof localUserId !== 'string' || !localUserId)
|
|
103
|
+
throw new AuthFailure('invalid_options');
|
|
104
|
+
try {
|
|
105
|
+
validateClientId(clientId);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
throw new AuthFailure('invalid_options');
|
|
109
|
+
}
|
|
110
|
+
if ((!identity && !options.assertions) || (!!options.assertions !== !!options.providerIdentity))
|
|
111
|
+
throw new AuthFailure('invalid_options');
|
|
112
|
+
const owner = { namespace, clientId, localUserId };
|
|
113
|
+
// shortcut: nonces live in memory per origin, so a restart costs one extra use_dpop_nonce round trip.
|
|
114
|
+
const nonces = new Map();
|
|
115
|
+
function checkOwner(state) {
|
|
116
|
+
if (state.owner && (state.owner.namespace !== namespace || state.owner.clientId !== clientId || state.owner.localUserId !== localUserId))
|
|
117
|
+
throw new AuthFailure('owner_mismatch');
|
|
118
|
+
}
|
|
119
|
+
async function load(selection) { const state = await store.read(selection); checkOwner(state); return state; }
|
|
120
|
+
function transaction(selection, change) {
|
|
121
|
+
return store.transaction(selection, state => { checkOwner(state); state.owner ??= owner; return change(state); });
|
|
122
|
+
}
|
|
123
|
+
async function record(connectionId) {
|
|
124
|
+
const found = typeof connectionId === 'string' ? own((await load({ connectionId })).connections, connectionId) : undefined;
|
|
125
|
+
if (!found)
|
|
126
|
+
throw new AuthFailure('unknown_connection');
|
|
127
|
+
return found;
|
|
128
|
+
}
|
|
129
|
+
function destinations(metadata) {
|
|
130
|
+
const auth = metadata.authorization;
|
|
131
|
+
return JSON.stringify([auth?.issuer, auth?.token_endpoint, auth?.revocation_endpoint, auth?.authorization_endpoint, auth?.device_authorization_endpoint,
|
|
132
|
+
metadata.document.auth?.mediated?.endpoint, metadata.document.agent?.protocols, metadata.document.apis, metadata.document.web, metadata.document.feedback]);
|
|
133
|
+
}
|
|
134
|
+
async function verified(metadata) {
|
|
135
|
+
let fresh;
|
|
136
|
+
try {
|
|
137
|
+
fresh = await options.metadata.resolveCompany(new URL(metadata.url).hostname);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
throw new AuthFailure('discovery_failed');
|
|
141
|
+
}
|
|
142
|
+
if (destinations(fresh) !== destinations(metadata))
|
|
143
|
+
throw new AuthFailure('metadata_changed');
|
|
144
|
+
return fresh;
|
|
145
|
+
}
|
|
146
|
+
async function currentConnection(id) {
|
|
147
|
+
const found = await record(id), metadata = await verified(found.metadata);
|
|
148
|
+
return await transaction({ connectionId: id }, state => {
|
|
149
|
+
const current = own(state.connections, id);
|
|
150
|
+
if (destinations(current.metadata) !== destinations(metadata))
|
|
151
|
+
throw new AuthFailure('state_changed', id);
|
|
152
|
+
current.metadata = metadata;
|
|
153
|
+
return current;
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
function publicConnection(state, connection) {
|
|
157
|
+
const { id, domain, issuer, sessionId, generation, status, grantId } = connection;
|
|
158
|
+
const scopes = grantId === null ? [] : [...own(state.grants, grantId)?.scopes ?? []];
|
|
159
|
+
return { id, domain, issuer, organization: { ...connection.metadata.document.organization }, sessionId, generation, status, scopes };
|
|
160
|
+
}
|
|
161
|
+
async function loadDpopKey(reference) {
|
|
162
|
+
const text = await secrets.read(reference);
|
|
163
|
+
if (text === null)
|
|
164
|
+
throw new AuthFailure('missing_key');
|
|
165
|
+
// Parse errors quote the input, so a corrupted secret must never reach an error message.
|
|
166
|
+
let parsed;
|
|
167
|
+
try {
|
|
168
|
+
parsed = JSON.parse(text);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
throw new AuthFailure('missing_key');
|
|
172
|
+
}
|
|
173
|
+
const { d, ...rest } = parsed ?? {};
|
|
174
|
+
const jwk = publicEcJwk(rest, 'missing_key');
|
|
175
|
+
let privateKey;
|
|
176
|
+
try {
|
|
177
|
+
privateKey = await crypto.subtle.importKey('jwk', { ...jwk, d }, EC, false, ['sign']);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
throw new AuthFailure('missing_key');
|
|
181
|
+
}
|
|
182
|
+
return { privateKey, jwk, thumbprint: await calculateJwkThumbprint(jwk) };
|
|
183
|
+
}
|
|
184
|
+
async function signIdentity(header, payload, dpopThumbprint) {
|
|
185
|
+
if (!identity)
|
|
186
|
+
throw new AuthFailure('invalid_signer');
|
|
187
|
+
const jwk = publicEcJwk(await identity.publicJwk(), 'invalid_signer');
|
|
188
|
+
if (await calculateJwkThumbprint(jwk) === dpopThumbprint)
|
|
189
|
+
throw new AuthFailure('key_separation');
|
|
190
|
+
let key;
|
|
191
|
+
try {
|
|
192
|
+
key = await crypto.subtle.importKey('jwk', jwk, EC, false, ['verify']);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
throw new AuthFailure('invalid_signer');
|
|
196
|
+
}
|
|
197
|
+
return compact({ ...header, alg: 'ES256', kid: identity.kid }, payload, async (input) => {
|
|
198
|
+
const signature = await identity.sign(input);
|
|
199
|
+
// A signer that silently uses another key or DER encoding would produce assertions every Company rejects.
|
|
200
|
+
if (!(signature instanceof Uint8Array) || signature.length !== 64 || !await crypto.subtle.verify(ES256, key, new Uint8Array(signature), input))
|
|
201
|
+
throw new AuthFailure('invalid_signer');
|
|
202
|
+
return signature;
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
async function proof(key, method, url, token) {
|
|
206
|
+
const htu = new URL(url);
|
|
207
|
+
htu.search = '';
|
|
208
|
+
htu.hash = '';
|
|
209
|
+
const payload = { jti: randomId(), htm: method, htu: htu.href, iat: Math.floor(now() / 1000) };
|
|
210
|
+
if (token !== undefined)
|
|
211
|
+
payload.ath = b64(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token))));
|
|
212
|
+
const nonce = nonces.get(htu.origin);
|
|
213
|
+
if (nonce !== undefined)
|
|
214
|
+
payload.nonce = nonce;
|
|
215
|
+
return compact({ typ: 'dpop+jwt', alg: 'ES256', jwk: key.jwk }, payload, async (input) => new Uint8Array(await crypto.subtle.sign(ES256, key.privateKey, input)));
|
|
216
|
+
}
|
|
217
|
+
function rememberNonce(url, response) {
|
|
218
|
+
const nonce = response.headers.get('dpop-nonce');
|
|
219
|
+
if (nonce)
|
|
220
|
+
nonces.set(new URL(url).origin, nonce);
|
|
221
|
+
}
|
|
222
|
+
function assertionClaims(sub, aud) {
|
|
223
|
+
const iat = Math.floor(now() / 1000);
|
|
224
|
+
return { iss: clientId, sub, aud, iat, exp: iat + ASSERTION_SECONDS, jti: randomId() };
|
|
225
|
+
}
|
|
226
|
+
async function assertion(purpose, issuer, key, sub = clientId, operationId = randomId('op_')) {
|
|
227
|
+
if (!options.assertions)
|
|
228
|
+
return signIdentity({ typ: purpose === 'session' ? 'JWT' : 'client-authentication+jwt' }, assertionClaims(sub, issuer), key.thumbprint);
|
|
229
|
+
try {
|
|
230
|
+
const result = await options.assertions.assert({ operationId, issuer, purpose });
|
|
231
|
+
if (typeof result.assertion !== 'string' || !result.assertion || !Number.isFinite(result.expiresAt) || result.expiresAt * 1000 <= now() || result.expiresAt * 1000 > now() + ASSERTION_SECONDS * 1000)
|
|
232
|
+
throw 0;
|
|
233
|
+
return result.assertion;
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
throw new AuthFailure('provider_unavailable');
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function responseFailure(response, code) {
|
|
240
|
+
const value = response.headers.get('retry-after');
|
|
241
|
+
const delay = retryAfterMilliseconds(value, now());
|
|
242
|
+
const deadline = value && /^\d+$/.test(value) ? (BigInt(Math.floor(now())) + BigInt(value) * 1000n).toString()
|
|
243
|
+
: delay === undefined ? undefined : String(Math.floor(now() + delay));
|
|
244
|
+
return new AuthFailure(code, undefined, delay, deadline);
|
|
245
|
+
}
|
|
246
|
+
// Token endpoint request with client assertion and fresh DPoP proof (spec 4.1-4.3); sessionSub adds a session assertion.
|
|
247
|
+
// Throws transport_error only when the request may have reached the issuer without a readable answer.
|
|
248
|
+
async function tokenRequest(metadata, dpopKeyRef, fields, sessionSub, localIdentity = false) {
|
|
249
|
+
const authorization = (await verified(metadata)).authorization;
|
|
250
|
+
const endpoint = authorization.token_endpoint;
|
|
251
|
+
const key = await loadDpopKey(dpopKeyRef);
|
|
252
|
+
const scheme = await resourceScheme(metadata, fields.resource ?? null);
|
|
253
|
+
for (let attempt = 0;; attempt++) {
|
|
254
|
+
const body = new URLSearchParams(fields);
|
|
255
|
+
if (sessionSub !== null)
|
|
256
|
+
body.set('assertion', localIdentity ? await signIdentity({ typ: 'JWT' }, assertionClaims(sessionSub, authorization.issuer), key.thumbprint) : await assertion('session', authorization.issuer, key, sessionSub));
|
|
257
|
+
body.set('client_id', clientId);
|
|
258
|
+
body.set('client_assertion_type', CLIENT_ASSERTION);
|
|
259
|
+
body.set('client_assertion', localIdentity ? await signIdentity({ typ: 'client-authentication+jwt' }, assertionClaims(clientId, authorization.issuer), key.thumbprint) : await assertion('client-authentication', authorization.issuer, key));
|
|
260
|
+
const request = new Request(endpoint, { method: 'POST', body, headers: { accept: 'application/json', dpop: await proof(key, 'POST', endpoint) } });
|
|
261
|
+
let response, text;
|
|
262
|
+
try {
|
|
263
|
+
response = await transport.fetch(request);
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
throw new AuthFailure('transport_error');
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
text = await response.text();
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
throw responseFailure(response, 'transport_error');
|
|
273
|
+
}
|
|
274
|
+
rememberNonce(endpoint, response);
|
|
275
|
+
let json;
|
|
276
|
+
try {
|
|
277
|
+
json = parseJson(text);
|
|
278
|
+
}
|
|
279
|
+
catch { /* reported below */ }
|
|
280
|
+
if (response.status === 200) {
|
|
281
|
+
try {
|
|
282
|
+
return { token: parseTokenResponse(json), scheme };
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
throw new AuthFailure('invalid_response');
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
let error = response.status === 429 ? 'rate_limited' : 'token_error';
|
|
289
|
+
try {
|
|
290
|
+
if (response.status !== 429)
|
|
291
|
+
error = parseProtocolError(json).error;
|
|
292
|
+
}
|
|
293
|
+
catch { /* non-OAuth body */ }
|
|
294
|
+
if (error === 'use_dpop_nonce' && response.status === 400 && attempt === 0 && response.headers.has('dpop-nonce'))
|
|
295
|
+
continue;
|
|
296
|
+
// Only a recognised OAuth code on a 4xx is a definite rejection; a 5xx or proxy page may follow a completed exchange.
|
|
297
|
+
throw responseFailure(response, response.status >= 400 && response.status < 500 && (SAFE_ERRORS.has(error) || ['authorization_pending', 'slow_down', 'access_denied', 'expired_token'].includes(error) || response.status === 429) ? error : 'token_error');
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
async function resourceScheme(metadata, resource) {
|
|
301
|
+
const mcp = metadata.document.apis?.find(api => api.type === 'mcp' && api.url === resource);
|
|
302
|
+
if (!mcp)
|
|
303
|
+
return 'DPoP';
|
|
304
|
+
if (mcp.resource !== undefined && mcp.resource !== mcp.url)
|
|
305
|
+
throw new AuthFailure('invalid_target');
|
|
306
|
+
try {
|
|
307
|
+
const scheme = await mcpScheme(transport, metadata.authorization.issuer, mcp.url);
|
|
308
|
+
// Bearer MCP tokens cannot authorize a conversation, even when discovery reuses their resource identifier.
|
|
309
|
+
if (scheme === 'Bearer' && metadata.document.agent?.protocols.some(protocol => protocol.resource === resource))
|
|
310
|
+
throw 0;
|
|
311
|
+
return scheme;
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
throw new AuthFailure('invalid_target');
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
// Signed-out Session Token via the JWT bearer grant (spec 4.2); renewal passes session_id.
|
|
318
|
+
async function signedOutToken(metadata, userId, dpopKeyRef, sessionId, resource, anonymous = false) {
|
|
319
|
+
const fields = { grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer' };
|
|
320
|
+
if (sessionId !== null)
|
|
321
|
+
fields.session_id = sessionId;
|
|
322
|
+
if (resource !== null)
|
|
323
|
+
fields.resource = resource;
|
|
324
|
+
const { token, scheme } = await tokenRequest(metadata, dpopKeyRef, fields, userId, anonymous);
|
|
325
|
+
if (token.token_type !== scheme)
|
|
326
|
+
throw new AuthFailure('unsupported_token_type');
|
|
327
|
+
if (token.signed_in !== false || token.scope !== '' || (sessionId !== null && token.session_id !== sessionId))
|
|
328
|
+
throw new AuthFailure('invalid_response');
|
|
329
|
+
return token;
|
|
330
|
+
}
|
|
331
|
+
async function identityFor(issuer, anonymous = false) {
|
|
332
|
+
if (anonymous && !identity)
|
|
333
|
+
throw new AuthFailure('anonymous_unavailable');
|
|
334
|
+
const existing = anonymous ? undefined : own((await load({ issuer })).identities, issuer);
|
|
335
|
+
if (existing)
|
|
336
|
+
return existing;
|
|
337
|
+
// shortcut: extractable key, because the SecretStore holds the private JWK as a string and Node cannot persist a non-extractable CryptoKey; move to a KMS/OS-keystore signer when the host platform offers one.
|
|
338
|
+
const pair = await crypto.subtle.generateKey(EC, true, ['sign', 'verify']);
|
|
339
|
+
const { kty, crv, x, y, d } = await crypto.subtle.exportKey('jwk', pair.privateKey);
|
|
340
|
+
const dpopKeyRef = await secrets.stage(JSON.stringify({ kty, crv, x, y, d }));
|
|
341
|
+
let userId = randomId('usr_');
|
|
342
|
+
if (options.providerIdentity && !anonymous) {
|
|
343
|
+
try {
|
|
344
|
+
const bound = await options.providerIdentity.identity(issuer);
|
|
345
|
+
if (bound.clientId !== clientId || typeof bound.userId !== 'string' || !bound.userId)
|
|
346
|
+
throw 0;
|
|
347
|
+
userId = bound.userId;
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
await secrets.remove(dpopKeyRef);
|
|
351
|
+
throw new AuthFailure('provider_unavailable');
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (anonymous)
|
|
355
|
+
return { userId, dpopKeyRef };
|
|
356
|
+
const winner = await transaction({ issuer }, state => own(state.identities, issuer) ?? (state.identities[issuer] = { userId, dpopKeyRef }));
|
|
357
|
+
// A concurrent start won; our staged key was never referenced by any record.
|
|
358
|
+
if (winner.dpopKeyRef !== dpopKeyRef)
|
|
359
|
+
await secrets.remove(dpopKeyRef);
|
|
360
|
+
return winner;
|
|
361
|
+
}
|
|
362
|
+
// Commits a change that may stage or release secrets. When our own fence check rejects it, nothing references the
|
|
363
|
+
// staged secrets, so they are removed; references the committed change pushed to `drop` are removed after commit.
|
|
364
|
+
// shortcut: a crash between stage() and commit leaves an unreferenced secret; SecretStore cannot list, so add a sweep when D offers one.
|
|
365
|
+
async function commit(selection, staged, change) {
|
|
366
|
+
const drop = [];
|
|
367
|
+
let result;
|
|
368
|
+
try {
|
|
369
|
+
result = await transaction(selection, state => { drop.length = 0; return change(state, drop); });
|
|
370
|
+
}
|
|
371
|
+
catch (error) {
|
|
372
|
+
if (error instanceof AuthFailure && error.code === 'state_changed')
|
|
373
|
+
for (const ref of staged)
|
|
374
|
+
if (ref !== null)
|
|
375
|
+
await secrets.remove(ref);
|
|
376
|
+
throw error;
|
|
377
|
+
}
|
|
378
|
+
for (const ref of drop)
|
|
379
|
+
await secrets.remove(ref);
|
|
380
|
+
return result;
|
|
381
|
+
}
|
|
382
|
+
// Credentials of an ended Session are unusable for every resource, so all of its slots go with it.
|
|
383
|
+
function switchSession(connection, sessionId, drop) {
|
|
384
|
+
if (connection.sessionId === sessionId)
|
|
385
|
+
return;
|
|
386
|
+
drop.push(...Object.values(connection.slots).map(slot => slot.secretRef));
|
|
387
|
+
connection.slots = {};
|
|
388
|
+
connection.sessionId = sessionId;
|
|
389
|
+
}
|
|
390
|
+
function replaceSlot(connection, resource, credential, drop) {
|
|
391
|
+
const key = slotKey(connection.sessionId, resource);
|
|
392
|
+
const old = own(connection.slots, key);
|
|
393
|
+
if (old)
|
|
394
|
+
drop.push(old.secretRef);
|
|
395
|
+
connection.slots[key] = credential;
|
|
396
|
+
}
|
|
397
|
+
// Completed renewals are only history; uncertain ones stay because they decide the cleanup status.
|
|
398
|
+
function pruneRenewals(state, connectionId) {
|
|
399
|
+
for (const op of Object.values(state.operations))
|
|
400
|
+
if (op.kind === 'renew' && op.connectionId === connectionId && op.phase === 'terminal' && op.status !== 'uncertain')
|
|
401
|
+
delete state.operations[op.id];
|
|
402
|
+
}
|
|
403
|
+
// Terminal start/sign-in results only answer late duplicate callbacks, so keep the newest few; uncertain ones decide the cleanup status.
|
|
404
|
+
function pruneHistory(state) {
|
|
405
|
+
// Newest first; reverse() keeps insertion order as the tie-break for equal timestamps.
|
|
406
|
+
const done = Object.values(state.operations).reverse().filter(op => op.kind !== 'renew' && op.phase === 'terminal' && op.status !== 'uncertain').sort((a, b) => b.createdAt - a.createdAt);
|
|
407
|
+
for (const op of done.slice(KEEP_OPERATIONS))
|
|
408
|
+
delete state.operations[op.id];
|
|
409
|
+
}
|
|
410
|
+
function renewOperation(state, id, connection, grant, at, resource, scopes) {
|
|
411
|
+
pruneRenewals(state, connection.id);
|
|
412
|
+
return state.operations[id] = {
|
|
413
|
+
epoch: state.epoch, id, kind: 'renew', connectionId: connection.id, connectionGeneration: connection.generation, grantId: grant?.id ?? null, grantGeneration: grant?.generation ?? null,
|
|
414
|
+
phase: 'prepared', status: 'pending', createdAt: at, expiresAt: at, resource, scopes, direct: null, staged: null, result: null,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
async function signedOutRenewal(connection, resource) {
|
|
418
|
+
// Expiry counts from before dispatch so a slow response never extends the token's believed lifetime.
|
|
419
|
+
const issuedAt = now(), operationId = randomId('op_');
|
|
420
|
+
let token;
|
|
421
|
+
try {
|
|
422
|
+
token = await signedOutToken(connection.metadata, connection.userId, connection.dpopKeyRef, connection.sessionId, resource, connection.anonymous);
|
|
423
|
+
}
|
|
424
|
+
catch (error) {
|
|
425
|
+
// The Session ended (spec 4.2): start a new signed-out one. Signed-out renewal never touches an account.
|
|
426
|
+
if (!(error instanceof AuthFailure && error.code === 'invalid_session'))
|
|
427
|
+
throw error;
|
|
428
|
+
token = await signedOutToken(connection.metadata, connection.userId, connection.dpopKeyRef, null, resource, connection.anonymous);
|
|
429
|
+
}
|
|
430
|
+
const secretRef = await stageSessionToken(token.access_token);
|
|
431
|
+
return commit({ connectionId: connection.id }, [secretRef], (state, drop) => {
|
|
432
|
+
const current = own(state.connections, connection.id);
|
|
433
|
+
if (!current || current.generation !== connection.generation || current.status !== 'signed_out' || current.sessionId !== connection.sessionId)
|
|
434
|
+
throw new AuthFailure('state_changed', connection.id);
|
|
435
|
+
switchSession(current, token.session_id, drop);
|
|
436
|
+
replaceSlot(current, resource, { secretRef, tokenType: token.token_type, dpopKeyRef: current.dpopKeyRef, scopes: [], expiresAt: issuedAt + token.expires_in * 1000, generation: current.generation }, drop);
|
|
437
|
+
return finish(state, renewOperation(state, operationId, current, null, issuedAt, resource, []), 'completed');
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
// Account Token refresh (spec 4.8). The grant claim allows one rotation in flight per grant across processes,
|
|
441
|
+
// because a second sender could present a token the first rotation already replaced.
|
|
442
|
+
async function refresh(connection, resource, scopes) {
|
|
443
|
+
const executionOwner = acquireOwner();
|
|
444
|
+
try {
|
|
445
|
+
const at = now(), operationId = randomId('op_');
|
|
446
|
+
const claim = await transaction({ connectionId: connection.id }, state => {
|
|
447
|
+
const current = own(state.connections, connection.id);
|
|
448
|
+
const grant = current.grantId === null ? undefined : own(state.grants, current.grantId);
|
|
449
|
+
if (current.status !== 'signed_in' || !grant)
|
|
450
|
+
throw new AuthFailure('state_changed', connection.id);
|
|
451
|
+
const running = grant.operationId === null ? undefined : own(state.operations, grant.operationId);
|
|
452
|
+
if (running && running.phase !== 'terminal')
|
|
453
|
+
return { running: outcomeOf(state, running) };
|
|
454
|
+
if (grant.status !== 'active')
|
|
455
|
+
throw new AuthFailure('grant_uncertain', connection.id);
|
|
456
|
+
if (scopes && !scopes.every(scope => grant.scopes.includes(scope)))
|
|
457
|
+
throw new AuthFailure('invalid_scope', connection.id);
|
|
458
|
+
grant.operationId = operationId;
|
|
459
|
+
const operation = renewOperation(state, operationId, current, grant, at, resource, scopes ?? [...grant.scopes]);
|
|
460
|
+
operation.owner = executionOwner;
|
|
461
|
+
return { running: null, connection: current, secretRef: grant.secretRef, requested: operation.scopes };
|
|
462
|
+
});
|
|
463
|
+
if (claim.running)
|
|
464
|
+
return claim.running;
|
|
465
|
+
let refreshToken;
|
|
466
|
+
try {
|
|
467
|
+
refreshToken = await secrets.read(claim.secretRef);
|
|
468
|
+
}
|
|
469
|
+
catch {
|
|
470
|
+
return await endRefresh(operationId, 'failed', 'storage_error');
|
|
471
|
+
}
|
|
472
|
+
if (refreshToken === null)
|
|
473
|
+
return await endRefresh(operationId, 'failed', 'missing_credential');
|
|
474
|
+
const sessionId = claim.connection.sessionId;
|
|
475
|
+
const fields = { grant_type: 'refresh_token', refresh_token: refreshToken, session_id: sessionId };
|
|
476
|
+
if (resource !== null)
|
|
477
|
+
fields.resource = resource;
|
|
478
|
+
if (scopes)
|
|
479
|
+
fields.scope = scopes.join(' ');
|
|
480
|
+
// The last local step before the send; a crash before it sent nothing, so recover just releases the grant.
|
|
481
|
+
const dispatched = await transaction({ operationId }, state => {
|
|
482
|
+
const operation = own(state.operations, operationId);
|
|
483
|
+
if (!operation || operation.epoch !== state.epoch || operation.phase !== 'prepared')
|
|
484
|
+
return false;
|
|
485
|
+
operation.phase = 'dispatched';
|
|
486
|
+
operation.owner = executionOwner;
|
|
487
|
+
return true;
|
|
488
|
+
});
|
|
489
|
+
if (!dispatched)
|
|
490
|
+
return await endRefresh(operationId, 'failed', 'state_changed');
|
|
491
|
+
const issuedAt = now();
|
|
492
|
+
const send = () => tokenRequest(claim.connection.metadata, claim.connection.dpopKeyRef, fields, null);
|
|
493
|
+
let acquired, newSession = false;
|
|
494
|
+
try {
|
|
495
|
+
try {
|
|
496
|
+
acquired = await send();
|
|
497
|
+
}
|
|
498
|
+
catch (error) {
|
|
499
|
+
// A definite invalid_session (a 4xx OAuth code) left the Account Token unused, so this is a new request, not a resend:
|
|
500
|
+
// without session_id the Company starts a new Session signed in to the same account (spec 4.8).
|
|
501
|
+
if (!(error instanceof AuthFailure && error.code === 'invalid_session'))
|
|
502
|
+
throw error;
|
|
503
|
+
delete fields.session_id;
|
|
504
|
+
newSession = true;
|
|
505
|
+
acquired = await send();
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
catch (error) {
|
|
509
|
+
if (error instanceof AuthFailure && error.retryAfterMs !== undefined)
|
|
510
|
+
await holdOff(connection.id, error);
|
|
511
|
+
const code = error instanceof AuthFailure ? error.code : 'local_error'; // non-AuthFailure errors come from local signing before dispatch
|
|
512
|
+
// A lost, unreadable or non-OAuth answer may follow a rotation, so the old Account Token is never resent (IMPLEMENTATION 4).
|
|
513
|
+
if (AMBIGUOUS.has(code))
|
|
514
|
+
return await endRefresh(operationId, 'uncertain', 'lost_response');
|
|
515
|
+
return await endRefresh(operationId, 'failed', code);
|
|
516
|
+
}
|
|
517
|
+
const { token, scheme } = acquired;
|
|
518
|
+
const granted = token.scope.split(' ').filter(Boolean);
|
|
519
|
+
let problem = null;
|
|
520
|
+
if (token.token_type !== scheme)
|
|
521
|
+
problem = 'unsupported_token_type';
|
|
522
|
+
else if (token.signed_in !== true || (newSession ? token.session_id === sessionId : token.session_id !== sessionId) || granted.some(scope => !claim.requested.includes(scope)))
|
|
523
|
+
problem = 'invalid_response';
|
|
524
|
+
let refreshRef = null, accessRef = null;
|
|
525
|
+
try {
|
|
526
|
+
if (token.refresh_token !== undefined)
|
|
527
|
+
refreshRef = await secrets.stage(token.refresh_token);
|
|
528
|
+
}
|
|
529
|
+
catch {
|
|
530
|
+
return await endRefresh(operationId, 'uncertain', 'storage_error');
|
|
531
|
+
}
|
|
532
|
+
if (!problem)
|
|
533
|
+
try {
|
|
534
|
+
accessRef = await stageSessionToken(token.access_token);
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
problem = 'storage_error';
|
|
538
|
+
}
|
|
539
|
+
if (problem || accessRef === null)
|
|
540
|
+
return await endRefresh(operationId, 'failed', problem ?? 'storage_error', refreshRef);
|
|
541
|
+
const staged = {
|
|
542
|
+
accessRef, refreshRef, tokenType: token.token_type, sessionId: token.session_id, scopes: granted, signedIn: true, expiresAt: issuedAt + token.expires_in * 1000,
|
|
543
|
+
refreshExpiresAt: token.refresh_token_expires_in === undefined ? null : issuedAt + token.refresh_token_expires_in * 1000,
|
|
544
|
+
};
|
|
545
|
+
// Its own commit, so a crash before activation leaves the new Account Token referenced for recover (interface 4).
|
|
546
|
+
const recorded = await transaction({ operationId }, state => {
|
|
547
|
+
const operation = own(state.operations, operationId);
|
|
548
|
+
if (!operation || operation.epoch !== state.epoch || operation.phase !== 'dispatched')
|
|
549
|
+
return false;
|
|
550
|
+
operation.phase = 'staged';
|
|
551
|
+
operation.staged = staged;
|
|
552
|
+
return true;
|
|
553
|
+
});
|
|
554
|
+
if (!recorded)
|
|
555
|
+
return await endRefresh(operationId, 'failed', 'state_changed', refreshRef, accessRef);
|
|
556
|
+
return await commit({ operationId }, [accessRef, refreshRef], (state, drop) => {
|
|
557
|
+
const operation = own(state.operations, operationId);
|
|
558
|
+
if (!operation || operation.epoch !== state.epoch)
|
|
559
|
+
throw new AuthFailure('state_changed');
|
|
560
|
+
return operation.phase === 'staged' ? activateRefresh(state, operation, drop) : outcomeOf(state, operation);
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
finally {
|
|
564
|
+
releaseOwner(executionOwner);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
// Ends a refresh that will not activate. A returned Account Token is parked for revocation.
|
|
568
|
+
function endRefresh(operationId, status, error, refreshRef = null, accessRef = null) {
|
|
569
|
+
return commit({ operationId }, [refreshRef, accessRef], (state, drop) => {
|
|
570
|
+
const operation = own(state.operations, operationId);
|
|
571
|
+
if (!operation || operation.epoch !== state.epoch)
|
|
572
|
+
throw new AuthFailure('state_changed');
|
|
573
|
+
if (accessRef !== null)
|
|
574
|
+
drop.push(accessRef);
|
|
575
|
+
if (refreshRef !== null)
|
|
576
|
+
retire(state, `cln_${operationId}`, own(state.connections, operation.connectionId), refreshRef);
|
|
577
|
+
return operation.phase === 'terminal' ? outcomeOf(state, operation) : closeRefresh(state, operation, status, error, refreshRef !== null, drop);
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
function closeRefresh(state, operation, status, error, rotated, drop) {
|
|
581
|
+
const connection = own(state.connections, operation.connectionId);
|
|
582
|
+
const grant = operation.grantId === null ? undefined : own(state.grants, operation.grantId);
|
|
583
|
+
if (grant?.operationId === operation.id) {
|
|
584
|
+
grant.operationId = null;
|
|
585
|
+
if (connection.grantId === grant.id && connection.generation === operation.connectionGeneration) {
|
|
586
|
+
if (error === 'invalid_grant') {
|
|
587
|
+
// Expired or revoked: the Session continues signed out and the User signs in again only on request (spec 4.8, 4.9).
|
|
588
|
+
drop.push(grant.secretRef, ...Object.values(connection.slots).map(slot => slot.secretRef));
|
|
589
|
+
delete state.grants[grant.id];
|
|
590
|
+
connection.grantId = null;
|
|
591
|
+
connection.status = 'signed_out';
|
|
592
|
+
connection.slots = {};
|
|
593
|
+
}
|
|
594
|
+
else if (status === 'uncertain' || rotated) {
|
|
595
|
+
// The Account Token we hold may already be replaced; only a fresh sign-in is safe.
|
|
596
|
+
grant.status = 'uncertain';
|
|
597
|
+
connection.status = 'uncertain';
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return finish(state, operation, status, { error });
|
|
602
|
+
}
|
|
603
|
+
function activateRefresh(state, operation, drop) {
|
|
604
|
+
const staged = operation.staged;
|
|
605
|
+
const connection = own(state.connections, operation.connectionId);
|
|
606
|
+
const grant = operation.grantId === null ? undefined : own(state.grants, operation.grantId);
|
|
607
|
+
if (!grant || grant.operationId !== operation.id || grant.generation !== operation.grantGeneration || connection.grantId !== grant.id || connection.generation !== operation.connectionGeneration) {
|
|
608
|
+
// A sign-out or replacement won the race; the late Account Token cannot activate and awaits revocation.
|
|
609
|
+
if (staged.refreshRef !== null)
|
|
610
|
+
retire(state, `cln_${operation.id}`, connection, staged.refreshRef);
|
|
611
|
+
drop.push(staged.accessRef);
|
|
612
|
+
if (grant?.operationId === operation.id)
|
|
613
|
+
grant.operationId = null;
|
|
614
|
+
return finish(state, operation, 'failed', { error: 'state_changed' });
|
|
615
|
+
}
|
|
616
|
+
grant.operationId = null;
|
|
617
|
+
grant.generation++;
|
|
618
|
+
if (staged.refreshRef !== null) {
|
|
619
|
+
// The Company rotated the Account Token; the old one is dead and is never sent again (spec 4.8).
|
|
620
|
+
drop.push(grant.secretRef);
|
|
621
|
+
grant.secretRef = staged.refreshRef;
|
|
622
|
+
if (staged.refreshExpiresAt !== null)
|
|
623
|
+
grant.expiresAt = staged.refreshExpiresAt;
|
|
624
|
+
}
|
|
625
|
+
switchSession(connection, staged.sessionId, drop);
|
|
626
|
+
// Only this resource's credential changes; other resources' credentials survive (IMPLEMENTATION 3).
|
|
627
|
+
replaceSlot(connection, operation.resource, { secretRef: staged.accessRef, tokenType: staged.tokenType ?? 'DPoP', dpopKeyRef: connection.dpopKeyRef, scopes: staged.scopes, expiresAt: staged.expiresAt, generation: connection.generation }, drop);
|
|
628
|
+
return finish(state, operation, 'completed');
|
|
629
|
+
}
|
|
630
|
+
// Decimal absolute milliseconds preserve even Retry-After values beyond safe JS integers.
|
|
631
|
+
async function holdOff(connectionId, error) {
|
|
632
|
+
if (error.retryAt === undefined)
|
|
633
|
+
return;
|
|
634
|
+
await transaction({ connectionId }, state => {
|
|
635
|
+
const connection = own(state.connections, connectionId);
|
|
636
|
+
if (BigInt(error.retryAt) > BigInt(connection.notBefore ?? '0'))
|
|
637
|
+
connection.notBefore = error.retryAt;
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
function checkCooldown(connection) {
|
|
641
|
+
const wait = BigInt(connection.notBefore ?? '0') - BigInt(Math.floor(now()));
|
|
642
|
+
if (wait > 0n)
|
|
643
|
+
throw new AuthFailure('rate_limited', connection.id, Number(wait), connection.notBefore);
|
|
644
|
+
}
|
|
645
|
+
// Every token-request path (internal refresh and public renew) goes through here, so all honour Retry-After.
|
|
646
|
+
async function renewal(connection, resource, scopes) {
|
|
647
|
+
// Local failures first: a wait is only meaningful for a connection that can still send a token request.
|
|
648
|
+
if (connection.status === 'signed_out' && scopes?.length)
|
|
649
|
+
throw new AuthFailure('invalid_scope', connection.id);
|
|
650
|
+
if (connection.status !== 'signed_out' && connection.status !== 'signed_in')
|
|
651
|
+
throw new AuthFailure(connection.status === 'disconnected' ? 'disconnected' : 'grant_uncertain', connection.id);
|
|
652
|
+
checkCooldown(await record(connection.id));
|
|
653
|
+
let outcome;
|
|
654
|
+
try {
|
|
655
|
+
outcome = connection.status === 'signed_out' ? await signedOutRenewal(connection, resource) : await refresh(connection, resource, scopes);
|
|
656
|
+
}
|
|
657
|
+
catch (error) {
|
|
658
|
+
if (error instanceof AuthFailure && error.retryAfterMs !== undefined)
|
|
659
|
+
await holdOff(connection.id, error);
|
|
660
|
+
throw error;
|
|
661
|
+
}
|
|
662
|
+
const deadline = (await record(connection.id)).notBefore;
|
|
663
|
+
return deadline === undefined ? outcome : { ...outcome, retryAfterMs: Math.max(0, Number(BigInt(deadline) - BigInt(Math.floor(now())))) };
|
|
664
|
+
}
|
|
665
|
+
// In-process single flight: parallel requests on one stale slot share one renewal; the grant claim covers other processes.
|
|
666
|
+
const renewing = new Map();
|
|
667
|
+
function renewShared(connectionId, resource) {
|
|
668
|
+
const key = JSON.stringify([connectionId, resource]);
|
|
669
|
+
let pending = renewing.get(key);
|
|
670
|
+
if (!pending) {
|
|
671
|
+
pending = (async () => {
|
|
672
|
+
const outcome = await renewal(await record(connectionId), resource, null);
|
|
673
|
+
if (outcome.status !== 'completed')
|
|
674
|
+
throw new AuthFailure(outcome.status === 'pending' ? 'renewal_pending' : outcome.error ?? 'renewal_failed', connectionId, outcome.retryAfterMs);
|
|
675
|
+
const slot = own((await record(connectionId)).slots, slotKey(outcome.connection.sessionId, resource));
|
|
676
|
+
if (!slot)
|
|
677
|
+
throw new AuthFailure('state_changed', connectionId);
|
|
678
|
+
return slot;
|
|
679
|
+
})().finally(() => renewing.delete(key));
|
|
680
|
+
renewing.set(key, pending);
|
|
681
|
+
}
|
|
682
|
+
return pending;
|
|
683
|
+
}
|
|
684
|
+
// A valid current credential, or a renewal; `stale` skips a credential the Company rejected or a renewal removed.
|
|
685
|
+
async function credentialFor(connectionId, resource, stale) {
|
|
686
|
+
const connection = await record(connectionId);
|
|
687
|
+
if (connection.status === 'disconnected')
|
|
688
|
+
throw new AuthFailure('disconnected', connectionId);
|
|
689
|
+
const slot = own(connection.slots, slotKey(connection.sessionId, resource));
|
|
690
|
+
return slot && slot.secretRef !== stale && slot.expiresAt - SKEW_MS > now() ? slot : renewShared(connectionId, resource);
|
|
691
|
+
}
|
|
692
|
+
function cleanupStatus(state, connectionId) {
|
|
693
|
+
const operations = Object.values(state.operations).filter(op => op.connectionId === connectionId && op.epoch === state.epoch);
|
|
694
|
+
const items = Object.values(state.cleanup).filter(item => item.connectionId === connectionId);
|
|
695
|
+
// A lost exchange or rotation may have issued an Account Token we never saw, so it can never be claimed revoked.
|
|
696
|
+
if (operations.some(op => op.status === 'uncertain') || items.some(item => item.status === 'uncertain'))
|
|
697
|
+
return 'uncertain';
|
|
698
|
+
return items.length || operations.some(op => op.phase === 'dispatched' || op.phase === 'staged') ? 'pending' : 'complete';
|
|
699
|
+
}
|
|
700
|
+
// RFC 7009 revocation with a fresh client assertion (spec 4.9). Replay is safe, so anything but 200 stays pending.
|
|
701
|
+
async function revoke(connection, item, token) {
|
|
702
|
+
try {
|
|
703
|
+
if (BigInt(item.notBefore ?? '0') > BigInt(Math.floor(now())))
|
|
704
|
+
return false;
|
|
705
|
+
const key = await loadDpopKey(connection.dpopKeyRef);
|
|
706
|
+
await verified(connection.metadata);
|
|
707
|
+
const body = new URLSearchParams({ token, token_type_hint: 'refresh_token', client_id: clientId, client_assertion_type: CLIENT_ASSERTION, client_assertion: await assertion('client-authentication', item.issuer, key) });
|
|
708
|
+
const response = await transport.fetch(new Request(item.revocationEndpoint, { method: 'POST', body, headers: { accept: 'application/json' } }));
|
|
709
|
+
if (response.status === 429 || response.status === 503) {
|
|
710
|
+
const deadline = responseFailure(response, 'rate_limited').retryAt;
|
|
711
|
+
if (deadline)
|
|
712
|
+
await transaction({ connectionId: connection.id }, state => {
|
|
713
|
+
const current = own(state.cleanup, item.id);
|
|
714
|
+
if (current?.secretRef === item.secretRef && BigInt(deadline) > BigInt(current.notBefore ?? '0'))
|
|
715
|
+
current.notBefore = deadline;
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
await response.body?.cancel();
|
|
719
|
+
return response.status === 200;
|
|
720
|
+
}
|
|
721
|
+
catch {
|
|
722
|
+
return false;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
async function cleanupNow(connectionId) {
|
|
726
|
+
const connection = await record(connectionId);
|
|
727
|
+
const items = Object.values((await load({ connectionId })).cleanup).filter(item => item.connectionId === connectionId && item.status === 'pending');
|
|
728
|
+
for (const item of items) {
|
|
729
|
+
const token = await secrets.read(item.secretRef);
|
|
730
|
+
const revoked = token !== null && await revoke(connection, item, token);
|
|
731
|
+
if (token !== null && !revoked)
|
|
732
|
+
continue;
|
|
733
|
+
await commit({ connectionId }, [], (state, drop) => {
|
|
734
|
+
const current = own(state.cleanup, item.id);
|
|
735
|
+
if (current?.secretRef !== item.secretRef)
|
|
736
|
+
return;
|
|
737
|
+
if (revoked) {
|
|
738
|
+
delete state.cleanup[item.id];
|
|
739
|
+
drop.push(item.secretRef);
|
|
740
|
+
}
|
|
741
|
+
else
|
|
742
|
+
current.status = 'uncertain';
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
const state = await load({ connectionId });
|
|
746
|
+
return { connection: publicConnection(state, own(state.connections, connectionId)), cleanup: cleanupStatus(state, connectionId) };
|
|
747
|
+
}
|
|
748
|
+
function targetOf(connection) {
|
|
749
|
+
const entry = connection.metadata.document.agent?.protocols.find(protocol => protocol.type === 'poppy');
|
|
750
|
+
if (!entry)
|
|
751
|
+
throw new AuthFailure('no_conversation', connection.id);
|
|
752
|
+
return { issuer: connection.issuer, endpoint: entry.endpoint, ...(entry.resource === undefined ? {} : { resource: entry.resource }) };
|
|
753
|
+
}
|
|
754
|
+
function outcomeOf(state, operation) {
|
|
755
|
+
return operation.result ?? { operationId: operation.id, status: 'pending', connection: publicConnection(state, own(state.connections, operation.connectionId)), nextAction: 'wait' };
|
|
756
|
+
}
|
|
757
|
+
// The terminal result is recorded once and returned verbatim to duplicate callbacks.
|
|
758
|
+
function finish(state, operation, status, extra = {}) {
|
|
759
|
+
operation.phase = 'terminal';
|
|
760
|
+
operation.status = status;
|
|
761
|
+
const result = { operationId: operation.id, status, connection: publicConnection(state, own(state.connections, operation.connectionId)), ...extra };
|
|
762
|
+
if (status === 'failed' || status === 'expired' || status === 'uncertain')
|
|
763
|
+
result.nextAction = 'sign_in';
|
|
764
|
+
return operation.result = result;
|
|
765
|
+
}
|
|
766
|
+
// An Account Token that will not be used stays private until retryCleanup revokes it.
|
|
767
|
+
function retire(state, id, connection, secretRef) {
|
|
768
|
+
state.cleanup[id] = { id, connectionId: connection.id, issuer: connection.issuer, revocationEndpoint: connection.metadata.authorization.revocation_endpoint, secretRef, status: 'pending' };
|
|
769
|
+
}
|
|
770
|
+
// Records a terminal sign-in result if the exchange still owns the operation; a recovery that ran meanwhile wins.
|
|
771
|
+
async function settle(operationId, verifierRef, status, extra, unusedGrantRef = null) {
|
|
772
|
+
const outcome = await transaction({ operationId }, state => {
|
|
773
|
+
const operation = own(state.operations, operationId);
|
|
774
|
+
if (!operation || operation.epoch !== state.epoch)
|
|
775
|
+
throw new AuthFailure('state_changed');
|
|
776
|
+
if (unusedGrantRef !== null)
|
|
777
|
+
retire(state, `cln_${operationId}`, own(state.connections, operation.connectionId), unusedGrantRef);
|
|
778
|
+
return operation.phase === 'dispatched' ? finish(state, operation, operation.cancelRequested && status !== 'uncertain' ? 'cancelled' : status, extra) : outcomeOf(state, operation);
|
|
779
|
+
});
|
|
780
|
+
// Terminal operations never read their verifier again.
|
|
781
|
+
await secrets.remove(verifierRef);
|
|
782
|
+
return outcome;
|
|
783
|
+
}
|
|
784
|
+
function privateRefs(operation) {
|
|
785
|
+
return [...(operation.direct ? [operation.direct.verifierRef] : []), ...(operation.device ? [operation.device.codeRef] : [])];
|
|
786
|
+
}
|
|
787
|
+
async function terminate(operationId, status, error) {
|
|
788
|
+
return commit({ operationId }, [], (state, drop) => {
|
|
789
|
+
const operation = own(state.operations, operationId);
|
|
790
|
+
if (operation.phase === 'terminal')
|
|
791
|
+
return outcomeOf(state, operation);
|
|
792
|
+
drop.push(...privateRefs(operation));
|
|
793
|
+
return finish(state, operation, operation.cancelRequested && status !== 'uncertain' ? 'cancelled' : status, error ? { error } : {});
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
function activateSignIn(state, operation, drop) {
|
|
797
|
+
const staged = operation.staged, target = own(state.connections, operation.connectionId);
|
|
798
|
+
const granted = staged.scopes, missingScopes = operation.scopes.filter(scope => !granted.includes(scope));
|
|
799
|
+
const previous = target.grantId === null ? undefined : own(state.grants, target.grantId);
|
|
800
|
+
const problem = operation.cancelRequested ? 'cancelled' : target.generation !== operation.connectionGeneration || target.sessionId !== operation.signInSessionId ? 'state_changed'
|
|
801
|
+
: previous && !(operation.requiredExistingScopes ?? previous.scopes).every(scope => granted.includes(scope)) ? 'replacement_rejected' : null;
|
|
802
|
+
drop.push(...privateRefs(operation));
|
|
803
|
+
if (problem) {
|
|
804
|
+
retire(state, `cln_${operation.id}`, target, staged.refreshRef);
|
|
805
|
+
drop.push(staged.accessRef);
|
|
806
|
+
return finish(state, operation, problem === 'cancelled' ? 'cancelled' : 'failed', { error: problem, grantedScopes: [...granted], missingScopes });
|
|
807
|
+
}
|
|
808
|
+
if (previous) {
|
|
809
|
+
retire(state, `cln_${previous.id}`, target, previous.secretRef);
|
|
810
|
+
delete state.grants[previous.id];
|
|
811
|
+
}
|
|
812
|
+
const grantId = randomId('grt_');
|
|
813
|
+
state.grants[grantId] = { id: grantId, issuer: target.issuer, userId: target.userId, secretRef: staged.refreshRef, scopes: granted,
|
|
814
|
+
expiresAt: staged.refreshExpiresAt, generation: 1, status: 'active', operationId: null };
|
|
815
|
+
target.grantId = grantId;
|
|
816
|
+
target.status = 'signed_in';
|
|
817
|
+
target.generation++;
|
|
818
|
+
drop.push(...Object.values(target.slots).map(slot => slot.secretRef));
|
|
819
|
+
target.slots = { [slotKey(staged.sessionId, operation.resource)]: { secretRef: staged.accessRef, tokenType: 'DPoP', dpopKeyRef: target.dpopKeyRef,
|
|
820
|
+
scopes: granted, expiresAt: staged.expiresAt, generation: target.generation } };
|
|
821
|
+
return finish(state, operation, 'completed', { grantedScopes: [...granted], missingScopes });
|
|
822
|
+
}
|
|
823
|
+
async function acceptSignIn(operationId, token, issuedAt) {
|
|
824
|
+
const snapshot = await load({ operationId }), operation = own(snapshot.operations, operationId);
|
|
825
|
+
const granted = token.scope.split(' ').filter(Boolean);
|
|
826
|
+
let refreshRef = null;
|
|
827
|
+
try {
|
|
828
|
+
if (token.refresh_token !== undefined)
|
|
829
|
+
refreshRef = await secrets.stage(token.refresh_token);
|
|
830
|
+
}
|
|
831
|
+
catch {
|
|
832
|
+
return await terminate(operationId, 'uncertain', 'storage_error');
|
|
833
|
+
}
|
|
834
|
+
if (token.token_type !== 'DPoP' || token.signed_in !== true || token.session_id !== operation.signInSessionId || !refreshRef ||
|
|
835
|
+
!granted.length || new Set(granted).size !== granted.length || granted.some(scope => !operation.scopes.includes(scope))) {
|
|
836
|
+
return commit({ operationId }, [], (state, drop) => {
|
|
837
|
+
const current = own(state.operations, operationId);
|
|
838
|
+
if (refreshRef)
|
|
839
|
+
retire(state, `cln_${operationId}`, own(state.connections, current.connectionId), refreshRef);
|
|
840
|
+
drop.push(...privateRefs(current));
|
|
841
|
+
return current.phase === 'terminal' ? outcomeOf(state, current) : finish(state, current, 'failed', { error: token.token_type !== 'DPoP' ? 'unsupported_token_type' : 'invalid_response' });
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
const accessRef = await stageSessionToken(token.access_token);
|
|
845
|
+
const staged = { accessRef, refreshRef, sessionId: token.session_id, scopes: granted, signedIn: true,
|
|
846
|
+
expiresAt: issuedAt + token.expires_in * 1000, refreshExpiresAt: token.refresh_token_expires_in === undefined ? null : issuedAt + token.refresh_token_expires_in * 1000 };
|
|
847
|
+
// Stage before activation: recovery can finish locally without exchanging a consumed credential again.
|
|
848
|
+
const recorded = await transaction({ operationId }, state => {
|
|
849
|
+
const current = own(state.operations, operationId);
|
|
850
|
+
if (current.phase !== 'dispatched') {
|
|
851
|
+
retire(state, `cln_${operationId}`, own(state.connections, current.connectionId), refreshRef);
|
|
852
|
+
return false;
|
|
853
|
+
}
|
|
854
|
+
current.phase = 'staged';
|
|
855
|
+
current.staged = staged;
|
|
856
|
+
return true;
|
|
857
|
+
});
|
|
858
|
+
if (!recorded) {
|
|
859
|
+
await secrets.remove(accessRef);
|
|
860
|
+
return (await api.operation(operationId));
|
|
861
|
+
}
|
|
862
|
+
return commit({ operationId }, [], (state, drop) => {
|
|
863
|
+
const current = own(state.operations, operationId);
|
|
864
|
+
return current.phase === 'staged' ? activateSignIn(state, current, drop) : outcomeOf(state, current);
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
async function claimSignIn(operationId, executionOwner, preSend = false) {
|
|
868
|
+
const snapshot = await load({ operationId }), operation = own(snapshot.operations, operationId);
|
|
869
|
+
await currentConnection(operation.connectionId);
|
|
870
|
+
return await transaction({ operationId }, state => {
|
|
871
|
+
const current = own(state.operations, operationId);
|
|
872
|
+
if (current.phase !== 'prepared')
|
|
873
|
+
return outcomeOf(state, current);
|
|
874
|
+
if (current.cancelRequested)
|
|
875
|
+
return finish(state, current, 'cancelled');
|
|
876
|
+
if (current.owner && (options.ownerAlive ?? localOwnerAlive)(current.owner))
|
|
877
|
+
return outcomeOf(state, current);
|
|
878
|
+
const connection = own(state.connections, current.connectionId);
|
|
879
|
+
if (current.expiresAt <= now())
|
|
880
|
+
return finish(state, current, 'expired');
|
|
881
|
+
if (current.connectionGeneration !== connection.generation || current.signInSessionId !== connection.sessionId)
|
|
882
|
+
return finish(state, current, 'failed', { error: 'state_changed' });
|
|
883
|
+
checkCooldown(connection);
|
|
884
|
+
if (!preSend)
|
|
885
|
+
current.phase = 'dispatched';
|
|
886
|
+
current.owner = executionOwner;
|
|
887
|
+
return { operation: current, connection };
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
async function remoteFailure(operationId, error) {
|
|
891
|
+
const operation = own((await load({ operationId })).operations, operationId);
|
|
892
|
+
if (error instanceof AuthFailure)
|
|
893
|
+
await holdOff(operation.connectionId, error);
|
|
894
|
+
const code = error instanceof AuthFailure ? error.code : 'local_error';
|
|
895
|
+
return await terminate(operationId, AMBIGUOUS.has(code) ? 'uncertain' : 'failed', AMBIGUOUS.has(code) ? 'lost_response' : code);
|
|
896
|
+
}
|
|
897
|
+
async function beginDevice(operationId) {
|
|
898
|
+
const executionOwner = acquireOwner();
|
|
899
|
+
try {
|
|
900
|
+
const claim = await claimSignIn(operationId, executionOwner);
|
|
901
|
+
if (!('operation' in claim))
|
|
902
|
+
return { outcome: claim };
|
|
903
|
+
const { operation, connection } = claim, metadata = connection.metadata;
|
|
904
|
+
const endpoint = metadata.authorization.device_authorization_endpoint, key = await loadDpopKey(connection.dpopKeyRef);
|
|
905
|
+
const body = new URLSearchParams({ scope: operation.scopes.join(' '), client_id: clientId, client_assertion_type: CLIENT_ASSERTION,
|
|
906
|
+
client_assertion: await assertion('client-authentication', connection.issuer, key, clientId, operationId) });
|
|
907
|
+
let response, value;
|
|
908
|
+
try {
|
|
909
|
+
response = await transport.fetch(new Request(endpoint, { method: 'POST', body, headers: { accept: 'application/json' } }));
|
|
910
|
+
}
|
|
911
|
+
catch {
|
|
912
|
+
throw new AuthFailure('transport_error');
|
|
913
|
+
}
|
|
914
|
+
if (response.status !== 200) {
|
|
915
|
+
await response.body?.cancel();
|
|
916
|
+
throw responseFailure(response, response.status === 429 ? 'rate_limited' : 'token_error');
|
|
917
|
+
}
|
|
918
|
+
try {
|
|
919
|
+
value = parseJson(await response.text());
|
|
920
|
+
}
|
|
921
|
+
catch {
|
|
922
|
+
throw new AuthFailure('invalid_response');
|
|
923
|
+
}
|
|
924
|
+
const { device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval = 5 } = value ?? {};
|
|
925
|
+
const hosts = [new URL(connection.issuer).hostname, ...metadata.authorization.poppy_domains];
|
|
926
|
+
if (typeof device_code !== 'string' || !device_code || typeof user_code !== 'string' || !user_code || typeof verification_uri !== 'string' || !allowedHost(verification_uri, hosts) ||
|
|
927
|
+
(verification_uri_complete !== undefined && (typeof verification_uri_complete !== 'string' || !allowedHost(verification_uri_complete, hosts))) ||
|
|
928
|
+
typeof expires_in !== 'number' || !Number.isSafeInteger(expires_in) || expires_in <= 0 || expires_in > 86400 || typeof interval !== 'number' || !Number.isSafeInteger(interval) || interval < 1 || interval > 86400)
|
|
929
|
+
throw new AuthFailure('invalid_response');
|
|
930
|
+
const codeRef = await secrets.stage(device_code), nextPollAt = now() + interval * 1000;
|
|
931
|
+
const outcome = await commit({ operationId }, [codeRef], (state, drop) => {
|
|
932
|
+
const current = own(state.operations, operationId);
|
|
933
|
+
if (current.cancelRequested) {
|
|
934
|
+
drop.push(codeRef);
|
|
935
|
+
return finish(state, current, 'cancelled');
|
|
936
|
+
}
|
|
937
|
+
if (current.phase !== 'dispatched' || own(state.connections, current.connectionId).generation !== current.connectionGeneration) {
|
|
938
|
+
drop.push(codeRef);
|
|
939
|
+
return finish(state, current, 'failed', { error: 'state_changed' });
|
|
940
|
+
}
|
|
941
|
+
current.phase = 'prepared';
|
|
942
|
+
delete current.owner;
|
|
943
|
+
current.expiresAt = Math.min(current.expiresAt, now() + expires_in * 1000);
|
|
944
|
+
current.device = { codeRef, intervalMs: interval * 1000, nextPollAt };
|
|
945
|
+
return outcomeOf(state, current);
|
|
946
|
+
});
|
|
947
|
+
if (outcome.status !== 'pending')
|
|
948
|
+
return { outcome };
|
|
949
|
+
return { outcome, device: { verificationUri: verification_uri, ...(verification_uri_complete === undefined ? {} : { verificationUriComplete: verification_uri_complete }),
|
|
950
|
+
userCode: user_code, organization: { ...metadata.document.organization }, nextPollAt } };
|
|
951
|
+
}
|
|
952
|
+
catch (error) {
|
|
953
|
+
return { outcome: await remoteFailure(operationId, error) };
|
|
954
|
+
}
|
|
955
|
+
finally {
|
|
956
|
+
releaseOwner(executionOwner);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
async function pollDevice(operationId) {
|
|
960
|
+
const snapshot = await load({ operationId }), found = own(snapshot.operations, operationId);
|
|
961
|
+
if (!found.device)
|
|
962
|
+
throw new AuthFailure('invalid_operation');
|
|
963
|
+
if (found.expiresAt <= now())
|
|
964
|
+
return await terminate(operationId, 'expired');
|
|
965
|
+
const wait = found.device.nextPollAt - now();
|
|
966
|
+
if (wait > 0)
|
|
967
|
+
return { ...outcomeOf(snapshot, found), retryAfterMs: wait };
|
|
968
|
+
const executionOwner = acquireOwner();
|
|
969
|
+
try {
|
|
970
|
+
const claim = await claimSignIn(operationId, executionOwner);
|
|
971
|
+
if (!('operation' in claim))
|
|
972
|
+
return claim;
|
|
973
|
+
const { operation, connection } = claim, code = await secrets.read(operation.device.codeRef);
|
|
974
|
+
if (!code)
|
|
975
|
+
return await terminate(operationId, 'failed', 'missing_credential');
|
|
976
|
+
const issuedAt = now(), fields = { grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code: code, session_id: operation.signInSessionId };
|
|
977
|
+
if (operation.resource)
|
|
978
|
+
fields.resource = operation.resource;
|
|
979
|
+
let token;
|
|
980
|
+
try {
|
|
981
|
+
token = (await tokenRequest(connection.metadata, connection.dpopKeyRef, fields, null)).token;
|
|
982
|
+
}
|
|
983
|
+
catch (error) {
|
|
984
|
+
if (error instanceof AuthFailure && ['authorization_pending', 'slow_down', 'rate_limited'].includes(error.code)) {
|
|
985
|
+
await holdOff(connection.id, error);
|
|
986
|
+
return await transaction({ operationId }, state => {
|
|
987
|
+
const current = own(state.operations, operationId);
|
|
988
|
+
if (current.phase !== 'dispatched')
|
|
989
|
+
return outcomeOf(state, current);
|
|
990
|
+
if (current.cancelRequested)
|
|
991
|
+
return finish(state, current, 'cancelled');
|
|
992
|
+
if (error.code === 'slow_down')
|
|
993
|
+
current.device.intervalMs += 5000;
|
|
994
|
+
current.device.nextPollAt = now() + Math.max(current.device.intervalMs, Number.isFinite(error.retryAfterMs) ? error.retryAfterMs ?? 0 : 0);
|
|
995
|
+
current.phase = 'prepared';
|
|
996
|
+
delete current.owner;
|
|
997
|
+
return { ...outcomeOf(state, current), retryAfterMs: current.device.nextPollAt - now() };
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
if (error instanceof AuthFailure && ['access_denied', 'expired_token'].includes(error.code))
|
|
1001
|
+
return await terminate(operationId, error.code === 'access_denied' ? 'denied' : 'expired', error.code);
|
|
1002
|
+
return await remoteFailure(operationId, error);
|
|
1003
|
+
}
|
|
1004
|
+
return await acceptSignIn(operationId, token, issuedAt);
|
|
1005
|
+
}
|
|
1006
|
+
finally {
|
|
1007
|
+
releaseOwner(executionOwner);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
async function continueMediated(operationId) {
|
|
1011
|
+
const executionOwner = acquireOwner();
|
|
1012
|
+
let sensitive = null;
|
|
1013
|
+
try {
|
|
1014
|
+
const claim = await claimSignIn(operationId, executionOwner, true);
|
|
1015
|
+
if (!('operation' in claim))
|
|
1016
|
+
return claim;
|
|
1017
|
+
const { operation, connection } = claim, mediated = connection.metadata.document.auth.mediated;
|
|
1018
|
+
if (!options.trustedInput)
|
|
1019
|
+
return await terminate(operationId, 'failed', 'trusted_input_required');
|
|
1020
|
+
const fields = operation.mediated ? [{ name: 'code', label: 'One-time code', secret: true }] : mediated.fields;
|
|
1021
|
+
let values;
|
|
1022
|
+
try {
|
|
1023
|
+
values = await options.trustedInput.collect({ operationId, issuer: connection.issuer, organization: { ...connection.metadata.document.organization }, fields: structuredClone(fields),
|
|
1024
|
+
...(operation.mediated ? { challenge: { id: operation.mediated.challengeId, sentTo: operation.mediated.sentTo, expiresAt: operation.expiresAt } } : {}) });
|
|
1025
|
+
}
|
|
1026
|
+
catch {
|
|
1027
|
+
return await terminate(operationId, 'failed', 'input_unavailable');
|
|
1028
|
+
}
|
|
1029
|
+
sensitive = values;
|
|
1030
|
+
if (values === null)
|
|
1031
|
+
return await terminate(operationId, 'cancelled');
|
|
1032
|
+
if (!values || 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))
|
|
1033
|
+
return await terminate(operationId, 'failed', 'invalid_input');
|
|
1034
|
+
const endpoint = operation.mediated ? `${mediated.endpoint.replace(/\/$/, '')}/${encodeURIComponent(operation.mediated.challengeId)}` : mediated.endpoint;
|
|
1035
|
+
let credential = await credentialFor(connection.id, operation.resource, null), token = await secrets.read(credential.secretRef);
|
|
1036
|
+
if (!token) {
|
|
1037
|
+
credential = await credentialFor(connection.id, operation.resource, credential.secretRef);
|
|
1038
|
+
token = await secrets.read(credential.secretRef);
|
|
1039
|
+
}
|
|
1040
|
+
if (!token)
|
|
1041
|
+
return await terminate(operationId, 'failed', 'missing_credential');
|
|
1042
|
+
if (connection.sessionId !== (await record(connection.id)).sessionId || now() >= operation.expiresAt)
|
|
1043
|
+
return await terminate(operationId, 'expired');
|
|
1044
|
+
let response, json;
|
|
1045
|
+
const issuedAt = now(), key = await loadDpopKey(connection.dpopKeyRef);
|
|
1046
|
+
const dispatch = await transaction({ operationId }, state => {
|
|
1047
|
+
const current = own(state.operations, operationId);
|
|
1048
|
+
if (current.phase !== 'prepared' || current.owner?.id !== executionOwner.id || current.cancelRequested)
|
|
1049
|
+
return false;
|
|
1050
|
+
const live = own(state.connections, current.connectionId);
|
|
1051
|
+
if (live.generation !== current.connectionGeneration || live.sessionId !== current.signInSessionId || current.expiresAt <= now()) {
|
|
1052
|
+
finish(state, current, 'cancelled');
|
|
1053
|
+
return false;
|
|
1054
|
+
}
|
|
1055
|
+
current.phase = 'dispatched';
|
|
1056
|
+
return true;
|
|
1057
|
+
});
|
|
1058
|
+
if (!dispatch)
|
|
1059
|
+
return (await api.operation(operationId));
|
|
1060
|
+
try {
|
|
1061
|
+
// Bounded nonce retry only; credentials remain exclusively within this trusted call.
|
|
1062
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
1063
|
+
if (attempt > 0 && own((await load({ operationId })).operations, operationId)?.cancelRequested)
|
|
1064
|
+
return await terminate(operationId, 'cancelled');
|
|
1065
|
+
response = await transport.fetch(new Request(endpoint, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `DPoP ${token}`, dpop: await proof(key, 'POST', endpoint, token) },
|
|
1066
|
+
body: JSON.stringify(operation.mediated ? { code: values.code } : { scope: operation.scopes.join(' '), credentials: values }) }));
|
|
1067
|
+
rememberNonce(endpoint, response);
|
|
1068
|
+
if (response.status === 401 && attempt === 0 && dpopChallenge(response.headers.get('www-authenticate'))?.error === 'use_dpop_nonce' && response.headers.has('dpop-nonce')) {
|
|
1069
|
+
await response.body?.cancel();
|
|
1070
|
+
continue;
|
|
1071
|
+
}
|
|
1072
|
+
break;
|
|
1073
|
+
}
|
|
1074
|
+
if (response.status === 429 || response.status === 503)
|
|
1075
|
+
await holdOff(connection.id, responseFailure(response, 'rate_limited'));
|
|
1076
|
+
json = parseJson(await response.text());
|
|
1077
|
+
}
|
|
1078
|
+
catch {
|
|
1079
|
+
return await terminate(operationId, response?.status === 429 ? 'failed' : 'uncertain', response?.status === 429 ? 'rate_limited' : 'lost_response');
|
|
1080
|
+
}
|
|
1081
|
+
const echoed = Object.values(values);
|
|
1082
|
+
for (const name of Object.keys(values))
|
|
1083
|
+
delete values[name];
|
|
1084
|
+
values = null;
|
|
1085
|
+
if (response.status !== 200) {
|
|
1086
|
+
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');
|
|
1087
|
+
return await remoteFailure(operationId, error);
|
|
1088
|
+
}
|
|
1089
|
+
if (json?.status === 'complete') {
|
|
1090
|
+
let result;
|
|
1091
|
+
try {
|
|
1092
|
+
result = parseTokenResponse(json);
|
|
1093
|
+
}
|
|
1094
|
+
catch {
|
|
1095
|
+
return await terminate(operationId, 'uncertain', 'invalid_response');
|
|
1096
|
+
}
|
|
1097
|
+
return await acceptSignIn(operationId, result, issuedAt);
|
|
1098
|
+
}
|
|
1099
|
+
if (json?.status === 'failed' || json?.status === 'expired')
|
|
1100
|
+
return await terminate(operationId, json.status === 'expired' ? 'expired' : 'denied');
|
|
1101
|
+
const code = json?.code, expiresAt = typeof json?.expires_at === 'string' ? Date.parse(json.expires_at) : NaN;
|
|
1102
|
+
if (json?.status !== 'code_required' || typeof json.sign_in_id !== 'string' || !/^[A-Za-z0-9_-]{1,256}$/.test(json.sign_in_id) ||
|
|
1103
|
+
(operation.mediated && operation.mediated.challengeId !== json.sign_in_id) || typeof code?.sent_to !== 'string' || code.sent_to.length > 512 || !Number.isFinite(expiresAt) || expiresAt <= now())
|
|
1104
|
+
return await terminate(operationId, 'failed', 'invalid_response');
|
|
1105
|
+
const sentTo = echoed.some(value => value.length > 2 && code.sent_to.includes(value)) ? 'The company sent a one-time code.' : code.sent_to;
|
|
1106
|
+
return await transaction({ operationId }, state => {
|
|
1107
|
+
const current = own(state.operations, operationId);
|
|
1108
|
+
if (current.phase !== 'dispatched')
|
|
1109
|
+
return outcomeOf(state, current);
|
|
1110
|
+
if (current.cancelRequested)
|
|
1111
|
+
return finish(state, current, 'cancelled');
|
|
1112
|
+
current.phase = 'prepared';
|
|
1113
|
+
delete current.owner;
|
|
1114
|
+
current.expiresAt = Math.min(current.expiresAt, expiresAt);
|
|
1115
|
+
current.mediated = { challengeId: json.sign_in_id, sentTo };
|
|
1116
|
+
return outcomeOf(state, current);
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
catch (error) {
|
|
1120
|
+
if (!(error instanceof AuthFailure))
|
|
1121
|
+
throw error;
|
|
1122
|
+
return await remoteFailure(operationId, error);
|
|
1123
|
+
}
|
|
1124
|
+
finally {
|
|
1125
|
+
if (sensitive)
|
|
1126
|
+
for (const name of Object.keys(sensitive))
|
|
1127
|
+
delete sensitive[name];
|
|
1128
|
+
releaseOwner(executionOwner);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
async function sendAuthorized(connectionId, request, target, requiredScopes, scheme = 'DPoP', retryRejected = true) {
|
|
1132
|
+
const connection = await record(connectionId);
|
|
1133
|
+
const url = new URL(request.url), endpoint = new URL(target.endpoint), base = endpoint.pathname.replace(/\/$/, '');
|
|
1134
|
+
if (!safeHttps(request.url) || url.origin !== endpoint.origin || (url.pathname !== endpoint.pathname && !url.pathname.startsWith(`${base}/`)))
|
|
1135
|
+
throw new AuthFailure('invalid_target', connectionId);
|
|
1136
|
+
if (connection.status === 'disconnected')
|
|
1137
|
+
throw new AuthFailure('disconnected', connectionId);
|
|
1138
|
+
checkCooldown(connection);
|
|
1139
|
+
const resource = target.resource ?? null, wait = url.searchParams.get('wait');
|
|
1140
|
+
const waitMs = wait !== null && /^\d+$/.test(wait) ? Math.min(Number(wait), MAX_WAIT_SECONDS) * 1000 : 0;
|
|
1141
|
+
let credential = await credentialFor(connectionId, resource, null), nonceRetried = false, renewed = false;
|
|
1142
|
+
for (;;) {
|
|
1143
|
+
if (credential.tokenType !== scheme)
|
|
1144
|
+
throw new AuthFailure('invalid_target', connectionId);
|
|
1145
|
+
if (!requiredScopes.every(scope => credential.scopes.includes(scope))) {
|
|
1146
|
+
const requirement = await api.authorization(connectionId, requiredScopes);
|
|
1147
|
+
if (!renewed && requirement.sufficient) {
|
|
1148
|
+
renewed = true;
|
|
1149
|
+
credential = await renewShared(connectionId, resource);
|
|
1150
|
+
continue;
|
|
1151
|
+
}
|
|
1152
|
+
throw new AuthFailure('insufficient_scope', connectionId);
|
|
1153
|
+
}
|
|
1154
|
+
const token = await secrets.read(credential.secretRef);
|
|
1155
|
+
if (token === null) {
|
|
1156
|
+
if (renewed)
|
|
1157
|
+
throw new AuthFailure('missing_credential', connectionId);
|
|
1158
|
+
renewed = true;
|
|
1159
|
+
credential = await credentialFor(connectionId, resource, credential.secretRef);
|
|
1160
|
+
continue;
|
|
1161
|
+
}
|
|
1162
|
+
const headers = new Headers(request.headers);
|
|
1163
|
+
headers.delete('cookie');
|
|
1164
|
+
headers.delete('dpop');
|
|
1165
|
+
headers.set('authorization', `${scheme} ${token}`);
|
|
1166
|
+
if (scheme === 'DPoP')
|
|
1167
|
+
headers.set('dpop', await proof(await loadDpopKey(credential.dpopKeyRef), request.method, request.url, token));
|
|
1168
|
+
const authorized = new Request(request.clone(), { headers, credentials: 'omit', redirect: 'error' });
|
|
1169
|
+
const streaming = request.headers.get('accept')?.split(',').some(value => value.trim().split(';')[0] === 'text/event-stream');
|
|
1170
|
+
const response = await (streaming && transport.stream ? transport.stream(authorized) : transport.fetch(authorized, { waitMs }));
|
|
1171
|
+
rememberNonce(request.url, response);
|
|
1172
|
+
if (response.status === 429 || response.status === 503)
|
|
1173
|
+
await holdOff(connectionId, responseFailure(response, 'rate_limited'));
|
|
1174
|
+
if (response.status !== 401 || !retryRejected)
|
|
1175
|
+
return response;
|
|
1176
|
+
const error = schemeChallenge(response.headers.get('www-authenticate'), scheme)?.error;
|
|
1177
|
+
if (error === 'use_dpop_nonce' && !nonceRetried && response.headers.has('dpop-nonce')) {
|
|
1178
|
+
await response.body?.cancel().catch(() => { });
|
|
1179
|
+
nonceRetried = true;
|
|
1180
|
+
continue;
|
|
1181
|
+
}
|
|
1182
|
+
if (error === 'invalid_token' && !renewed) {
|
|
1183
|
+
await response.body?.cancel().catch(() => { });
|
|
1184
|
+
renewed = true;
|
|
1185
|
+
credential = await credentialFor(connectionId, resource, credential.secretRef);
|
|
1186
|
+
continue;
|
|
1187
|
+
}
|
|
1188
|
+
return response;
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
const api = {
|
|
1192
|
+
async start(domain, input) {
|
|
1193
|
+
let metadata;
|
|
1194
|
+
try {
|
|
1195
|
+
metadata = await options.metadata.resolveCompany(domain);
|
|
1196
|
+
}
|
|
1197
|
+
catch {
|
|
1198
|
+
throw new AuthFailure('discovery_failed');
|
|
1199
|
+
}
|
|
1200
|
+
if (!metadata.authorization)
|
|
1201
|
+
throw new AuthFailure('no_auth');
|
|
1202
|
+
const issuer = metadata.authorization.issuer;
|
|
1203
|
+
const anonymous = input?.anonymous === true;
|
|
1204
|
+
const { userId, dpopKeyRef } = await identityFor(issuer, anonymous);
|
|
1205
|
+
const createdAt = now();
|
|
1206
|
+
const token = await signedOutToken(metadata, userId, dpopKeyRef, null, null, anonymous);
|
|
1207
|
+
const secretRef = await stageSessionToken(token.access_token);
|
|
1208
|
+
const id = randomId('con_'), operationId = randomId('op_');
|
|
1209
|
+
return commit({ issuer }, [secretRef], state => {
|
|
1210
|
+
// A reset or competing identity since we signed means this session belongs to no current identity.
|
|
1211
|
+
if (!anonymous && own(state.identities, issuer)?.dpopKeyRef !== dpopKeyRef)
|
|
1212
|
+
throw new AuthFailure('state_changed');
|
|
1213
|
+
const connection = {
|
|
1214
|
+
id, domain, issuer, metadata, userId, dpopKeyRef, sessionId: token.session_id, generation: 1, status: 'signed_out', grantId: null,
|
|
1215
|
+
slots: { [slotKey(token.session_id, null)]: { secretRef, tokenType: 'DPoP', dpopKeyRef, scopes: [], expiresAt: createdAt + token.expires_in * 1000, generation: 1 } },
|
|
1216
|
+
...(anonymous ? { anonymous: true } : {}),
|
|
1217
|
+
};
|
|
1218
|
+
state.connections[id] = connection;
|
|
1219
|
+
pruneHistory(state);
|
|
1220
|
+
const result = { operationId, status: 'completed', connection: publicConnection(state, connection) };
|
|
1221
|
+
state.operations[operationId] = {
|
|
1222
|
+
epoch: state.epoch, id: operationId, kind: 'start', connectionId: id, connectionGeneration: 1, grantId: null, grantGeneration: null,
|
|
1223
|
+
phase: 'terminal', status: 'completed', createdAt, expiresAt: createdAt, resource: null, scopes: [], direct: null, staged: null, result,
|
|
1224
|
+
};
|
|
1225
|
+
return result;
|
|
1226
|
+
});
|
|
1227
|
+
},
|
|
1228
|
+
async get(connectionId) {
|
|
1229
|
+
const state = await load({ connectionId });
|
|
1230
|
+
const found = typeof connectionId === 'string' ? own(state.connections, connectionId) : undefined;
|
|
1231
|
+
return found ? publicConnection(state, found) : null;
|
|
1232
|
+
},
|
|
1233
|
+
async list() {
|
|
1234
|
+
const state = await load({ list: true });
|
|
1235
|
+
return Object.values(state.connections).map(connection => publicConnection(state, connection));
|
|
1236
|
+
},
|
|
1237
|
+
async target(connectionId) { return targetOf(await currentConnection(connectionId)); },
|
|
1238
|
+
// Direct Sign-In (spec 4.5): the operation binds everything the callback must match before the URL exists.
|
|
1239
|
+
async authorization(connectionId, requiredScopes) {
|
|
1240
|
+
if (!Array.isArray(requiredScopes) || !requiredScopes.every(scope => typeof scope === 'string' && /^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope)))
|
|
1241
|
+
throw new AuthFailure('invalid_scope', connectionId);
|
|
1242
|
+
const connection = await currentConnection(connectionId), state = await load({ connectionId });
|
|
1243
|
+
const grantedScopes = publicConnection(state, connection).scopes;
|
|
1244
|
+
const grant = connection.grantId ? own(state.grants, connection.grantId) : undefined;
|
|
1245
|
+
const methods = ['direct', 'device', 'mediated'].flatMap(method => {
|
|
1246
|
+
const supported = connection.metadata.document.auth?.[method];
|
|
1247
|
+
return supported ? [{ method, scopes: [...supported.scopes] }] : [];
|
|
1248
|
+
});
|
|
1249
|
+
const required = [...new Set(requiredScopes)], missingScopes = required.filter(scope => !grantedScopes.includes(scope));
|
|
1250
|
+
return { requiredScopes: required, grantedScopes, missingScopes, sufficient: connection.status === 'signed_in' && grant?.status === 'active' && (grant.expiresAt === null || grant.expiresAt > now()) && !missingScopes.length,
|
|
1251
|
+
methods, organization: { ...connection.metadata.document.organization } };
|
|
1252
|
+
},
|
|
1253
|
+
async beginSignIn(connectionId, input) {
|
|
1254
|
+
let found = await currentConnection(connectionId);
|
|
1255
|
+
if (found.anonymous) {
|
|
1256
|
+
await api.signOut(connectionId);
|
|
1257
|
+
const regular = await api.start(found.domain);
|
|
1258
|
+
connectionId = regular.connection.id;
|
|
1259
|
+
found = await currentConnection(connectionId);
|
|
1260
|
+
}
|
|
1261
|
+
const method = input?.method ?? 'direct';
|
|
1262
|
+
if (!['direct', 'device', 'mediated'].includes(method))
|
|
1263
|
+
throw new AuthFailure('unsupported_method', connectionId);
|
|
1264
|
+
const supported = found.metadata.document.auth?.[method];
|
|
1265
|
+
const authorizationEndpoint = found.metadata.authorization?.authorization_endpoint;
|
|
1266
|
+
if (!supported || (method === 'direct' && !authorizationEndpoint) || (method === 'device' && !found.metadata.authorization?.device_authorization_endpoint))
|
|
1267
|
+
throw new AuthFailure(`${method}_unavailable`, connectionId);
|
|
1268
|
+
const { scopes, redirectUri, browserBinding, requiredExistingScopes } = input ?? {};
|
|
1269
|
+
if (!Array.isArray(scopes) || !scopes.length || new Set(scopes).size !== scopes.length || !scopes.every(scope => typeof scope === 'string' && supported.scopes.includes(scope)))
|
|
1270
|
+
throw new AuthFailure('invalid_scope', connectionId);
|
|
1271
|
+
const requirement = await api.authorization(connectionId, scopes);
|
|
1272
|
+
if (requiredExistingScopes !== undefined && (!Array.isArray(requiredExistingScopes) || !requiredExistingScopes.every(scope => requirement.grantedScopes.includes(scope))))
|
|
1273
|
+
throw new AuthFailure('invalid_scope', connectionId);
|
|
1274
|
+
if (requirement.sufficient && !input.force)
|
|
1275
|
+
return { outcome: { operationId: '', status: 'completed', connection: publicConnection(await load({ connectionId }), found), grantedScopes: requirement.grantedScopes, missingScopes: [] } };
|
|
1276
|
+
checkCooldown(found);
|
|
1277
|
+
if (method === 'mediated' && !options.trustedInput)
|
|
1278
|
+
throw new AuthFailure('trusted_input_required', connectionId);
|
|
1279
|
+
if (method === 'direct' && (typeof redirectUri !== 'string' || !options.redirectUris.includes(redirectUri) || !URL.canParse(redirectUri) || !safeHttps(redirectUri) || new URL(redirectUri).hostname !== new URL(clientId).hostname || CALLBACK_PARAMS.some(name => new URL(redirectUri).searchParams.has(name))))
|
|
1280
|
+
throw new AuthFailure('invalid_redirect', connectionId);
|
|
1281
|
+
// The host derives this from its authenticated browser session; a short value would be guessable.
|
|
1282
|
+
if (method === 'direct' && (typeof browserBinding !== 'string' || browserBinding.length < 22))
|
|
1283
|
+
throw new AuthFailure('invalid_binding', connectionId);
|
|
1284
|
+
// Ask for the conversation resource up front so the signed-in token is usable there (spec 4.3 "Narrower tokens").
|
|
1285
|
+
const resource = found.metadata.document.agent?.protocols.find(protocol => protocol.type === 'poppy')?.resource ?? null;
|
|
1286
|
+
if (await resourceScheme(found.metadata, resource) !== 'DPoP')
|
|
1287
|
+
throw new AuthFailure('invalid_target', connectionId);
|
|
1288
|
+
// The code exchange names this Session (spec 4.5). A stale credential means it may have ended, so renew first,
|
|
1289
|
+
// which replaces an ended Session; a failed renewal leaves the Session as it was for the issuer to judge.
|
|
1290
|
+
const renew = () => credentialFor(connectionId, resource, null).catch(error => { if (!(error instanceof AuthFailure))
|
|
1291
|
+
throw error; });
|
|
1292
|
+
if (found.status === 'signed_out' || found.status === 'signed_in')
|
|
1293
|
+
await renew();
|
|
1294
|
+
let connection = await record(connectionId);
|
|
1295
|
+
// An expired Account Token ends the refresh as invalid_grant, leaving the connection signed out on a possibly ended Session.
|
|
1296
|
+
if (found.status === 'signed_in' && connection.status === 'signed_out') {
|
|
1297
|
+
await renew();
|
|
1298
|
+
connection = await record(connectionId);
|
|
1299
|
+
}
|
|
1300
|
+
// An uncertain grant recovers only through a fresh sign-in; a disconnected connection never signs in again (spec 4.9).
|
|
1301
|
+
if (connection.status === 'disconnected' || connection.sessionId === null)
|
|
1302
|
+
throw new AuthFailure('sign_in_unavailable', connectionId);
|
|
1303
|
+
const state = b64(crypto.getRandomValues(new Uint8Array(32)));
|
|
1304
|
+
const verifier = b64(crypto.getRandomValues(new Uint8Array(32)));
|
|
1305
|
+
const verifierRef = method === 'direct' ? await secrets.stage(verifier) : null;
|
|
1306
|
+
const createdAt = now(), operationId = randomId('op_');
|
|
1307
|
+
const outcome = await commit({ connectionId }, [verifierRef], draft => {
|
|
1308
|
+
const current = own(draft.connections, connectionId);
|
|
1309
|
+
if (!current || current.generation !== connection.generation || current.status !== connection.status || current.sessionId !== connection.sessionId)
|
|
1310
|
+
throw new AuthFailure('state_changed', connectionId);
|
|
1311
|
+
pruneHistory(draft);
|
|
1312
|
+
const operation = draft.operations[operationId] = {
|
|
1313
|
+
epoch: draft.epoch, id: operationId, kind: 'sign_in', connectionId, connectionGeneration: current.generation, grantId: current.grantId, grantGeneration: null,
|
|
1314
|
+
phase: 'prepared', status: 'pending', createdAt, expiresAt: createdAt + SIGN_IN_MS, resource, scopes: [...scopes],
|
|
1315
|
+
method, signInSessionId: connection.sessionId, requiredExistingScopes: requiredExistingScopes ?? requirement.grantedScopes,
|
|
1316
|
+
direct: method === 'direct' ? { stateHash: sha256(state), verifierRef: verifierRef, browserHash: sha256(browserBinding), redirectUri: redirectUri, issuer: current.issuer, clientId, sessionId: connection.sessionId } : null,
|
|
1317
|
+
staged: null, result: null,
|
|
1318
|
+
};
|
|
1319
|
+
return outcomeOf(draft, operation);
|
|
1320
|
+
});
|
|
1321
|
+
if (method === 'device')
|
|
1322
|
+
return beginDevice(outcome.operationId);
|
|
1323
|
+
if (method === 'mediated')
|
|
1324
|
+
return { outcome: await continueMediated(outcome.operationId) };
|
|
1325
|
+
const url = new URL(authorizationEndpoint);
|
|
1326
|
+
const query = { response_type: 'code', client_id: clientId, redirect_uri: redirectUri, scope: scopes.join(' '), state, code_challenge: sha256(verifier), code_challenge_method: 'S256' };
|
|
1327
|
+
for (const [name, value] of Object.entries(query))
|
|
1328
|
+
url.searchParams.set(name, value);
|
|
1329
|
+
if (resource !== null)
|
|
1330
|
+
url.searchParams.set('resource', resource);
|
|
1331
|
+
return { outcome, authorizationUrl: url.href };
|
|
1332
|
+
},
|
|
1333
|
+
async completeSignIn(input) {
|
|
1334
|
+
const executionOwner = acquireOwner();
|
|
1335
|
+
try {
|
|
1336
|
+
const { callbackUrl, browserBinding } = (input ?? {});
|
|
1337
|
+
const { params, base } = parseCallback(callbackUrl);
|
|
1338
|
+
if (typeof browserBinding !== 'string' || !params.state || (!params.code && !params.error) || (params.code !== undefined) === (params.error !== undefined))
|
|
1339
|
+
throw new AuthFailure('invalid_callback');
|
|
1340
|
+
const stateHash = sha256(params.state), browserHash = sha256(browserBinding);
|
|
1341
|
+
const snapshot = await load({ callbackHash: stateHash });
|
|
1342
|
+
const found = Object.values(snapshot.operations).find(operation => operation.kind === 'sign_in' && operation.epoch === snapshot.epoch && operation.direct?.stateHash === stateHash);
|
|
1343
|
+
const direct = found?.direct;
|
|
1344
|
+
// A wrong browser, redirect or issuer (mix-up, spec 4.5) leaves the operation untouched so the genuine callback still completes.
|
|
1345
|
+
if (!found || !direct || direct.browserHash !== browserHash || redirectShape(base) !== redirectShape(new URL(direct.redirectUri)) || params.iss !== direct.issuer)
|
|
1346
|
+
throw new AuthFailure('invalid_callback');
|
|
1347
|
+
checkCooldown(await record(found.connectionId));
|
|
1348
|
+
const operationId = found.id;
|
|
1349
|
+
const verifier = found.phase === 'prepared' && params.code !== undefined ? await secrets.read(direct.verifierRef) : null;
|
|
1350
|
+
const at = now();
|
|
1351
|
+
// Only one claimant moves prepared -> dispatched; everyone else gets the recorded progress or result.
|
|
1352
|
+
const claim = await transaction({ operationId }, state => {
|
|
1353
|
+
const operation = own(state.operations, operationId);
|
|
1354
|
+
if (!operation || operation.epoch !== state.epoch)
|
|
1355
|
+
throw new AuthFailure('invalid_callback');
|
|
1356
|
+
if (operation.phase !== 'prepared')
|
|
1357
|
+
return outcomeOf(state, operation);
|
|
1358
|
+
const connection = own(state.connections, operation.connectionId);
|
|
1359
|
+
if (connection.generation !== operation.connectionGeneration)
|
|
1360
|
+
return finish(state, operation, 'failed', { error: 'state_changed' });
|
|
1361
|
+
if (at >= operation.expiresAt)
|
|
1362
|
+
return finish(state, operation, 'expired');
|
|
1363
|
+
if (params.error !== undefined)
|
|
1364
|
+
return params.error === 'access_denied' ? finish(state, operation, 'denied', { error: 'access_denied' }) : finish(state, operation, 'failed', { error: AUTHORIZE_ERRORS.has(params.error) ? params.error : 'authorization_error' });
|
|
1365
|
+
if (verifier === null)
|
|
1366
|
+
return finish(state, operation, 'failed', { error: 'missing_key' });
|
|
1367
|
+
operation.phase = 'dispatched';
|
|
1368
|
+
operation.owner = executionOwner;
|
|
1369
|
+
return { connection, operation };
|
|
1370
|
+
});
|
|
1371
|
+
if (!('operation' in claim)) {
|
|
1372
|
+
if (claim.status !== 'pending')
|
|
1373
|
+
await secrets.remove(direct.verifierRef);
|
|
1374
|
+
return claim;
|
|
1375
|
+
}
|
|
1376
|
+
const { connection, operation } = claim;
|
|
1377
|
+
const fields = { grant_type: 'authorization_code', code: params.code, redirect_uri: direct.redirectUri, code_verifier: verifier, session_id: direct.sessionId };
|
|
1378
|
+
if (operation.resource !== null)
|
|
1379
|
+
fields.resource = operation.resource;
|
|
1380
|
+
const issuedAt = now();
|
|
1381
|
+
let token;
|
|
1382
|
+
try {
|
|
1383
|
+
token = (await tokenRequest(connection.metadata, connection.dpopKeyRef, fields, null)).token;
|
|
1384
|
+
}
|
|
1385
|
+
catch (error) {
|
|
1386
|
+
if (error instanceof AuthFailure)
|
|
1387
|
+
await holdOff(connection.id, error);
|
|
1388
|
+
// A definite rejection is a failure; a lost or non-OAuth answer may have consumed the code, so it is never resent.
|
|
1389
|
+
const definite = error instanceof AuthFailure && !AMBIGUOUS.has(error.code);
|
|
1390
|
+
return settle(operationId, direct.verifierRef, definite ? 'failed' : 'uncertain', { error: definite ? error.code : 'lost_response' });
|
|
1391
|
+
}
|
|
1392
|
+
return await acceptSignIn(operationId, token, issuedAt);
|
|
1393
|
+
}
|
|
1394
|
+
finally {
|
|
1395
|
+
releaseOwner(executionOwner);
|
|
1396
|
+
}
|
|
1397
|
+
},
|
|
1398
|
+
async continueSignIn(operationId) {
|
|
1399
|
+
const state = await load({ operationId }), operation = own(state.operations, operationId);
|
|
1400
|
+
if (!operation || operation.kind !== 'sign_in')
|
|
1401
|
+
throw new AuthFailure('unknown_operation');
|
|
1402
|
+
if (operation.phase === 'terminal' || operation.phase !== 'prepared')
|
|
1403
|
+
return outcomeOf(state, operation);
|
|
1404
|
+
return operation.method === 'device' ? pollDevice(operationId) : operation.method === 'mediated' ? continueMediated(operationId) : outcomeOf(state, operation);
|
|
1405
|
+
},
|
|
1406
|
+
async cancelSignIn(operationId) {
|
|
1407
|
+
return commit({ operationId }, [], (state, drop) => {
|
|
1408
|
+
const operation = typeof operationId === 'string' ? own(state.operations, operationId) : undefined;
|
|
1409
|
+
if (!operation || operation.kind !== 'sign_in' || operation.epoch !== state.epoch)
|
|
1410
|
+
throw new AuthFailure('unknown_operation');
|
|
1411
|
+
if (operation.phase === 'terminal')
|
|
1412
|
+
return outcomeOf(state, operation);
|
|
1413
|
+
operation.cancelRequested = true;
|
|
1414
|
+
drop.push(...privateRefs(operation));
|
|
1415
|
+
if (operation.phase === 'staged') {
|
|
1416
|
+
retire(state, `cln_${operationId}`, own(state.connections, operation.connectionId), operation.staged.refreshRef);
|
|
1417
|
+
drop.push(operation.staged.accessRef);
|
|
1418
|
+
}
|
|
1419
|
+
if (operation.phase !== 'dispatched')
|
|
1420
|
+
return finish(state, operation, 'cancelled');
|
|
1421
|
+
// Keep dispatched work recoverable: cancellation cannot prove whether the issuer acted.
|
|
1422
|
+
return operation.result = { operationId, status: 'cancelled', connection: publicConnection(state, own(state.connections, operation.connectionId)) };
|
|
1423
|
+
});
|
|
1424
|
+
},
|
|
1425
|
+
async operation(operationId) {
|
|
1426
|
+
const state = await load({ operationId });
|
|
1427
|
+
const found = typeof operationId === 'string' ? own(state.operations, operationId) : undefined;
|
|
1428
|
+
return found && found.epoch === state.epoch ? outcomeOf(state, found) : null;
|
|
1429
|
+
},
|
|
1430
|
+
// Host calls this at startup; it never repeats a remote exchange.
|
|
1431
|
+
async recover() {
|
|
1432
|
+
const at = now();
|
|
1433
|
+
const results = [];
|
|
1434
|
+
let after;
|
|
1435
|
+
for (;;) {
|
|
1436
|
+
const ids = await store.pendingOperations(after, 100);
|
|
1437
|
+
if (!ids.length)
|
|
1438
|
+
return results;
|
|
1439
|
+
for (const operationId of ids) {
|
|
1440
|
+
const recovered = await commit({ operationId }, [], (state, drop) => {
|
|
1441
|
+
const operation = own(state.operations, operationId);
|
|
1442
|
+
if (!operation || operation.epoch !== state.epoch || operation.phase === 'terminal')
|
|
1443
|
+
return;
|
|
1444
|
+
if (operation.owner && (options.ownerAlive ?? localOwnerAlive)(operation.owner))
|
|
1445
|
+
return;
|
|
1446
|
+
let result;
|
|
1447
|
+
// A fully staged refresh finishes locally; its fences still decide whether it activates.
|
|
1448
|
+
if (operation.kind === 'renew') {
|
|
1449
|
+
if (operation.phase === 'staged')
|
|
1450
|
+
result = activateRefresh(state, operation, drop);
|
|
1451
|
+
else
|
|
1452
|
+
result = operation.phase === 'prepared' ? closeRefresh(state, operation, 'failed', 'interrupted', false, drop) : closeRefresh(state, operation, 'uncertain', 'lost_response', false, drop);
|
|
1453
|
+
}
|
|
1454
|
+
// The code may have reached the issuer before the crash; only a fresh sign-in is safe.
|
|
1455
|
+
else if (operation.phase === 'staged')
|
|
1456
|
+
result = activateSignIn(state, operation, drop);
|
|
1457
|
+
else if (operation.phase !== 'prepared')
|
|
1458
|
+
result = finish(state, operation, 'uncertain', { error: 'lost_response' });
|
|
1459
|
+
else if (at >= operation.expiresAt)
|
|
1460
|
+
result = finish(state, operation, 'expired');
|
|
1461
|
+
else
|
|
1462
|
+
return;
|
|
1463
|
+
// Terminal operations never read their verifier again.
|
|
1464
|
+
drop.push(...privateRefs(operation));
|
|
1465
|
+
return result;
|
|
1466
|
+
});
|
|
1467
|
+
if (recovered)
|
|
1468
|
+
results.push(recovered);
|
|
1469
|
+
}
|
|
1470
|
+
after = ids.at(-1);
|
|
1471
|
+
}
|
|
1472
|
+
},
|
|
1473
|
+
async renew(connectionId, input) {
|
|
1474
|
+
const { resource = null, scopes } = (input ?? {});
|
|
1475
|
+
if (resource !== null && (typeof resource !== 'string' || !URL.canParse(resource)))
|
|
1476
|
+
throw new AuthFailure('invalid_target', connectionId);
|
|
1477
|
+
if (scopes !== undefined && (!Array.isArray(scopes) || !scopes.length || new Set(scopes).size !== scopes.length || !scopes.every(scope => typeof scope === 'string')))
|
|
1478
|
+
throw new AuthFailure('invalid_scope', connectionId);
|
|
1479
|
+
return renewal(await currentConnection(connectionId), resource, scopes ?? null);
|
|
1480
|
+
},
|
|
1481
|
+
// Sign-out wins over in-flight sign-in and refresh: disconnection and the new generation commit before anything goes remote (IMPLEMENTATION 4).
|
|
1482
|
+
async signOut(connectionId) {
|
|
1483
|
+
await record(connectionId);
|
|
1484
|
+
await commit({ connectionId }, [], (state, drop) => {
|
|
1485
|
+
const connection = own(state.connections, connectionId);
|
|
1486
|
+
if (connection.status === 'disconnected')
|
|
1487
|
+
return;
|
|
1488
|
+
connection.generation++;
|
|
1489
|
+
connection.status = 'disconnected';
|
|
1490
|
+
drop.push(...Object.values(connection.slots).map(slot => slot.secretRef));
|
|
1491
|
+
connection.slots = {};
|
|
1492
|
+
if (connection.anonymous) {
|
|
1493
|
+
drop.push(connection.dpopKeyRef);
|
|
1494
|
+
connection.userId = '';
|
|
1495
|
+
connection.sessionId = null;
|
|
1496
|
+
}
|
|
1497
|
+
const grant = connection.grantId === null ? undefined : own(state.grants, connection.grantId);
|
|
1498
|
+
if (grant) {
|
|
1499
|
+
retire(state, `cln_${grant.id}`, connection, grant.secretRef);
|
|
1500
|
+
delete state.grants[grant.id];
|
|
1501
|
+
}
|
|
1502
|
+
connection.grantId = null;
|
|
1503
|
+
for (const operation of Object.values(state.operations)) {
|
|
1504
|
+
if (operation.connectionId !== connectionId || operation.epoch !== state.epoch || operation.phase !== 'prepared')
|
|
1505
|
+
continue;
|
|
1506
|
+
finish(state, operation, 'cancelled');
|
|
1507
|
+
drop.push(...privateRefs(operation));
|
|
1508
|
+
}
|
|
1509
|
+
});
|
|
1510
|
+
return cleanupNow(connectionId);
|
|
1511
|
+
},
|
|
1512
|
+
retryCleanup: cleanupNow,
|
|
1513
|
+
async apiTarget(connectionId, apiUrl, destination) {
|
|
1514
|
+
const connection = await currentConnection(connectionId);
|
|
1515
|
+
try {
|
|
1516
|
+
return await resolveApiTarget(connection.metadata, options.metadata, transport, apiUrl, destination);
|
|
1517
|
+
}
|
|
1518
|
+
catch {
|
|
1519
|
+
throw new AuthFailure('invalid_target', connectionId);
|
|
1520
|
+
}
|
|
1521
|
+
},
|
|
1522
|
+
async apiFetch(connectionId, request, target, requiredScopes = []) {
|
|
1523
|
+
const expected = await api.apiTarget(connectionId, target.apiUrl, target.endpoint);
|
|
1524
|
+
if (expected.kind !== target.kind || expected.issuer !== target.issuer || expected.endpoint !== target.endpoint || expected.resource !== target.resource || expected.scheme !== target.scheme)
|
|
1525
|
+
throw new AuthFailure('invalid_target', connectionId);
|
|
1526
|
+
if (expected.kind === 'mcp' && request.url !== expected.endpoint)
|
|
1527
|
+
throw new AuthFailure('invalid_target', connectionId);
|
|
1528
|
+
return sendAuthorized(connectionId, request, expected, requiredScopes, expected.scheme);
|
|
1529
|
+
},
|
|
1530
|
+
async joinBrowser(connectionId, returnTo, controller) {
|
|
1531
|
+
const connection = await currentConnection(connectionId), endpoint = connection.metadata.document.web?.browser_session_endpoint;
|
|
1532
|
+
if (!endpoint || !connection.sessionId || connection.status === 'disconnected' || connection.status === 'uncertain' || controller?.kind !== 'agent-controlled' || typeof controller.postForm !== 'function')
|
|
1533
|
+
throw new AuthFailure('invalid_browser', connectionId);
|
|
1534
|
+
try {
|
|
1535
|
+
browserDestination(returnTo, connection.metadata.document.organization.domain);
|
|
1536
|
+
}
|
|
1537
|
+
catch {
|
|
1538
|
+
throw new AuthFailure('invalid_target', connectionId);
|
|
1539
|
+
}
|
|
1540
|
+
const key = await loadDpopKey(connection.dpopKeyRef);
|
|
1541
|
+
let value;
|
|
1542
|
+
if (options.assertions && !connection.anonymous) {
|
|
1543
|
+
try {
|
|
1544
|
+
const result = await options.assertions.assert({ purpose: 'browser', operationId: randomId('op_'), issuer: connection.issuer, sessionId: connection.sessionId, returnTo });
|
|
1545
|
+
if (!result.assertion || result.expiresAt * 1000 <= now() || result.expiresAt * 1000 > now() + 60000)
|
|
1546
|
+
throw 0;
|
|
1547
|
+
value = result.assertion;
|
|
1548
|
+
}
|
|
1549
|
+
catch {
|
|
1550
|
+
throw new AuthFailure('provider_unavailable', connectionId);
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
else
|
|
1554
|
+
value = await signIdentity({ typ: 'poppy-browser+jwt' }, { ...assertionClaims(connection.userId, endpoint), session_id: connection.sessionId, return_to: returnTo }, key.thumbprint);
|
|
1555
|
+
try {
|
|
1556
|
+
await controller.postForm({ url: endpoint, fields: { assertion: value }, validateRedirect(status, location) {
|
|
1557
|
+
if (status !== 303 || location !== returnTo)
|
|
1558
|
+
throw new AuthFailure('invalid_browser_redirect', connectionId);
|
|
1559
|
+
return returnTo;
|
|
1560
|
+
} });
|
|
1561
|
+
}
|
|
1562
|
+
catch {
|
|
1563
|
+
throw new AuthFailure('browser_failed', connectionId);
|
|
1564
|
+
}
|
|
1565
|
+
},
|
|
1566
|
+
async feedback(connectionId, report) {
|
|
1567
|
+
validateFeedback(report);
|
|
1568
|
+
const connection = await currentConnection(connectionId), endpoint = connection.metadata.document.feedback?.endpoint;
|
|
1569
|
+
if (!endpoint)
|
|
1570
|
+
throw new AuthFailure('feedback_unavailable', connectionId);
|
|
1571
|
+
const response = await sendAuthorized(connectionId, new Request(endpoint, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(report) }), { issuer: connection.issuer, endpoint }, [], 'DPoP', false);
|
|
1572
|
+
if (response.status !== 202 || (await response.text()) !== '')
|
|
1573
|
+
throw new AuthFailure('feedback_failed', connectionId);
|
|
1574
|
+
},
|
|
1575
|
+
async authorizedFetch(connectionId, request, target, requiredScopes = []) {
|
|
1576
|
+
const connection = await currentConnection(connectionId), expected = targetOf(connection);
|
|
1577
|
+
if (target.issuer !== expected.issuer || target.endpoint !== expected.endpoint || target.resource !== expected.resource)
|
|
1578
|
+
throw new AuthFailure('invalid_target', connectionId);
|
|
1579
|
+
return sendAuthorized(connectionId, request, expected, requiredScopes);
|
|
1580
|
+
},
|
|
1581
|
+
};
|
|
1582
|
+
return api;
|
|
1583
|
+
}
|