@icp-sdk/auth 8.0.3 → 9.0.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/dist/esm/client/app-delegation-source.d.ts +65 -0
- package/dist/esm/client/app-delegation-source.js +58 -0
- package/dist/esm/client/app-delegation-source.js.map +1 -0
- package/dist/esm/client/app-lock.d.ts +63 -0
- package/dist/esm/client/app-lock.js +92 -0
- package/dist/esm/client/app-lock.js.map +1 -0
- package/dist/esm/client/auth-client.d.ts +270 -53
- package/dist/esm/client/auth-client.js +1017 -414
- package/dist/esm/client/auth-client.js.map +1 -1
- package/dist/esm/client/base64.d.ts +8 -0
- package/dist/esm/client/base64.js +28 -0
- package/dist/esm/client/base64.js.map +1 -0
- package/dist/esm/client/cookie-state-storage.d.ts +59 -0
- package/dist/esm/client/cookie-state-storage.js +245 -0
- package/dist/esm/client/cookie-state-storage.js.map +1 -0
- package/dist/esm/client/credential-storage.d.ts +48 -0
- package/dist/esm/client/credential-storage.js +2 -0
- package/dist/esm/client/credential-storage.js.map +1 -0
- package/dist/esm/client/db.d.ts +6 -1
- package/dist/esm/client/db.js +56 -15
- package/dist/esm/client/db.js.map +1 -1
- package/dist/esm/client/foreground-refresh.d.ts +35 -0
- package/dist/esm/client/foreground-refresh.js +83 -0
- package/dist/esm/client/foreground-refresh.js.map +1 -0
- package/dist/esm/client/idb-credential-storage.d.ts +25 -0
- package/dist/esm/client/idb-credential-storage.js +69 -0
- package/dist/esm/client/idb-credential-storage.js.map +1 -0
- package/dist/esm/client/index.d.ts +14 -3
- package/dist/esm/client/index.js +13 -3
- package/dist/esm/client/index.js.map +1 -1
- package/dist/esm/client/local-credential-storage.d.ts +26 -0
- package/dist/esm/client/local-credential-storage.js +59 -0
- package/dist/esm/client/local-credential-storage.js.map +1 -0
- package/dist/esm/client/memory-credential-storage.d.ts +22 -0
- package/dist/esm/client/memory-credential-storage.js +35 -0
- package/dist/esm/client/memory-credential-storage.js.map +1 -0
- package/dist/esm/client/session-delegation.d.ts +40 -0
- package/dist/esm/client/session-delegation.js +100 -0
- package/dist/esm/client/session-delegation.js.map +1 -0
- package/dist/esm/client/session-identity.d.ts +97 -0
- package/dist/esm/client/session-identity.js +392 -0
- package/dist/esm/client/session-identity.js.map +1 -0
- package/dist/esm/client/session-minter.d.ts +94 -0
- package/dist/esm/client/session-minter.js +185 -0
- package/dist/esm/client/session-minter.js.map +1 -0
- package/dist/esm/client/shared-memory-credential-storage.d.ts +44 -0
- package/dist/esm/client/shared-memory-credential-storage.js +243 -0
- package/dist/esm/client/shared-memory-credential-storage.js.map +1 -0
- package/dist/esm/client/slots.d.ts +59 -0
- package/dist/esm/client/slots.js +67 -0
- package/dist/esm/client/slots.js.map +1 -0
- package/dist/esm/client/sso.d.ts +33 -0
- package/dist/esm/client/sso.js +120 -0
- package/dist/esm/client/sso.js.map +1 -0
- package/dist/esm/client/state-storage.d.ts +135 -0
- package/dist/esm/client/state-storage.js +178 -0
- package/dist/esm/client/state-storage.js.map +1 -0
- package/package.json +2 -1
- package/src/client/app-delegation-source.ts +86 -0
- package/src/client/app-lock.ts +120 -0
- package/src/client/auth-client.ts +1300 -487
- package/src/client/base64.ts +28 -0
- package/src/client/cookie-state-storage.ts +277 -0
- package/src/client/credential-storage.ts +56 -0
- package/src/client/db.ts +56 -12
- package/src/client/foreground-refresh.ts +92 -0
- package/src/client/idb-credential-storage.ts +82 -0
- package/src/client/index.ts +24 -8
- package/src/client/local-credential-storage.ts +68 -0
- package/src/client/memory-credential-storage.ts +41 -0
- package/src/client/session-delegation.ts +138 -0
- package/src/client/session-identity.ts +510 -0
- package/src/client/session-minter.ts +286 -0
- package/src/client/shared-memory-credential-storage.ts +303 -0
- package/src/client/slots.ts +78 -0
- package/src/client/sso.ts +122 -0
- package/src/client/state-storage.ts +284 -0
- package/dist/esm/client/idle-manager.d.ts +0 -83
- package/dist/esm/client/idle-manager.js +0 -100
- package/dist/esm/client/idle-manager.js.map +0 -1
- package/dist/esm/client/storage.d.ts +0 -51
- package/dist/esm/client/storage.js +0 -90
- package/dist/esm/client/storage.js.map +0 -1
- package/src/client/idle-manager.ts +0 -162
- package/src/client/storage.ts +0 -114
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
import type { DerEncodedPublicKey, HttpAgentRequest } from '@icp-sdk/core/agent';
|
|
2
|
+
import { DelegationChain, DelegationIdentity } from '@icp-sdk/core/identity';
|
|
3
|
+
import { Principal } from '@icp-sdk/core/principal';
|
|
4
|
+
import {
|
|
5
|
+
AccountMismatchError,
|
|
6
|
+
type AppDelegationSource,
|
|
7
|
+
chainAuthorisesKey,
|
|
8
|
+
SessionGoneError,
|
|
9
|
+
SessionUnsupportedError,
|
|
10
|
+
} from './app-delegation-source.js';
|
|
11
|
+
import { withMintLock } from './app-lock.js';
|
|
12
|
+
import type { Credential, CredentialStorage } from './credential-storage.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* How much life a delegation needs left to be handed to a caller. Covers a
|
|
16
|
+
* request's flight time, because the delegation has to still be valid when the
|
|
17
|
+
* replica verifies the request it was attached to.
|
|
18
|
+
*/
|
|
19
|
+
const BLOCK_MARGIN_MS = 10_000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* When a replacement is minted. Covers one mint and nothing more: minting early
|
|
23
|
+
* discards the rest of a delegation's life, so an active session consumes
|
|
24
|
+
* lifetime at `ttl / (ttl - threshold)` and every second here costs update calls
|
|
25
|
+
* on every active session. A refresh is scheduled for this moment rather than
|
|
26
|
+
* waiting for a request to arrive inside the window, which is what keeps it small.
|
|
27
|
+
*/
|
|
28
|
+
const PRE_MINT_THRESHOLD_MS = 15_000;
|
|
29
|
+
|
|
30
|
+
/** A credential that is certainly whole: only a ceremony's slot holds a key alone. */
|
|
31
|
+
type Held = Required<Credential>;
|
|
32
|
+
|
|
33
|
+
const expiresAtMs = (chain: DelegationChain): number =>
|
|
34
|
+
Number(
|
|
35
|
+
chain.delegations.reduce(
|
|
36
|
+
(soonest, { delegation }) =>
|
|
37
|
+
delegation.expiration < soonest ? delegation.expiration : soonest,
|
|
38
|
+
chain.delegations[0]?.delegation.expiration ?? 0n,
|
|
39
|
+
) / 1_000_000n,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const msLeftOf = (chain: DelegationChain): number => expiresAtMs(chain) - Date.now();
|
|
43
|
+
|
|
44
|
+
/** The account a chain is rooted at. */
|
|
45
|
+
const accountOf = (chain: DelegationChain): Principal =>
|
|
46
|
+
Principal.selfAuthenticating(new Uint8Array(chain.publicKey));
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether a credential may be used for `expected`.
|
|
50
|
+
*
|
|
51
|
+
* Two checks at opposite ends of the chain. The leaf must be the key stored
|
|
52
|
+
* beside it, which is true of any whole record whoever it belongs to. And the
|
|
53
|
+
* root must be the account the caller names — a credential left by another
|
|
54
|
+
* account would otherwise decide who this identity is, and requests would go
|
|
55
|
+
* out as that account and succeed until its delegation expired.
|
|
56
|
+
*
|
|
57
|
+
* `expected` is absent only where the caller has no account to name, which is
|
|
58
|
+
* why a read that cannot check it does not adopt.
|
|
59
|
+
*/
|
|
60
|
+
const isForAccount = (held: Held, expected: Principal | undefined): boolean => {
|
|
61
|
+
if (!chainAuthorisesKey(held.chain, held.identity.getPublicKey().toDer())) return false;
|
|
62
|
+
return expected === undefined || accountOf(held.chain).compareTo(expected) === 'eq';
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Reads what the slot holds, or `undefined` where it holds nothing usable.
|
|
67
|
+
*
|
|
68
|
+
* @param evictSpent - Whether a spent record may be removed, which only a caller
|
|
69
|
+
* holding the mint lock may do. The store reaches every tab of the origin, so a
|
|
70
|
+
* lock-free read that removed what it declined could delete a credential
|
|
71
|
+
* another tab minted between the read and the removal.
|
|
72
|
+
* @param verify - Rejects a credential not rooted at the account already
|
|
73
|
+
* established. Absent on the first read of all, where there is no account to
|
|
74
|
+
* compare against and the first mint is what establishes one.
|
|
75
|
+
*/
|
|
76
|
+
async function readSlot({
|
|
77
|
+
storage,
|
|
78
|
+
slot,
|
|
79
|
+
evictSpent,
|
|
80
|
+
verify,
|
|
81
|
+
}: {
|
|
82
|
+
storage: CredentialStorage;
|
|
83
|
+
slot: string;
|
|
84
|
+
evictSpent: boolean;
|
|
85
|
+
verify?: (credential: Held) => boolean;
|
|
86
|
+
}): Promise<Held | undefined> {
|
|
87
|
+
const stored = await storage.get(slot).catch(() => null);
|
|
88
|
+
if (!stored?.chain) return undefined;
|
|
89
|
+
|
|
90
|
+
// A spent credential goes rather than merely being declined. The state is the
|
|
91
|
+
// only thing meant to outlive a delegation, and it records who is signed in on
|
|
92
|
+
// its own, so nothing here is worth keeping once it can no longer sign. This is
|
|
93
|
+
// what bounds a stored credential at one delegation lifetime with nothing
|
|
94
|
+
// having to fire — there is no dependable signal for a browser closing.
|
|
95
|
+
//
|
|
96
|
+
// Both halves together, because they are one record: the key is the thing that
|
|
97
|
+
// signs, and a chain without it authorises nothing.
|
|
98
|
+
if (msLeftOf(stored.chain) <= 0) {
|
|
99
|
+
if (evictSpent) {
|
|
100
|
+
// Best effort. A credential that cannot be removed is still one this tab
|
|
101
|
+
// refuses, so a failure costs tidiness and not correctness.
|
|
102
|
+
await storage.remove(slot).catch(() => undefined);
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const credential = { identity: stored.identity, chain: stored.chain };
|
|
108
|
+
if (verify !== undefined && !verify(credential)) return undefined;
|
|
109
|
+
return credential;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The one place an app credential is minted and written.
|
|
114
|
+
*
|
|
115
|
+
* Reads the slot under the lock and mints only where what it finds cannot be
|
|
116
|
+
* used. The lock alone would merely serialise: five tabs waking together would
|
|
117
|
+
* queue politely and then make five calls one after another. The read inside it
|
|
118
|
+
* is what turns serialising into suppressing, and it is why the floor is one mint
|
|
119
|
+
* per origin rather than one per tab.
|
|
120
|
+
*
|
|
121
|
+
* Shared by the first acquisition and by every rotation, so both go through the
|
|
122
|
+
* same lock and the same read. Having a second reader-and-writer outside the lock
|
|
123
|
+
* is how a load could land on top of a peer's freshly minted credential.
|
|
124
|
+
*/
|
|
125
|
+
async function acquireCredential({
|
|
126
|
+
storage,
|
|
127
|
+
slot,
|
|
128
|
+
source,
|
|
129
|
+
lockName,
|
|
130
|
+
verify,
|
|
131
|
+
adopt,
|
|
132
|
+
onGone,
|
|
133
|
+
}: {
|
|
134
|
+
storage: CredentialStorage;
|
|
135
|
+
slot: string;
|
|
136
|
+
source: AppDelegationSource;
|
|
137
|
+
lockName: string | null;
|
|
138
|
+
verify?: (credential: Held) => boolean;
|
|
139
|
+
adopt: boolean;
|
|
140
|
+
onGone?: () => void;
|
|
141
|
+
}): Promise<Held> {
|
|
142
|
+
return withMintLock(lockName, async (stolen) => {
|
|
143
|
+
const stored = await readSlot({ storage, slot, evictSpent: true, verify });
|
|
144
|
+
// Read either way, so a spent record is evicted; adopted only where the
|
|
145
|
+
// caller could say which account it expects.
|
|
146
|
+
if (adopt && stored && msLeftOf(stored.chain) > PRE_MINT_THRESHOLD_MS) return stored;
|
|
147
|
+
|
|
148
|
+
const identity = await storage.create();
|
|
149
|
+
let chain: DelegationChain;
|
|
150
|
+
try {
|
|
151
|
+
chain = await source.mint(identity.getPublicKey().toDer());
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (error instanceof SessionGoneError) onGone?.();
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const credential = { identity, chain };
|
|
158
|
+
if (verify !== undefined && !verify(credential)) {
|
|
159
|
+
throw new AccountMismatchError();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Checked here, after the calls have returned and before anything is written:
|
|
163
|
+
// a sign-out elsewhere took the lock away while this was in flight, and it
|
|
164
|
+
// has already cleared the slot this would write to. Storing now would put a
|
|
165
|
+
// credential back for a session that has ended.
|
|
166
|
+
if (stolen.aborted) {
|
|
167
|
+
onGone?.();
|
|
168
|
+
throw new SessionGoneError('The session ended while this mint was in flight');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// A credential that cannot be written is still one this tab can use. The cost
|
|
172
|
+
// of a failed write is that other tabs mint for themselves, which is what
|
|
173
|
+
// they do without a lock at all — it is not a reason to fail the request that
|
|
174
|
+
// is waiting on this.
|
|
175
|
+
await storage.set(slot, credential).catch(() => undefined);
|
|
176
|
+
return credential;
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Whether a session has enough left that minting against it returns something usable. */
|
|
181
|
+
const sessionHasLifeLeft = (sessionExpiresAtMs: number): boolean =>
|
|
182
|
+
sessionExpiresAtMs - Date.now() > BLOCK_MARGIN_MS;
|
|
183
|
+
|
|
184
|
+
/** The lock a mint is serialised on, or `null` where none is worth taking. */
|
|
185
|
+
const lockNameFor = (storage: CredentialStorage, slot: string): string | null =>
|
|
186
|
+
// A store no other tab can read has nothing another tab could adopt, so
|
|
187
|
+
// serialising would spread the mints without preventing any of them.
|
|
188
|
+
storage.shared ? slot : null;
|
|
189
|
+
|
|
190
|
+
export interface SessionIdentityOptions {
|
|
191
|
+
/** The account's key, which every app delegation is rooted at. */
|
|
192
|
+
accountKey: DerEncodedPublicKey;
|
|
193
|
+
|
|
194
|
+
/** When the session itself ends. Nothing is minted past this. */
|
|
195
|
+
sessionExpiresAtMs: number;
|
|
196
|
+
|
|
197
|
+
source: AppDelegationSource;
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Where the app credential is kept, and what makes the key a mint gets a
|
|
201
|
+
* delegation for.
|
|
202
|
+
*
|
|
203
|
+
* The store is what every tab of the origin reads, so a delegation minted in
|
|
204
|
+
* one is the delegation the others use.
|
|
205
|
+
*/
|
|
206
|
+
storage: CredentialStorage;
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The slot the app credential lives under, and the name of the lock that
|
|
210
|
+
* serialises minting into it.
|
|
211
|
+
*
|
|
212
|
+
* Taken rather than assumed, because the client assigns every name from one
|
|
213
|
+
* namespace: reaching for the bare constant here would read and write a slot
|
|
214
|
+
* the client never promotes into, so a namespaced client would mint again on
|
|
215
|
+
* every load and its sign-out would clear a slot nothing was using.
|
|
216
|
+
*/
|
|
217
|
+
slot: string;
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Called once when the session turns out to be gone, so the client can drop
|
|
221
|
+
* what it holds and tell its subscribers. Ending a session is the client's to
|
|
222
|
+
* do; this only reports it.
|
|
223
|
+
*/
|
|
224
|
+
onSessionGone: () => void;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* An identity whose credential is short-lived and replaced under the caller.
|
|
229
|
+
*
|
|
230
|
+
* It extends {@link DelegationIdentity} so anything already accepting one accepts
|
|
231
|
+
* this, and what it presents is an ordinary app delegation. What differs is that
|
|
232
|
+
* the key and delegation it signs with are replaced as they age, which is why an
|
|
233
|
+
* agent can hold this object for hours: an agent keeps whatever identity it was
|
|
234
|
+
* given, so a snapshot of one credential would sign with it until it expired.
|
|
235
|
+
*
|
|
236
|
+
* A rotation is invisible to the holder. The principal comes from the account's
|
|
237
|
+
* key rather than from whatever is current, and a request is signed and its
|
|
238
|
+
* delegation attached in the same act, so what is already in flight carries a
|
|
239
|
+
* matching pair however soon the next one arrives.
|
|
240
|
+
*/
|
|
241
|
+
export class SessionIdentity extends DelegationIdentity {
|
|
242
|
+
readonly #source: AppDelegationSource;
|
|
243
|
+
readonly #storage: CredentialStorage;
|
|
244
|
+
readonly #slot: string;
|
|
245
|
+
readonly #onSessionGone: () => void;
|
|
246
|
+
readonly #sessionExpiresAtMs: number;
|
|
247
|
+
/** The account every credential must be rooted at, derived once. */
|
|
248
|
+
readonly #account: Principal;
|
|
249
|
+
/** Reached by the base class's `sign`, so rotating does not change the object. */
|
|
250
|
+
readonly #held: { current?: Held };
|
|
251
|
+
|
|
252
|
+
#inFlight: Promise<Held> | undefined;
|
|
253
|
+
#currentWasUsed = false;
|
|
254
|
+
#scheduled: ReturnType<typeof setTimeout> | undefined;
|
|
255
|
+
#reportedGone = false;
|
|
256
|
+
#unsupported: SessionUnsupportedError | undefined;
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Builds an identity for a session, resolving the account key it needs.
|
|
260
|
+
*
|
|
261
|
+
* The account key cannot be worked out from the session — the session chain is
|
|
262
|
+
* rooted at the session's own key, and the state carries the account's
|
|
263
|
+
* principal, which is a hash of the account key rather than the key. So the
|
|
264
|
+
* only two places one can come from are a credential the store already holds
|
|
265
|
+
* and a mint, and a caller with neither has to wait for the mint before an
|
|
266
|
+
* identity exists at all.
|
|
267
|
+
*
|
|
268
|
+
* That resolution goes through the same locked read-or-mint every rotation
|
|
269
|
+
* uses, which is what keeps the app slot to one writer: doing it here, outside
|
|
270
|
+
* the lock, is how a page load could overwrite a credential a peer tab had just
|
|
271
|
+
* minted, and it would skip the read that lets this tab adopt that credential
|
|
272
|
+
* instead of paying for its own.
|
|
273
|
+
*
|
|
274
|
+
* The identity comes back already holding what was resolved, so a caller does
|
|
275
|
+
* not have to refresh it before use.
|
|
276
|
+
* @param options - As the constructor takes them, less the account key.
|
|
277
|
+
*/
|
|
278
|
+
static async create(
|
|
279
|
+
options: Omit<SessionIdentityOptions, 'accountKey'> & { expectedAccount?: Principal },
|
|
280
|
+
): Promise<SessionIdentity> {
|
|
281
|
+
if (!sessionHasLifeLeft(options.sessionExpiresAtMs)) {
|
|
282
|
+
options.onSessionGone();
|
|
283
|
+
throw new SessionGoneError('The session has expired');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const credential = await acquireCredential({
|
|
287
|
+
storage: options.storage,
|
|
288
|
+
slot: options.slot,
|
|
289
|
+
source: options.source,
|
|
290
|
+
lockName: lockNameFor(options.storage, options.slot),
|
|
291
|
+
verify: (candidate) => isForAccount(candidate, options.expectedAccount),
|
|
292
|
+
// A caller that cannot name the account has nothing to check the slot
|
|
293
|
+
// against, so it mints rather than trusting what is there: a mint reports
|
|
294
|
+
// the account, and is the only thing that can establish one.
|
|
295
|
+
adopt: options.expectedAccount !== undefined,
|
|
296
|
+
onGone: options.onSessionGone,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
const identity = new SessionIdentity({
|
|
300
|
+
...options,
|
|
301
|
+
accountKey: credential.chain.publicKey,
|
|
302
|
+
});
|
|
303
|
+
identity.#adopt(credential);
|
|
304
|
+
return identity;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
constructor(options: SessionIdentityOptions) {
|
|
308
|
+
const held: { current?: Held } = {};
|
|
309
|
+
// The base signs through this, so what it holds can be replaced without the
|
|
310
|
+
// object an application is holding changing. The account's key is the root of
|
|
311
|
+
// every app delegation, so an empty chain rooted at it answers getPublicKey()
|
|
312
|
+
// and getPrincipal() before anything is minted.
|
|
313
|
+
super(
|
|
314
|
+
{
|
|
315
|
+
sign: (blob) => {
|
|
316
|
+
const key = held.current?.identity;
|
|
317
|
+
if (!key) throw new Error('This identity holds no delegation yet');
|
|
318
|
+
return key.sign(blob);
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
DelegationChain.fromDelegations([], options.accountKey),
|
|
322
|
+
);
|
|
323
|
+
this.#held = held;
|
|
324
|
+
this.#source = options.source;
|
|
325
|
+
this.#storage = options.storage;
|
|
326
|
+
this.#slot = options.slot;
|
|
327
|
+
this.#onSessionGone = options.onSessionGone;
|
|
328
|
+
this.#sessionExpiresAtMs = options.sessionExpiresAtMs;
|
|
329
|
+
this.#account = Principal.selfAuthenticating(new Uint8Array(options.accountKey));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
override getDelegation(): DelegationChain {
|
|
333
|
+
return this.#held.current?.chain ?? super.getDelegation();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
override async transformRequest(request: HttpAgentRequest): Promise<unknown> {
|
|
337
|
+
const credential = await this.#usable();
|
|
338
|
+
this.#currentWasUsed = true;
|
|
339
|
+
return DelegationIdentity.fromDelegation(
|
|
340
|
+
credential.identity,
|
|
341
|
+
credential.chain,
|
|
342
|
+
).transformRequest(request);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Says somebody is here, and mints now if one is due.
|
|
347
|
+
*
|
|
348
|
+
* For a caller that knows the moment is a good one, such as a page load, a tab
|
|
349
|
+
* coming back to the foreground, or a pointer moving. The caller says when;
|
|
350
|
+
* this still decides whether, so a credential with plenty of life left costs
|
|
351
|
+
* no call.
|
|
352
|
+
*
|
|
353
|
+
* It is never a no-op, though: being here counts as use, exactly as signing a
|
|
354
|
+
* request does, so the credential held afterwards has earned the next
|
|
355
|
+
* rotation. Without that a session bounded by how long it goes unminted would
|
|
356
|
+
* end under a user who is reading rather than clicking — the delegation would
|
|
357
|
+
* only ever be replaced in the last {@link PRE_MINT_THRESHOLD_MS} of its life,
|
|
358
|
+
* and only if a pointer happened to move inside that window.
|
|
359
|
+
*/
|
|
360
|
+
async refresh(): Promise<void> {
|
|
361
|
+
await this.#refreshIfDue();
|
|
362
|
+
// After, not before: both branches below can replace what is held, and
|
|
363
|
+
// adopting resets this.
|
|
364
|
+
this.#currentWasUsed = true;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async #refreshIfDue(): Promise<void> {
|
|
368
|
+
const current = this.#held.current;
|
|
369
|
+
if (current && this.#msLeft(current.chain) > PRE_MINT_THRESHOLD_MS) return;
|
|
370
|
+
|
|
371
|
+
if (current === undefined) {
|
|
372
|
+
// Nothing held yet, so what another tab left is worth having before minting.
|
|
373
|
+
const stored = await this.#readStored();
|
|
374
|
+
if (stored && this.#msLeft(stored.chain) > PRE_MINT_THRESHOLD_MS) {
|
|
375
|
+
this.#adopt(stored);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
await this.#mint().catch(() => undefined);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Drops the scheduled refresh. */
|
|
383
|
+
dispose(): void {
|
|
384
|
+
clearTimeout(this.#scheduled);
|
|
385
|
+
this.#scheduled = undefined;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
get #lockName(): string | null {
|
|
389
|
+
return lockNameFor(this.#storage, this.#slot);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
#isForThisSession(held: Held): boolean {
|
|
393
|
+
return isForAccount(held, this.#account);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
#msLeft(chain: DelegationChain): number {
|
|
397
|
+
return expiresAtMs(chain) - Date.now();
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* The stored credential, where there is one this session may use.
|
|
402
|
+
*
|
|
403
|
+
* Whatever another tab minted arrives this way: sharing is a read of the store
|
|
404
|
+
* rather than a conversation, which works whether the other tabs are running,
|
|
405
|
+
* frozen, or gone.
|
|
406
|
+
*
|
|
407
|
+
* Takes no lock, so it declines a spent record rather than evicting it. The
|
|
408
|
+
* mint that follows evicts under the lock, per {@link readSlot}.
|
|
409
|
+
*/
|
|
410
|
+
#readStored(): Promise<Held | undefined> {
|
|
411
|
+
return readSlot({
|
|
412
|
+
storage: this.#storage,
|
|
413
|
+
slot: this.#slot,
|
|
414
|
+
evictSpent: false,
|
|
415
|
+
verify: (candidate) => this.#isForThisSession(candidate),
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async #usable(): Promise<Held> {
|
|
420
|
+
const current = this.#held.current;
|
|
421
|
+
if (current) {
|
|
422
|
+
const left = this.#msLeft(current.chain);
|
|
423
|
+
if (left > PRE_MINT_THRESHOLD_MS) return current;
|
|
424
|
+
if (left > BLOCK_MARGIN_MS) {
|
|
425
|
+
// Enough life to serve this request while a replacement is on its way.
|
|
426
|
+
void this.#mint().catch(() => undefined);
|
|
427
|
+
return current;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return this.#mint();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
#mint(): Promise<Held> {
|
|
434
|
+
this.#inFlight ??= this.#mintOnce().finally(() => {
|
|
435
|
+
this.#inFlight = undefined;
|
|
436
|
+
});
|
|
437
|
+
return this.#inFlight;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async #mintOnce(): Promise<Held> {
|
|
441
|
+
// A session this library cannot act for will answer the same way every time,
|
|
442
|
+
// so nothing is gained by asking again: the rotation stops and the error
|
|
443
|
+
// reaches the caller. The record is left alone — the sign-in is alive, and
|
|
444
|
+
// retracting it would sign every tab on the domain out of it.
|
|
445
|
+
if (this.#unsupported) throw this.#unsupported;
|
|
446
|
+
|
|
447
|
+
// A delegation lasts min(its ttl, what remains of the session), so minting
|
|
448
|
+
// against an almost finished session returns one already too short to use,
|
|
449
|
+
// and refreshing on the delegation alone would mint without end.
|
|
450
|
+
if (!sessionHasLifeLeft(this.#sessionExpiresAtMs)) {
|
|
451
|
+
this.#reportGone();
|
|
452
|
+
throw new SessionGoneError('The session has expired');
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
let credential: Held;
|
|
456
|
+
try {
|
|
457
|
+
credential = await acquireCredential({
|
|
458
|
+
storage: this.#storage,
|
|
459
|
+
slot: this.#slot,
|
|
460
|
+
source: this.#source,
|
|
461
|
+
lockName: this.#lockName,
|
|
462
|
+
// Every mint after the first must be rooted at the account already
|
|
463
|
+
// established: one that is not is a failed mint, never a new principal.
|
|
464
|
+
verify: (candidate) => this.#isForThisSession(candidate),
|
|
465
|
+
adopt: true,
|
|
466
|
+
onGone: () => this.#reportGone(),
|
|
467
|
+
});
|
|
468
|
+
} catch (error) {
|
|
469
|
+
// Latched, and the rotation dropped: asking again would return the same
|
|
470
|
+
// answer, and a timer that keeps firing for it is a timer that never stops.
|
|
471
|
+
if (error instanceof SessionUnsupportedError) {
|
|
472
|
+
this.#unsupported = error;
|
|
473
|
+
this.dispose();
|
|
474
|
+
}
|
|
475
|
+
throw error;
|
|
476
|
+
}
|
|
477
|
+
this.#adopt(credential);
|
|
478
|
+
return credential;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
#adopt(credential: Held): void {
|
|
482
|
+
this.#held.current = credential;
|
|
483
|
+
this.#currentWasUsed = false;
|
|
484
|
+
this.#schedule(credential.chain);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
#schedule(chain: DelegationChain): void {
|
|
488
|
+
clearTimeout(this.#scheduled);
|
|
489
|
+
const delay = this.#msLeft(chain) - PRE_MINT_THRESHOLD_MS;
|
|
490
|
+
if (delay <= 0) return;
|
|
491
|
+
|
|
492
|
+
this.#scheduled = setTimeout(() => {
|
|
493
|
+
// Only refresh a credential something used — signing a request, or a
|
|
494
|
+
// caller saying somebody is here. Each one earns the next refresh and no
|
|
495
|
+
// more, so an application nobody is using lets its delegation lapse rather
|
|
496
|
+
// than refreshing for as long as a tab is open.
|
|
497
|
+
if (this.#currentWasUsed) void this.#mint().catch(() => undefined);
|
|
498
|
+
}, delay);
|
|
499
|
+
|
|
500
|
+
// Never hold a Node process open for a refresh nobody is waiting on.
|
|
501
|
+
(this.#scheduled as { unref?: () => void }).unref?.();
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
#reportGone(): void {
|
|
505
|
+
if (this.#reportedGone) return;
|
|
506
|
+
this.#reportedGone = true;
|
|
507
|
+
this.dispose();
|
|
508
|
+
this.#onSessionGone();
|
|
509
|
+
}
|
|
510
|
+
}
|