@awiki/dsh-pap 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.
Files changed (63) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +197 -2
  3. package/cordis.patch.yml +15 -0
  4. package/docs/verification.md +217 -0
  5. package/lib/auth-challenge.d.ts +19 -0
  6. package/lib/auth-challenge.js +136 -0
  7. package/lib/browser-client.js +65 -0
  8. package/lib/browser-client.js.map +6 -0
  9. package/lib/callback-handoff.d.ts +55 -0
  10. package/lib/callback-handoff.js +33 -0
  11. package/lib/client/controller.d.ts +107 -0
  12. package/lib/client/controller.js +106 -0
  13. package/lib/client/index.d.ts +6 -0
  14. package/lib/client/index.js +28 -0
  15. package/lib/client/views.d.ts +5 -0
  16. package/lib/client/views.js +28 -0
  17. package/lib/client.d.ts +108 -0
  18. package/lib/client.js +833 -0
  19. package/lib/cooldown.d.ts +11 -0
  20. package/lib/cooldown.js +88 -0
  21. package/lib/credential-history.d.ts +33 -0
  22. package/lib/credential-history.js +109 -0
  23. package/lib/delegation.d.ts +16 -0
  24. package/lib/delegation.js +59 -0
  25. package/lib/discovery.d.ts +8 -0
  26. package/lib/discovery.js +227 -0
  27. package/lib/errors.d.ts +14 -0
  28. package/lib/errors.js +17 -0
  29. package/lib/file-secrets.d.ts +14 -0
  30. package/lib/file-secrets.js +148 -0
  31. package/lib/host-secrets.d.ts +16 -0
  32. package/lib/host-secrets.js +22 -0
  33. package/lib/host.d.ts +83 -0
  34. package/lib/host.js +1 -0
  35. package/lib/index.d.ts +21 -0
  36. package/lib/index.js +13 -0
  37. package/lib/keyring.d.ts +10 -0
  38. package/lib/keyring.js +64 -0
  39. package/lib/management-remote.d.ts +13 -0
  40. package/lib/management-remote.js +69 -0
  41. package/lib/network.d.ts +23 -0
  42. package/lib/network.js +91 -0
  43. package/lib/plugin.d.ts +75 -0
  44. package/lib/plugin.js +473 -0
  45. package/lib/product-api.d.ts +89 -0
  46. package/lib/product-api.js +25 -0
  47. package/lib/product-operations.d.ts +16 -0
  48. package/lib/product-operations.js +120 -0
  49. package/lib/protocol-adapter.d.ts +90 -0
  50. package/lib/protocol-adapter.js +75 -0
  51. package/lib/remote.d.ts +3 -0
  52. package/lib/remote.js +17 -0
  53. package/lib/store.d.ts +14 -0
  54. package/lib/store.js +128 -0
  55. package/lib/strict-json.d.ts +2 -0
  56. package/lib/strict-json.js +111 -0
  57. package/lib/tools.d.ts +5 -0
  58. package/lib/tools.js +29 -0
  59. package/lib/types.d.ts +202 -0
  60. package/lib/types.js +1 -0
  61. package/lib/util.d.ts +17 -0
  62. package/lib/util.js +66 -0
  63. package/package.json +155 -4
package/lib/client.js ADDED
@@ -0,0 +1,833 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
2
+ import { asCooldownKey, assertCooldownElapsed, cooldownError, recordCooldown, remainingCooldownMs, resourceCooldownKey, responseHeader } from './cooldown.js';
3
+ import { protocolFailure } from './auth-challenge.js';
4
+ import { archiveCredentials } from './credential-history.js';
5
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
6
+ import { PapDiscovery } from './discovery.js';
7
+ import { PapError, requirePap } from './errors.js';
8
+ import { PapStore } from './store.js';
9
+ import { DelegationAttemptFailure, newDelegation, thumbprint, verifyCompact } from './delegation.js';
10
+ import { httpsUrl, htu, id, isPapLoopbackRedirect, jwtParts, matchedRoute, parseJson, scopeFor, sha256 } from './util.js';
11
+ function secureEqual(a, b) {
12
+ return typeof a === 'string' && Buffer.byteLength(a) === Buffer.byteLength(b) && timingSafeEqual(Buffer.from(a), Buffer.from(b));
13
+ }
14
+ const protectedHeaders = new Set(['authorization', 'dpop', 'poppy-session', 'host', 'cookie', 'proxy-authorization', 'content-length', 'connection', 'transfer-encoding']);
15
+ function authenticationError(code, hint) {
16
+ if (code === 'insufficient_scope' || code === 'invalid_scope' || code === 'invalid_target' || code === 'invalid_client' || code === 'invalid_token' || code === 'invalid_dpop_proof' || code === 'unauthorized_client')
17
+ return new PapError(code, undefined, hint);
18
+ if (code === 'invalid_grant')
19
+ return new PapError('reauthorization_required', undefined, hint);
20
+ if (code === 'access_denied')
21
+ return new PapError('permission_denied', undefined, hint);
22
+ return new PapError('oauth_error', undefined, hint);
23
+ }
24
+ function sameBinding(subject, options) {
25
+ return JSON.stringify(subject.binding) === JSON.stringify(options.binding);
26
+ }
27
+ /** Host-only engine. UI receives safe summaries, never this object or signer capabilities. */
28
+ export class PapClient {
29
+ options;
30
+ transport;
31
+ store;
32
+ discovery;
33
+ clock;
34
+ closed = false;
35
+ discoveryFlights = new Map();
36
+ recentJtis = new Map();
37
+ cooldownWaiters = new Set();
38
+ refreshFlights = new Map();
39
+ abortedSessions = new Map();
40
+ constructor(options, transport) {
41
+ this.options = options;
42
+ this.transport = transport;
43
+ requirePap(options.inlineCooldownWaitMs === undefined || Number.isInteger(options.inlineCooldownWaitMs) && options.inlineCooldownWaitMs >= 0 && options.inlineCooldownWaitMs <= 5000, 'permission_denied');
44
+ this.clock = options.clock ?? (() => Date.now());
45
+ this.store = new PapStore(options.stateRoot, options.secretStore, sha256(JSON.stringify(options.binding)));
46
+ this.discovery = new PapDiscovery(transport, this.clock);
47
+ }
48
+ close() { this.closed = true; for (const waiter of this.cooldownWaiters)
49
+ waiter.controller.abort(new PapError('disconnected')); for (const active of this.abortedSessions.values())
50
+ for (const controller of active)
51
+ controller.abort(); this.options.identityHost.dispose?.(); }
52
+ async recover() { await this.store.recover(); }
53
+ async resolvePapCompany(domain) {
54
+ // Failed discovery never commits or erases a previous issuer.
55
+ const discovered = await this.discovery.resolve(domain);
56
+ const company = await this.store.transaction(state => {
57
+ const previous = Object.values(state.companies).find(x => x.domain === discovered.domain && x.companyId === discovered.companyId);
58
+ if (previous)
59
+ discovered.ref = previous.ref;
60
+ discovered.generation = state.generation + 1;
61
+ state.companies[discovered.ref] = discovered;
62
+ return discovered;
63
+ });
64
+ return { companyRef: company.ref, companyId: company.companyId, name: company.name, authMode: company.authMode, scopes: company.scopes };
65
+ }
66
+ async getOrCreatePapUser(companyRef, mode = 'regular', agent = false) {
67
+ requirePap(mode === 'regular' || mode === 'anonymous', 'permission_denied');
68
+ return this.store.transaction(state => {
69
+ const company = this.company(state, companyRef);
70
+ let subject = Object.values(state.subjects).find(x => x.companyId === company.companyId && sameBinding(x, this.options) && mode !== 'anonymous' && x.mode === mode && Boolean(x.agentId) === agent);
71
+ if (!subject) {
72
+ const sameUser = mode === 'regular' ? Object.values(state.subjects).find(x => x.companyId === company.companyId && sameBinding(x, this.options) && x.mode === 'regular') : undefined;
73
+ subject = { ref: id('subj'), companyRef, companyId: company.companyId, binding: this.options.binding, mode, userId: sameUser?.userId ?? id('usr'), ...(agent ? { agentId: id('agt') } : {}) };
74
+ state.subjects[subject.ref] = subject;
75
+ }
76
+ return { subjectRef: subject.ref };
77
+ });
78
+ }
79
+ async openPapSession(subjectRef, accountSlot) {
80
+ return this.store.transaction(state => {
81
+ const subject = this.subject(state, subjectRef);
82
+ requirePap(!accountSlot || subject.mode === 'regular', 'permission_denied');
83
+ requirePap(subject.mode !== 'anonymous' || !Object.values(state.sessions).some(x => x.subjectRef === subjectRef), 'permission_denied');
84
+ if (accountSlot)
85
+ requirePap(Object.values(state.grants).some(x => this.sameUser(state, x.subjectRef, subject) && x.accountSlot === accountSlot && x.status === 'active'), 'reauthorization_required');
86
+ const session = { ref: id('sesref'), subjectRef, sid: id('ses'), generation: 1, connected: true, ...(accountSlot ? { accountSlot } : {}) };
87
+ state.sessions[session.ref] = session;
88
+ return { sessionRef: session.ref };
89
+ });
90
+ }
91
+ company(state, ref) {
92
+ const company = state.companies[ref];
93
+ requirePap(company, 'discovery_failed');
94
+ const latest = Object.values(state.companies).filter(x => x.domain === company.domain).sort((a, b) => b.generation - a.generation)[0];
95
+ requirePap(!latest || latest.companyId === company.companyId, 'namespace_changed');
96
+ return company;
97
+ }
98
+ subject(state, ref) {
99
+ const subject = state.subjects[ref];
100
+ requirePap(subject && sameBinding(subject, this.options), 'permission_denied');
101
+ this.company(state, subject.companyRef);
102
+ return subject;
103
+ }
104
+ sameUser(state, subjectRef, expected) {
105
+ const subject = state.subjects[subjectRef];
106
+ return Boolean(subject && sameBinding(subject, this.options) && subject.companyId === expected.companyId && subject.userId === expected.userId);
107
+ }
108
+ assertAccountEpoch(state, subject, accountSlot, epoch) {
109
+ const slot = state.accountSlots[accountSlot];
110
+ requirePap(slot && this.sameUser(state, slot.subjectRef, subject) && slot.epoch === epoch && !slot.signedOut, 'reauthorization_required');
111
+ }
112
+ session(state, ref) {
113
+ const session = state.sessions[ref];
114
+ requirePap(session?.connected, 'disconnected');
115
+ this.subject(state, session.subjectRef);
116
+ return session;
117
+ }
118
+ async ensureCompanyFresh(subjectRef) {
119
+ const state = await this.store.read();
120
+ const subject = this.subject(state, subjectRef);
121
+ const company = this.company(state, subject.companyRef);
122
+ if (company.expiresAt > this.clock())
123
+ return;
124
+ const existing = this.discoveryFlights.get(company.domain);
125
+ if (existing)
126
+ return existing;
127
+ const flight = this.resolvePapCompany(company.domain).then(result => { requirePap(result.companyId === company.companyId, 'namespace_changed'); });
128
+ this.discoveryFlights.set(company.domain, flight);
129
+ try {
130
+ await flight;
131
+ }
132
+ finally {
133
+ if (this.discoveryFlights.get(company.domain) === flight)
134
+ this.discoveryFlights.delete(company.domain);
135
+ }
136
+ }
137
+ async lease(subject, purpose, resource, destination, requestedScopes = []) {
138
+ requirePap(!this.closed, 'disconnected');
139
+ const state = await this.store.read();
140
+ const company = this.company(state, subject.companyRef);
141
+ requirePap(company.expiresAt > this.clock(), 'discovery_failed');
142
+ const lease = await this.options.identityHost.acquire(subject.binding, company, purpose, resource, destination, requestedScopes);
143
+ const identity = await lease.getOAuthClientIdentity();
144
+ requirePap(identity.clientId === subject.binding.clientId, 'permission_denied');
145
+ await lease.assertActive();
146
+ return lease;
147
+ }
148
+ assertWaitActive(state, context) {
149
+ requirePap(!this.closed, 'disconnected');
150
+ const subject = this.subject(state, context.subjectRef);
151
+ if (context.sessionRef) {
152
+ const session = this.session(state, context.sessionRef);
153
+ requirePap(this.sameUser(state, session.subjectRef, subject), 'permission_denied');
154
+ if (context.accountSlot)
155
+ requirePap(session.accountSlot === context.accountSlot, 'reauthorization_required');
156
+ }
157
+ if (context.accountSlot && context.accountEpoch !== undefined) {
158
+ const slot = state.accountSlots[context.accountSlot];
159
+ requirePap(slot && this.sameUser(state, slot.subjectRef, subject) && slot.epoch === context.accountEpoch && slot.signedOut === Boolean(context.allowSignedOut), 'reauthorization_required');
160
+ }
161
+ if (context.transactionRef) {
162
+ const transaction = state.transactions[context.transactionRef];
163
+ requirePap(transaction?.status === 'exchanging' && transaction.expiresAt > this.clock() && transaction.accountSlot === context.accountSlot && transaction.accountEpoch === context.accountEpoch, 'reauthorization_required');
164
+ }
165
+ if (context.grantRef) {
166
+ const grant = state.grants[context.grantRef];
167
+ requirePap(grant && grant.generation === context.grantGeneration && grant.status === (context.allowSignedOut ? 'revocation_pending' : 'refreshing'), 'state_conflict');
168
+ }
169
+ }
170
+ async awaitCooldown(key, context) {
171
+ const controller = new AbortController();
172
+ const waiter = { controller, context };
173
+ this.cooldownWaiters.add(waiter);
174
+ try {
175
+ for (;;) {
176
+ if (controller.signal.aborted)
177
+ throw controller.signal.reason;
178
+ const state = await this.store.read();
179
+ this.assertWaitActive(state, context);
180
+ const remaining = remainingCooldownMs(state, key, this.clock());
181
+ if (remaining === 0n)
182
+ return;
183
+ if (remaining > BigInt(this.options.inlineCooldownWaitMs ?? 1500))
184
+ throw cooldownError(state, key, this.clock());
185
+ try {
186
+ await delay(Math.min(Number(remaining), 50), undefined, { signal: controller.signal });
187
+ }
188
+ catch {
189
+ if (controller.signal.aborted)
190
+ throw controller.signal.reason;
191
+ throw new PapError('disconnected');
192
+ }
193
+ }
194
+ }
195
+ finally {
196
+ this.cooldownWaiters.delete(waiter);
197
+ }
198
+ }
199
+ async rotateDelegation(subjectRef) {
200
+ const subject = this.subject(await this.store.read(), subjectRef);
201
+ const lease = await this.lease(subject, 'pap_session', subject.companyId);
202
+ await this.delegation(subject, lease, true);
203
+ }
204
+ async delegation(subject, lease, force = false, assertAllowed) {
205
+ await this.cleanupRetirements(subject, lease);
206
+ // Reserve before creating a key: failure/retirement must never roll back the create operation id.
207
+ const reservation = await this.store.transaction(state => {
208
+ this.subject(state, subject.ref);
209
+ assertAllowed?.(state);
210
+ const existing = state.delegations[subject.ref];
211
+ if (!force && existing && existing.expiresAt > this.clock() + 30000)
212
+ return { existing };
213
+ requirePap(!Object.values(state.retirements).some(x => x.subjectRef === subject.ref), 'delegation_blocked');
214
+ const current = state.delegationAttempts[subject.ref];
215
+ const attempt = current?.status === 'reserved' ? current : { sequence: (current?.sequence ?? 0) + 1, status: 'reserved' };
216
+ state.delegationAttempts[subject.ref] = attempt;
217
+ return { attempt };
218
+ });
219
+ if (reservation.existing) {
220
+ await this.cleanupRetirements(subject, lease);
221
+ return reservation.existing;
222
+ }
223
+ const attempt = reservation.attempt;
224
+ const result = await this.store.transaction(async (state) => {
225
+ this.subject(state, subject.ref);
226
+ assertAllowed?.(state);
227
+ const current = state.delegationAttempts[subject.ref];
228
+ requirePap(current?.sequence === attempt.sequence, 'state_conflict');
229
+ const existing = state.delegations[subject.ref];
230
+ if (current.status === 'issued' && existing)
231
+ return { delegation: existing };
232
+ requirePap(current.status === 'reserved', 'delegation_blocked');
233
+ try {
234
+ const next = await newDelegation(subject, lease, (existing?.generation ?? 0) + 1, this.clock(), `pap-delegation/${subject.ref}/attempt-${attempt.sequence}`);
235
+ state.delegations[subject.ref] = next;
236
+ current.status = 'issued';
237
+ if (existing)
238
+ state.retirements[existing.key.handle] = { subjectRef: subject.ref, key: existing.key };
239
+ return { delegation: next };
240
+ }
241
+ catch (error) {
242
+ if (error instanceof DelegationAttemptFailure) {
243
+ current.status = 'failed';
244
+ if (!error.retired)
245
+ state.retirements[error.key.handle] = { subjectRef: subject.ref, key: error.key };
246
+ }
247
+ // Before a handle was returned, creation may have succeeded: preserve its operation id for recovery.
248
+ if (!force && existing && existing.expiresAt > this.clock()) {
249
+ try {
250
+ await lease.assertActive();
251
+ return { delegation: existing };
252
+ }
253
+ catch (authorizationError) {
254
+ return { errorCode: authorizationError instanceof PapError ? authorizationError.code : 'permission_denied' };
255
+ }
256
+ }
257
+ return { errorCode: error instanceof PapError || error instanceof DelegationAttemptFailure ? error.code : 'delegation_blocked' };
258
+ }
259
+ });
260
+ if (result.errorCode)
261
+ throw new PapError(result.errorCode);
262
+ await this.cleanupRetirements(subject, lease);
263
+ return result.delegation;
264
+ }
265
+ async cleanupRetirements(subject, lease) {
266
+ const pending = Object.values((await this.store.read()).retirements).filter(x => x.subjectRef === subject.ref);
267
+ for (const item of pending) {
268
+ try {
269
+ await lease.retireTemporaryKey(item.key, scopeFor(subject));
270
+ await this.store.transaction(state => { delete state.retirements[item.key.handle]; });
271
+ }
272
+ catch { /* Keep the durable retry reference; an old Delegation is never selected again. */ }
273
+ }
274
+ }
275
+ async dispatchPap(sessionRef, operation, control = {}) {
276
+ // Freeze all caller inputs before any await, including the exact body and business idempotency key.
277
+ const frozen = structuredClone(operation);
278
+ const headers = {};
279
+ for (const [name, value] of Object.entries(frozen.headers ?? {})) {
280
+ requirePap(!protectedHeaders.has(name.toLowerCase()) && !/[\r\n]/.test(value), 'permission_denied');
281
+ headers[name.toLowerCase()] = value;
282
+ }
283
+ if (frozen.idempotencyKey) {
284
+ requirePap(!headers['idempotency-key'] || headers['idempotency-key'] === frozen.idempotencyKey, 'permission_denied');
285
+ headers['idempotency-key'] = frozen.idempotencyKey;
286
+ }
287
+ let retriedSession = false;
288
+ let retriedToken = false;
289
+ let retriedNonce = false;
290
+ let accountContext;
291
+ for (;;) {
292
+ requirePap(!control.signal?.aborted, 'disconnected');
293
+ const initial = await this.store.read();
294
+ const initialSession = this.session(initial, sessionRef);
295
+ const initialSubject = this.subject(initial, initialSession.subjectRef);
296
+ const initialCompany = this.company(initial, initialSubject.companyRef);
297
+ control.assertAllowed?.(initial);
298
+ const initialMethod = (frozen.method ?? 'GET').toUpperCase();
299
+ const initialUrl = httpsUrl(frozen.url).href;
300
+ const initialRoute = initialCompany.routes.find(route => route.method === initialMethod && matchedRoute(route.template, initialUrl));
301
+ requirePap(initialRoute, 'unsafe_destination');
302
+ const waitContext = { subjectRef: initialSubject.ref, sessionRef, accountSlot: initialSession.accountSlot, accountEpoch: initialSession.accountSlot ? initial.accountSlots[initialSession.accountSlot]?.epoch : undefined };
303
+ if (waitContext.accountSlot) {
304
+ requirePap(waitContext.accountEpoch !== undefined, 'reauthorization_required');
305
+ accountContext ??= { slot: waitContext.accountSlot, epoch: waitContext.accountEpoch };
306
+ }
307
+ await this.awaitCooldown(resourceCooldownKey(initialCompany.companyId, initialRoute.resource), waitContext);
308
+ await this.ensureCompanyFresh(initialSession.subjectRef);
309
+ const state = await this.store.read();
310
+ const session = this.session(state, sessionRef);
311
+ const subject = this.subject(state, session.subjectRef);
312
+ const company = this.company(state, subject.companyRef);
313
+ const method = (frozen.method ?? 'GET').toUpperCase();
314
+ const url = httpsUrl(frozen.url).href;
315
+ requirePap(/^[A-Z]+$/.test(method), 'unsafe_destination');
316
+ const reserved = [...company.sources.map(x => x.url), company.metadata?.token_endpoint, company.metadata?.authorization_endpoint, company.metadata?.revocation_endpoint].filter((x) => Boolean(x));
317
+ requirePap(!reserved.some(value => htu(value) === htu(url)), 'unsafe_destination');
318
+ const route = company.routes.find(x => x.method === method && matchedRoute(x.template, url));
319
+ requirePap(route, 'unsafe_destination');
320
+ requirePap(!frozen.resource || frozen.resource === route.resource, 'unsafe_destination');
321
+ let grant = session.accountSlot ? Object.values(state.grants).find(x => this.sameUser(state, x.subjectRef, subject) && x.accountSlot === session.accountSlot && x.resource === route.resource && ['active', 'refreshing'].includes(x.status)) : undefined;
322
+ if (session.accountSlot && !grant)
323
+ throw new PapError('reauthorization_required');
324
+ if (!session.accountSlot)
325
+ requirePap(!accountContext, 'reauthorization_required');
326
+ if (grant) {
327
+ this.assertAccountEpoch(state, subject, grant.accountSlot, grant.accountEpoch);
328
+ requirePap(!accountContext || accountContext.slot === grant.accountSlot && accountContext.epoch === grant.accountEpoch, 'reauthorization_required');
329
+ accountContext ??= { slot: grant.accountSlot, epoch: grant.accountEpoch };
330
+ if (grant.status === 'refreshing') {
331
+ await this.waitForRefresh(grant.ref, grant.accountEpoch, grant.generation);
332
+ retriedToken = true;
333
+ continue;
334
+ }
335
+ requirePap(subject.mode === 'regular' && grant.clientId === subject.binding.clientId && grant.issuer === company.issuer, 'permission_denied');
336
+ requirePap((frozen.scopes ?? []).every(x => grant.scope.includes(x)), 'insufficient_scope');
337
+ if ((grant.expiresAt !== undefined && grant.expiresAt <= this.clock()) || grant.usableUntil <= this.clock()) {
338
+ if (!retriedToken && grant.refreshToken) {
339
+ await this.refreshAccountGrant(grant.ref, sessionRef);
340
+ retriedToken = true;
341
+ continue;
342
+ }
343
+ throw new PapError('reauthorization_required');
344
+ }
345
+ }
346
+ else
347
+ requirePap((frozen.scopes?.length ?? 0) === 0, 'reauthorization_required');
348
+ const cooldownKey = resourceCooldownKey(company.companyId, route.resource);
349
+ const allowed = (current) => { requirePap(!control.signal?.aborted, 'disconnected'); control.assertAllowed?.(current); this.assertWaitActive(current, waitContext); assertCooldownElapsed(current, cooldownKey, this.clock()); };
350
+ const lease = await this.lease(subject, 'pap_session', route.resource, url);
351
+ const delegation = await this.delegation(subject, lease, false, allowed);
352
+ await this.store.transaction(current => allowed(current));
353
+ const sessionJwt = await lease.signPapSessionRequest({ key: delegation.key, scope: scopeFor(subject), delegation: delegation.compact, clientId: subject.binding.clientId, sid: session.sid, sub: subject.userId, agentId: subject.agentId, httpMethod: method, httpUri: url, accessToken: grant?.accessToken });
354
+ this.checkSessionJwt(sessionJwt, delegation, subject, session, method, url, grant?.accessToken);
355
+ const sentHeaders = { ...headers, 'poppy-session': sessionJwt };
356
+ let dpopLease;
357
+ if (grant) {
358
+ sentHeaders.authorization = `${grant.tokenType} ${grant.accessToken}`;
359
+ if (grant.tokenType === 'DPoP') {
360
+ requirePap(grant.dpopKey, 'credential_unavailable');
361
+ dpopLease = await this.lease(subject, 'pap_account', grant.resource, url, grant.scope);
362
+ try {
363
+ const nonce = await this.store.transaction(current => { allowed(current); return current.nonces[`resource:${new URL(url).origin}`]; });
364
+ sentHeaders.dpop = await dpopLease.signDpopProof({ key: grant.dpopKey, scope: scopeFor(subject, grant.accountSlot), httpMethod: method, httpUri: url, accessToken: grant.accessToken, nonce });
365
+ }
366
+ catch (error) {
367
+ if (error instanceof PapError && error.code === 'credential_unavailable')
368
+ await this.store.transaction(current => { const item = current.grants[grant.ref]; if (item?.generation === grant.generation) {
369
+ item.status = 'reauthorization_required';
370
+ item.generation++;
371
+ } });
372
+ throw error;
373
+ }
374
+ await dpopLease.assertActive();
375
+ }
376
+ }
377
+ await lease.assertActive();
378
+ const controller = new AbortController();
379
+ const cancel = () => controller.abort();
380
+ control.signal?.addEventListener('abort', cancel, { once: true });
381
+ if (control.signal?.aborted)
382
+ controller.abort();
383
+ const active = this.abortedSessions.get(sessionRef) ?? new Set();
384
+ active.add(controller);
385
+ this.abortedSessions.set(sessionRef, active);
386
+ let response;
387
+ try {
388
+ // Same lock orders local disconnect/signout versus actual dispatch. No await between final CAS and HTTP start.
389
+ response = await this.store.transaction(async (current) => {
390
+ try {
391
+ await lease.assertActive();
392
+ await dpopLease?.assertActive();
393
+ }
394
+ catch {
395
+ throw new PapError('permission_denied');
396
+ }
397
+ allowed(current);
398
+ requirePap(!controller.signal.aborted, 'disconnected');
399
+ const currentSession = this.session(current, sessionRef);
400
+ requirePap(this.company(current, subject.companyRef).generation === company.generation && currentSession.generation === session.generation && current.delegations[subject.ref]?.generation === delegation.generation, 'state_conflict');
401
+ if (grant) {
402
+ this.assertAccountEpoch(current, subject, grant.accountSlot, grant.accountEpoch);
403
+ requirePap(current.grants[grant.ref]?.generation === grant.generation && current.grants[grant.ref]?.status === 'active', 'state_conflict');
404
+ }
405
+ control.captureSecrets?.([sessionJwt, sentHeaders.dpop, sentHeaders.authorization, grant?.accessToken, grant?.refreshToken, subject.userId, subject.agentId, session.sid].filter((value) => Boolean(value)), grant ? { accessToken: grant.accessToken, refreshToken: grant.refreshToken, accessExpiresInMs: grant.expiresAt === undefined ? undefined : Math.max(0, grant.expiresAt - this.clock()) } : undefined);
406
+ const response = await this.transport.request(url, { method, headers: sentHeaders, body: frozen.body, signal: controller.signal });
407
+ if (response.status === 429)
408
+ recordCooldown(current, cooldownKey, response, this.clock());
409
+ return response;
410
+ });
411
+ }
412
+ catch (error) {
413
+ if (error instanceof PapError)
414
+ throw error;
415
+ throw new PapError('request_uncertain');
416
+ }
417
+ finally {
418
+ control.signal?.removeEventListener('abort', cancel);
419
+ active.delete(controller);
420
+ if (active.size === 0)
421
+ this.abortedSessions.delete(sessionRef);
422
+ }
423
+ if (response.status >= 300 && response.status < 400)
424
+ throw new PapError('redirect_blocked');
425
+ if (response.status === 429) {
426
+ const limited = await this.store.read();
427
+ throw cooldownError(limited, cooldownKey, this.clock());
428
+ }
429
+ const failure = protocolFailure(response, company, route.resource, grant?.tokenType);
430
+ const error = failure.code;
431
+ if (error === 'session_ended') {
432
+ if (retriedSession)
433
+ throw new PapError('session_ended');
434
+ retriedSession = true;
435
+ await this.store.transaction(current => { const value = this.session(current, sessionRef); requirePap(value.generation === session.generation, 'state_conflict'); value.sid = id('ses'); value.generation++; });
436
+ continue;
437
+ }
438
+ if (error === 'invalid_session')
439
+ throw new PapError('invalid_session');
440
+ if (grant && error === 'use_dpop_nonce' && responseHeader(response, 'dpop-nonce') && !retriedNonce) {
441
+ retriedNonce = true;
442
+ await this.saveNonce(`resource:${new URL(url).origin}`, responseHeader(response, 'dpop-nonce'));
443
+ if (['GET', 'HEAD', 'OPTIONS'].includes(method))
444
+ continue;
445
+ throw authenticationError('invalid_dpop_proof', failure.hint);
446
+ }
447
+ if (grant && error === 'invalid_token') {
448
+ if (!retriedToken && grant.refreshToken && ['GET', 'HEAD', 'OPTIONS'].includes(method)) {
449
+ retriedToken = true;
450
+ await this.refreshAccountGrant(grant.ref, sessionRef);
451
+ continue;
452
+ }
453
+ throw authenticationError('invalid_token', failure.hint);
454
+ }
455
+ if (error === 'insufficient_scope' || error === 'unauthorized_client' || failure.challenge)
456
+ throw authenticationError(error, failure.hint);
457
+ return response;
458
+ }
459
+ }
460
+ checkSessionJwt(jwt, delegation, subject, session, method, url, token) {
461
+ const { header, payload } = jwtParts(jwt);
462
+ requirePap(header.typ === 'poppy-session+jwt' && header.alg === 'ES256' && header.kid === delegation.key.thumbprint && header.poppy_dlg === delegation.compact && verifyCompact(jwt, delegation.key.public_jwk), 'delegation_blocked');
463
+ requirePap(payload.iss === subject.binding.clientId && payload.aud === subject.companyId && payload.sid === session.sid && payload.sub === subject.userId && payload.agent_id === subject.agentId && payload.htm === method && payload.htu === htu(url), 'delegation_blocked');
464
+ requirePap(Number.isSafeInteger(payload.iat) && Math.abs(Number(payload.iat) * 1000 - this.clock()) <= 10000 && typeof payload.jti === 'string' && payload.jti.length >= 16, 'delegation_blocked');
465
+ requirePap(Object.keys(payload).every(key => ['iss', 'aud', 'sid', 'sub', 'agent_id', 'htm', 'htu', 'iat', 'jti', 'ath'].includes(key)), 'delegation_blocked');
466
+ for (const [jti, expires] of this.recentJtis)
467
+ if (expires < this.clock())
468
+ this.recentJtis.delete(jti);
469
+ requirePap(!this.recentJtis.has(String(payload.jti)) && this.recentJtis.size < 8192, 'delegation_blocked');
470
+ this.recentJtis.set(String(payload.jti), this.clock() + 120000);
471
+ requirePap(token ? payload.ath === sha256(token) : !Object.hasOwn(payload, 'ath'), 'delegation_blocked');
472
+ }
473
+ async saveNonce(target, nonce) {
474
+ requirePap(nonce.length > 0 && nonce.length <= 1024 && !/[\r\n]/.test(nonce), 'oauth_error');
475
+ await this.store.transaction(state => { state.nonces[target] = nonce; });
476
+ }
477
+ async startSignIn(subjectRef, input) {
478
+ requirePap(input.method === undefined || input.method === 'direct', 'unsupported_sign_in');
479
+ await this.ensureCompanyFresh(subjectRef);
480
+ const state = await this.store.read();
481
+ const subject = this.subject(state, subjectRef);
482
+ const company = this.company(state, subject.companyRef);
483
+ requirePap(subject.mode === 'regular' && company.issuer && company.metadata && !company.authDisabled, 'unsupported_sign_in');
484
+ const metadata = company.metadata;
485
+ requirePap(metadata.grant_types_supported.includes('authorization_code') && metadata.grant_types_supported.includes('refresh_token') && metadata.revocation_endpoint && metadata.authorization_endpoint && metadata.token_endpoint_auth_methods_supported?.includes('private_key_jwt') && metadata.code_challenge_methods_supported?.includes('S256'), 'unsupported_sign_in');
486
+ requirePap(!metadata.dpop_signing_alg_values_supported?.length || metadata.dpop_signing_alg_values_supported.includes('ES256'), 'unsupported_sign_in');
487
+ const scope = [...new Set(input.scopes)].sort();
488
+ requirePap(scope.length > 0 && scope.every(x => company.scopes.includes(x) && /^[\x21\x23-\x5b\x5d-\x7e]+$/.test(x)) && company.routes.some(x => x.resource === input.resource), 'insufficient_scope');
489
+ const lease = await this.lease(subject, 'pap_account', input.resource, undefined, scope);
490
+ const identity = await lease.getOAuthClientIdentity();
491
+ const client = httpsUrl(subject.binding.clientId);
492
+ const loopback = isPapLoopbackRedirect(input.redirectUri);
493
+ requirePap(input.callbackMode === 'handoff' ? httpsUrl(input.redirectUri).hostname === client.hostname && this.options.callbackHandoff : loopback, 'unsupported_sign_in');
494
+ requirePap(identity.redirectUris?.some(value => {
495
+ if (value === input.redirectUri)
496
+ return true;
497
+ if (!loopback || !isPapLoopbackRedirect(value))
498
+ return false;
499
+ const configured = new URL(value);
500
+ configured.port = '';
501
+ const actual = new URL(input.redirectUri);
502
+ actual.port = '';
503
+ return configured.href === actual.href;
504
+ }), 'unsupported_sign_in');
505
+ const tx = { ref: id('auth'), subjectRef, accountSlot: id('acct'), issuer: company.issuer, clientId: subject.binding.clientId, redirectUri: input.redirectUri, state: id('state'), verifier: randomBytes(32).toString('base64url'), resource: input.resource, scope, expiresAt: this.clock() + 600000, accountEpoch: 1, status: 'pending' };
506
+ if (metadata.dpop_signing_alg_values_supported?.includes('ES256'))
507
+ tx.dpopKey = await lease.createDpopKey(scopeFor(subject, tx.accountSlot), `pap-dpop/${tx.ref}`);
508
+ await this.store.transaction(current => { this.subject(current, subjectRef); current.accountSlots[tx.accountSlot] = { subjectRef, epoch: tx.accountEpoch, signedOut: false }; current.transactions[tx.ref] = tx; });
509
+ if (input.callbackMode === 'handoff') {
510
+ const registered = await this.options.callbackHandoff.register({ transactionId: tx.ref, issuer: tx.issuer, clientId: tx.clientId, redirectUri: tx.redirectUri, state: tx.state, expiresAt: tx.expiresAt });
511
+ await this.store.transaction(current => { requirePap(current.transactions[tx.ref]?.status === 'pending', 'state_conflict'); current.transactions[tx.ref].callbackRef = registered.callbackRef; });
512
+ }
513
+ const authorize = httpsUrl(metadata.authorization_endpoint);
514
+ const params = authorize.searchParams;
515
+ for (const [key, value] of Object.entries({ response_type: 'code', client_id: tx.clientId, redirect_uri: tx.redirectUri, scope: scope.join(' '), state: tx.state, code_challenge: sha256(tx.verifier), code_challenge_method: 'S256', resource: tx.resource }))
516
+ params.set(key, value);
517
+ // This is user-only browser data. It must not be emitted to an LLM tool response.
518
+ return { transactionRef: tx.ref, authorizationUrl: authorize.href, accountSlot: tx.accountSlot };
519
+ }
520
+ async collectSignIn(transactionRef) {
521
+ const tx = (await this.store.read()).transactions[transactionRef];
522
+ requirePap(tx?.callbackRef && this.options.callbackHandoff, 'invalid_authorization_response');
523
+ return this.completeSignIn(transactionRef, await this.options.callbackHandoff.collect(tx.callbackRef));
524
+ }
525
+ /** Called only by an authenticated Host callback listener or handoff, never an arbitrary model tool. */
526
+ async completeSignIn(transactionRef, response) {
527
+ const tx = await this.store.transaction(state => {
528
+ const tx = state.transactions[transactionRef];
529
+ requirePap(tx && tx.status === 'pending' && tx.expiresAt > this.clock(), 'invalid_authorization_response');
530
+ const subject = this.subject(state, tx.subjectRef);
531
+ this.assertAccountEpoch(state, subject, tx.accountSlot, tx.accountEpoch);
532
+ requirePap(secureEqual(response.state, tx.state) && secureEqual(response.iss, tx.issuer), 'invalid_authorization_response');
533
+ if (response.error) {
534
+ tx.status = 'failed';
535
+ return { ...tx, status: 'failed' };
536
+ }
537
+ requirePap(typeof response.code === 'string' && response.code.length > 0 && response.code.length <= 8192, 'invalid_authorization_response');
538
+ tx.status = 'exchanging';
539
+ tx.exchangeInFlight = true;
540
+ return tx;
541
+ });
542
+ if (tx.status === 'failed')
543
+ throw new PapError('oauth_error');
544
+ const state = await this.store.read();
545
+ const subject = this.subject(state, tx.subjectRef);
546
+ const company = this.company(state, subject.companyRef);
547
+ try {
548
+ const result = await this.tokenRequest(subject, company, tx.resource, tx.accountSlot, tx.dpopKey, { grant_type: 'authorization_code', code: response.code, redirect_uri: tx.redirectUri, code_verifier: tx.verifier, resource: tx.resource }, { scopes: tx.scope, accountEpoch: tx.accountEpoch, transactionRef: tx.ref });
549
+ const grant = this.parseGrant(result, tx, tx.dpopKey);
550
+ requirePap(grant.refreshToken, 'oauth_error');
551
+ const committed = await this.store.transaction(current => {
552
+ archiveCredentials(current, [grant.accessToken, grant.refreshToken]);
553
+ const subject = this.subject(current, tx.subjectRef);
554
+ const active = current.transactions[tx.ref];
555
+ const slot = current.accountSlots[tx.accountSlot];
556
+ const valid = active?.status === 'exchanging' && active.expiresAt > this.clock() && slot?.epoch === tx.accountEpoch && !slot.signedOut && this.sameUser(current, slot.subjectRef, subject);
557
+ if (valid)
558
+ current.grants[grant.ref] = grant;
559
+ else
560
+ current.grants[grant.ref] = { ...grant, accessToken: '', status: 'revocation_pending' };
561
+ delete current.transactions[tx.ref];
562
+ return valid;
563
+ });
564
+ requirePap(committed, 'revocation_pending');
565
+ return { grantRef: grant.ref, accountSlot: grant.accountSlot };
566
+ }
567
+ catch (error) {
568
+ await this.store.transaction(current => {
569
+ const active = current.transactions[tx.ref];
570
+ if (active) {
571
+ if (active.status !== 'cancelled')
572
+ active.status = 'failed';
573
+ active.verifier = '';
574
+ active.exchangeInFlight = error instanceof PapError && error.code === 'request_uncertain';
575
+ }
576
+ });
577
+ throw error;
578
+ }
579
+ }
580
+ parseGrant(response, tx, dpopKey, prior) {
581
+ const body = parseJson(response);
582
+ requirePap(response.status === 200 && body.error === undefined && typeof body.access_token === 'string' && body.access_token.length > 0 && body.access_token.length <= 65536 && !/[\r\n]/.test(body.access_token), 'oauth_error');
583
+ requirePap(typeof body.token_type === 'string' && ['bearer', 'dpop'].includes(body.token_type.toLowerCase()), 'oauth_error');
584
+ const type = body.token_type.toLowerCase() === 'dpop' ? 'DPoP' : 'Bearer';
585
+ requirePap(type !== 'DPoP' || dpopKey, 'credential_unavailable');
586
+ requirePap(!prior || prior.tokenType !== 'DPoP' || type === 'DPoP', 'credential_unavailable');
587
+ const scope = body.scope === undefined ? tx.scope : typeof body.scope === 'string' ? [...new Set(body.scope.split(' ').filter(Boolean))].sort() : [];
588
+ requirePap(scope.length > 0 && scope.every(x => tx.scope.includes(x)), 'insufficient_scope');
589
+ requirePap(body.expires_in === undefined || typeof body.expires_in === 'number' && Number.isFinite(body.expires_in) && body.expires_in > 0, 'oauth_error');
590
+ requirePap(body.refresh_token === undefined || typeof body.refresh_token === 'string' && body.refresh_token.length > 0 && body.refresh_token.length <= 65536, 'oauth_error');
591
+ const grant = { ref: prior?.ref ?? id('grant'), subjectRef: tx.subjectRef, accountSlot: tx.accountSlot, issuer: tx.issuer, clientId: tx.clientId, resource: tx.resource, scope,
592
+ accessToken: body.access_token, refreshToken: body.refresh_token ?? prior?.refreshToken, tokenType: type, dpopKey,
593
+ expiresAt: body.expires_in === undefined ? undefined : this.clock() + Number(body.expires_in) * 1000,
594
+ usableUntil: this.clock() + 86400000, accountEpoch: tx.accountEpoch, generation: (prior?.generation ?? 0) + 1, status: 'active' };
595
+ this.options.observeCredentials?.({ accessToken: grant.accessToken, refreshToken: grant.refreshToken, accessExpiresInMs: body.expires_in === undefined ? undefined : Number(body.expires_in) * 1000 });
596
+ return grant;
597
+ }
598
+ async tokenRequest(subject, company, resource, accountSlot, key, fields, control) {
599
+ requirePap(company.issuer, 'unsupported_sign_in');
600
+ const cooldownKey = asCooldownKey(company.companyId, company.issuer);
601
+ const context = { subjectRef: subject.ref, sessionRef: control.sessionRef, accountSlot, accountEpoch: control.accountEpoch, allowSignedOut: control.purpose === 'revoke', grantRef: control.grantRef, grantGeneration: control.grantGeneration, transactionRef: control.transactionRef };
602
+ let retriedNonce = false;
603
+ for (;;) {
604
+ await this.awaitCooldown(cooldownKey, context);
605
+ await this.ensureCompanyFresh(subject.ref);
606
+ const latest = await this.store.read();
607
+ const currentSubject = this.subject(latest, subject.ref);
608
+ const currentCompany = this.company(latest, currentSubject.companyRef);
609
+ this.assertWaitActive(latest, context);
610
+ requirePap(currentCompany.issuer === company.issuer && currentCompany.routes.some(route => route.resource === resource), 'namespace_changed');
611
+ if (control.purpose !== 'revoke') {
612
+ const currentScopes = control.grantRef ? latest.grants[control.grantRef]?.scope : control.transactionRef ? latest.transactions[control.transactionRef]?.scope : undefined;
613
+ requirePap(currentScopes && control.scopes.every(scope => currentScopes.includes(scope)), 'insufficient_scope');
614
+ }
615
+ const target = control.purpose === 'revoke' ? currentCompany.metadata?.revocation_endpoint : currentCompany.metadata?.token_endpoint;
616
+ requirePap(target, 'unsupported_sign_in');
617
+ httpsUrl(target);
618
+ const lease = await this.lease(currentSubject, 'pap_account', resource, target, control.scopes);
619
+ await this.store.transaction(state => { this.assertWaitActive(state, context); assertCooldownElapsed(state, cooldownKey, this.clock()); });
620
+ const assertion = await lease.signOAuthClientAssertion();
621
+ const parts = jwtParts(assertion);
622
+ requirePap(parts.payload.iss === subject.binding.clientId && parts.payload.sub === subject.binding.clientId && parts.payload.aud === company.issuer, 'permission_denied');
623
+ const body = new URLSearchParams({ ...fields, client_id: subject.binding.clientId, client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', client_assertion: assertion }).toString();
624
+ const headers = { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' };
625
+ if (key) {
626
+ requirePap(key.purpose === 'pap_dpop' && thumbprint(key.public_jwk) === key.thumbprint, 'credential_unavailable');
627
+ const nonce = await this.store.transaction(state => { this.assertWaitActive(state, context); assertCooldownElapsed(state, cooldownKey, this.clock()); return state.nonces[`authorization:${company.issuer}`]; });
628
+ headers.dpop = await lease.signDpopProof({ key, scope: scopeFor(subject, accountSlot), httpMethod: 'POST', httpUri: target, nonce });
629
+ }
630
+ await lease.assertActive();
631
+ const signed = { body, headers };
632
+ let sent;
633
+ await this.store.transaction(async (state) => {
634
+ try {
635
+ await lease.assertActive();
636
+ }
637
+ catch {
638
+ throw new PapError('permission_denied');
639
+ }
640
+ this.assertWaitActive(state, context);
641
+ assertCooldownElapsed(state, cooldownKey, this.clock());
642
+ archiveCredentials(state, [fields.refresh_token, control.purpose === 'revoke' ? fields.token : undefined]);
643
+ sent = this.transport.request(target, { method: 'POST', headers: signed.headers, body: signed.body }).then(response => ({ response }), () => ({ failed: true }));
644
+ });
645
+ const outcome = await sent;
646
+ requirePap(outcome.response && !outcome.failed, 'request_uncertain');
647
+ const result = outcome.response;
648
+ if (control.purpose !== 'revoke' && result.status === 200) {
649
+ const received = parseJson(result);
650
+ const values = [received.access_token, received.refresh_token].filter((value) => typeof value === 'string' && value.length > 0 && value.length <= 65536);
651
+ if (values.length)
652
+ await this.store.transaction(state => archiveCredentials(state, values));
653
+ }
654
+ if (result.status === 429) {
655
+ await this.store.transaction(state => recordCooldown(state, cooldownKey, result, this.clock()));
656
+ throw cooldownError(await this.store.read(), cooldownKey, this.clock());
657
+ }
658
+ requirePap(result.status < 300 || result.status >= 400, 'redirect_blocked');
659
+ const failure = protocolFailure(result, currentCompany, resource, key ? 'DPoP' : undefined);
660
+ if (key && failure.code === 'use_dpop_nonce' && responseHeader(result, 'dpop-nonce') && !retriedNonce) {
661
+ retriedNonce = true;
662
+ await this.saveNonce(`authorization:${company.issuer}`, responseHeader(result, 'dpop-nonce'));
663
+ continue;
664
+ }
665
+ if (failure.code === 'invalid_grant' || failure.code === 'unauthorized_client' || failure.challenge)
666
+ throw authenticationError(failure.code, failure.hint);
667
+ return result;
668
+ }
669
+ }
670
+ async refreshAccountGrant(grantRef, sessionRef) {
671
+ const prior = this.refreshFlights.get(grantRef);
672
+ if (prior)
673
+ return prior;
674
+ const flight = this.refresh(grantRef, sessionRef);
675
+ this.refreshFlights.set(grantRef, flight);
676
+ try {
677
+ await flight;
678
+ }
679
+ finally {
680
+ if (this.refreshFlights.get(grantRef) === flight)
681
+ this.refreshFlights.delete(grantRef);
682
+ }
683
+ }
684
+ async waitForRefresh(grantRef, accountEpoch, refreshingGeneration, joinLocal = true) {
685
+ const local = joinLocal ? this.refreshFlights.get(grantRef) : undefined;
686
+ if (local)
687
+ await local;
688
+ for (let attempt = 0; attempt < 120; attempt++) {
689
+ const state = await this.store.read();
690
+ const grant = state.grants[grantRef];
691
+ requirePap(grant && grant.accountEpoch === accountEpoch, 'reauthorization_required');
692
+ this.assertAccountEpoch(state, this.subject(state, grant.subjectRef), grant.accountSlot, accountEpoch);
693
+ if (grant.status === 'active' && grant.generation > refreshingGeneration) {
694
+ const subject = this.subject(state, grant.subjectRef);
695
+ assertCooldownElapsed(state, asCooldownKey(subject.companyId, grant.issuer), this.clock());
696
+ return;
697
+ }
698
+ requirePap(grant.status === 'refreshing' && grant.generation === refreshingGeneration, 'reauthorization_required');
699
+ await new Promise(resolve => setTimeout(resolve, 100));
700
+ }
701
+ throw new PapError('reauthorization_required');
702
+ }
703
+ async refresh(grantRef, sessionRef) {
704
+ const claim = await this.store.transaction(state => {
705
+ const grant = state.grants[grantRef];
706
+ requirePap(grant && grant.refreshToken, 'reauthorization_required');
707
+ this.assertAccountEpoch(state, this.subject(state, grant.subjectRef), grant.accountSlot, grant.accountEpoch);
708
+ if (grant.status === 'refreshing')
709
+ return { waiting: grant };
710
+ requirePap(grant.status === 'active', 'reauthorization_required');
711
+ grant.status = 'refreshing';
712
+ grant.generation++;
713
+ return { claimed: grant };
714
+ });
715
+ if (claim.waiting) {
716
+ await this.waitForRefresh(grantRef, claim.waiting.accountEpoch, claim.waiting.generation, false);
717
+ return;
718
+ }
719
+ const claimed = claim.claimed;
720
+ const state = await this.store.read();
721
+ const subject = this.subject(state, claimed.subjectRef);
722
+ const company = this.company(state, subject.companyRef);
723
+ try {
724
+ const response = await this.tokenRequest(subject, company, claimed.resource, claimed.accountSlot, claimed.dpopKey, { grant_type: 'refresh_token', refresh_token: claimed.refreshToken, resource: claimed.resource, scope: claimed.scope.join(' ') }, { scopes: claimed.scope, accountEpoch: claimed.accountEpoch, sessionRef, grantRef: claimed.ref, grantGeneration: claimed.generation });
725
+ const next = this.parseGrant(response, claimed, claimed.dpopKey, claimed);
726
+ const committed = await this.store.transaction(current => {
727
+ archiveCredentials(current, [claimed.accessToken, claimed.refreshToken, next.accessToken, next.refreshToken]);
728
+ const grant = current.grants[grantRef];
729
+ if (grant?.generation === claimed.generation && grant.status === 'refreshing' && current.accountSlots[claimed.accountSlot]?.epoch === claimed.accountEpoch && !current.accountSlots[claimed.accountSlot]?.signedOut) {
730
+ current.grants[grantRef] = next;
731
+ return true;
732
+ }
733
+ if (grant && ['signed_out', 'revocation_pending'].includes(grant.status) && next.refreshToken) {
734
+ const pending = { ...next, ref: id('pending_revoke'), accessToken: '', status: 'revocation_pending' };
735
+ current.grants[pending.ref] = pending;
736
+ }
737
+ return false;
738
+ });
739
+ requirePap(committed, 'revocation_pending');
740
+ }
741
+ catch (error) {
742
+ await this.store.transaction(current => { const grant = current.grants[grantRef]; if (grant?.generation === claimed.generation && grant.status === 'refreshing') {
743
+ grant.status = error instanceof PapError && error.code === 'request_uncertain' ? 'uncertain' : error instanceof PapError && ['rate_limited', 'disconnected'].includes(error.code) ? 'active' : 'reauthorization_required';
744
+ grant.generation++;
745
+ } });
746
+ throw error;
747
+ }
748
+ }
749
+ async signOutAccount(subjectRef, accountSlot) {
750
+ let refreshInFlight = false;
751
+ const grants = await this.store.transaction(state => {
752
+ const subject = this.subject(state, subjectRef);
753
+ const slot = state.accountSlots[accountSlot];
754
+ requirePap(!slot || this.sameUser(state, slot.subjectRef, subject), 'permission_denied');
755
+ state.accountSlots[accountSlot] = { subjectRef, epoch: (slot?.epoch ?? 0) + 1, signedOut: true };
756
+ for (const tx of Object.values(state.transactions))
757
+ if (this.sameUser(state, tx.subjectRef, subject) && tx.accountSlot === accountSlot) {
758
+ if (tx.status === 'exchanging' || tx.exchangeInFlight)
759
+ refreshInFlight = true;
760
+ tx.status = 'cancelled';
761
+ tx.verifier = '';
762
+ delete tx.callbackRef;
763
+ }
764
+ const grants = Object.values(state.grants).filter(x => this.sameUser(state, x.subjectRef, subject) && x.accountSlot === accountSlot && x.status !== 'signed_out');
765
+ for (const grant of grants) {
766
+ archiveCredentials(state, [grant.accessToken, grant.refreshToken]);
767
+ if (grant.status === 'refreshing')
768
+ refreshInFlight = true;
769
+ grant.status = grant.refreshToken ? 'revocation_pending' : 'signed_out';
770
+ grant.accessToken = '';
771
+ grant.generation++;
772
+ }
773
+ for (const session of Object.values(state.sessions))
774
+ if (this.sameUser(state, session.subjectRef, subject) && session.accountSlot === accountSlot) {
775
+ delete session.accountSlot;
776
+ session.generation++;
777
+ }
778
+ return grants;
779
+ });
780
+ for (const waiter of this.cooldownWaiters)
781
+ if (waiter.context.accountSlot === accountSlot)
782
+ waiter.controller.abort(new PapError('reauthorization_required'));
783
+ let pending = refreshInFlight;
784
+ for (const grant of grants) {
785
+ if (!grant.refreshToken)
786
+ continue;
787
+ const state = await this.store.read();
788
+ const subject = this.subject(state, subjectRef);
789
+ const company = this.company(state, subject.companyRef);
790
+ try {
791
+ requirePap(company.metadata?.revocation_endpoint, 'revocation_pending');
792
+ const result = await this.tokenRequest(subject, company, grant.resource, grant.accountSlot, grant.dpopKey, { token: grant.refreshToken, token_type_hint: 'refresh_token' }, { purpose: 'revoke', scopes: grant.scope, accountEpoch: state.accountSlots[grant.accountSlot].epoch, grantRef: grant.ref, grantGeneration: grant.generation });
793
+ requirePap(result.status === 200, 'revocation_pending');
794
+ await this.store.transaction(current => { const item = current.grants[grant.ref]; if (item && item.generation === grant.generation && item.status === 'revocation_pending') {
795
+ item.status = 'signed_out';
796
+ delete item.refreshToken;
797
+ item.generation++;
798
+ } });
799
+ }
800
+ catch {
801
+ pending = true;
802
+ }
803
+ }
804
+ return { status: pending ? 'revocation_pending' : 'signed_out' };
805
+ }
806
+ async disconnectPap(sessionRef) {
807
+ for (const waiter of this.cooldownWaiters)
808
+ if (waiter.context.sessionRef === sessionRef)
809
+ waiter.controller.abort(new PapError('disconnected'));
810
+ for (const controller of this.abortedSessions.get(sessionRef) ?? [])
811
+ controller.abort();
812
+ const ended = await this.store.transaction(state => { const session = state.sessions[sessionRef]; requirePap(session, 'disconnected'); const subject = this.subject(state, session.subjectRef); session.connected = false; session.generation++; return { subject, delegation: state.delegations[subject.ref] }; });
813
+ if (ended.subject.mode === 'anonymous') {
814
+ try {
815
+ if (ended.delegation) {
816
+ const lease = await this.lease(ended.subject, 'pap_session', ended.subject.companyId);
817
+ await lease.retireTemporaryKey(ended.delegation.key, scopeFor(ended.subject));
818
+ }
819
+ }
820
+ finally {
821
+ await this.store.transaction(state => { delete state.subjects[ended.subject.ref]; delete state.delegations[ended.subject.ref]; delete state.delegationAttempts[ended.subject.ref]; for (const [key, value] of Object.entries(state.retirements))
822
+ if (value.subjectRef === ended.subject.ref)
823
+ delete state.retirements[key]; });
824
+ }
825
+ }
826
+ }
827
+ async getPapStatus() {
828
+ const state = await this.store.read();
829
+ return { companies: Object.values(state.companies).map(x => ({ companyRef: x.ref, name: x.name, authMode: x.authMode })),
830
+ sessions: Object.values(state.sessions).map(x => ({ sessionRef: x.ref, connected: x.connected, account: Boolean(x.accountSlot) })),
831
+ accounts: Object.values(state.grants).map(x => ({ grantRef: x.ref, status: x.status, scopes: x.scope })), supportedSignIn: ['direct'] };
832
+ }
833
+ }