@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,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base64 for the JSON-RPC wire, which carries bytes as strings.
|
|
3
|
+
*
|
|
4
|
+
* Uses the built-in `Uint8Array` methods where the runtime has them, since they
|
|
5
|
+
* are faster than a byte-at-a-time loop, and falls back where it does not.
|
|
6
|
+
*/
|
|
7
|
+
export function toBase64(bytes: Uint8Array): string {
|
|
8
|
+
if ('toBase64' in bytes && typeof bytes.toBase64 === 'function') {
|
|
9
|
+
return bytes.toBase64();
|
|
10
|
+
}
|
|
11
|
+
let binary = '';
|
|
12
|
+
for (let i = 0; i < bytes.byteLength; i++) {
|
|
13
|
+
binary += String.fromCharCode(bytes[i]);
|
|
14
|
+
}
|
|
15
|
+
return globalThis.btoa(binary);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function fromBase64(str: string): Uint8Array {
|
|
19
|
+
if ('fromBase64' in Uint8Array && typeof Uint8Array.fromBase64 === 'function') {
|
|
20
|
+
return Uint8Array.fromBase64(str);
|
|
21
|
+
}
|
|
22
|
+
const binary = globalThis.atob(str);
|
|
23
|
+
const bytes = new Uint8Array(binary.length);
|
|
24
|
+
for (let i = 0; i < binary.length; i++) {
|
|
25
|
+
bytes[i] = binary.charCodeAt(i);
|
|
26
|
+
}
|
|
27
|
+
return bytes;
|
|
28
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { Principal } from '@icp-sdk/core/principal';
|
|
2
|
+
import { LocalStateStorage, type SessionState, type StateStorage } from './state-storage.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* How long the browser keeps the record, which is not how long the sign-in
|
|
6
|
+
* lasts: the record says when the session expires, and it is kept past that so
|
|
7
|
+
* every sibling can say the session ended rather than that nobody signed in.
|
|
8
|
+
* Signing in rewrites it and signing out removes it, so this is only the
|
|
9
|
+
* ceiling on a record nothing came back for.
|
|
10
|
+
*
|
|
11
|
+
* Four hundred days because that is what browsers allow — Chrome, Edge and
|
|
12
|
+
* Firefox cap a persistent cookie there and silently truncate anything longer.
|
|
13
|
+
* Safari caps a cookie written by script to seven days, which shortens this
|
|
14
|
+
* without breaking it: the record lapses to signed out, the same answer a
|
|
15
|
+
* cleared store gives.
|
|
16
|
+
*/
|
|
17
|
+
const RECORD_MAX_AGE_SECONDS = 400 * 24 * 60 * 60;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Whether a hostname is loopback, which browsers treat as a secure context, so a
|
|
21
|
+
* cookie set there needs no `Secure` attribute.
|
|
22
|
+
*/
|
|
23
|
+
const isLoopbackHost = (hostname: string): boolean =>
|
|
24
|
+
hostname === 'localhost' ||
|
|
25
|
+
hostname.endsWith('.localhost') ||
|
|
26
|
+
hostname === '127.0.0.1' ||
|
|
27
|
+
hostname === '[::1]';
|
|
28
|
+
|
|
29
|
+
export interface CookieStateStorageOptions {
|
|
30
|
+
/**
|
|
31
|
+
* Domain to scope the cookie to, e.g. `example.com` so `a.example.com` and
|
|
32
|
+
* `b.example.com` share one sign-in.
|
|
33
|
+
*
|
|
34
|
+
* Must be the current host or a domain above it: the browser rejects a cookie
|
|
35
|
+
* scoped to anything else, including a sibling subdomain, and refuses a public
|
|
36
|
+
* suffix — so an over-broad value fails rather than leaking to unrelated sites.
|
|
37
|
+
* Nothing needs to be served at the domain; it is only a scope.
|
|
38
|
+
*/
|
|
39
|
+
domain: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The state of a sign-in, in a cookie, so every sibling of a domain shares it.
|
|
44
|
+
*
|
|
45
|
+
* A cookie is the only thing that crosses between origins, which is what lets one
|
|
46
|
+
* sign-out end them all: the record is the domain's rather than this origin's, so
|
|
47
|
+
* removing it is what tells a sibling the sign-in is over. It carries no chain
|
|
48
|
+
* and no key, so a sibling acting on it asks the identity provider to re-issue
|
|
49
|
+
* rather than treating it as proof.
|
|
50
|
+
* @see implements {@link StateStorage}
|
|
51
|
+
*/
|
|
52
|
+
export class CookieStateStorage implements StateStorage {
|
|
53
|
+
/**
|
|
54
|
+
* A sign-in kept here may be resumed without a ceremony.
|
|
55
|
+
*
|
|
56
|
+
* This is the store whose record reaches past one origin, so it is the store
|
|
57
|
+
* whose siblings arrive holding no credential and needing the identity
|
|
58
|
+
* provider to remember the session they can be given one from. Asking a
|
|
59
|
+
* provider to keep a sign-in is only worth the persistence where somebody is
|
|
60
|
+
* going to come back to it, and here somebody will.
|
|
61
|
+
*/
|
|
62
|
+
public readonly resumable = true;
|
|
63
|
+
|
|
64
|
+
readonly #attributes: string;
|
|
65
|
+
#subscribers = new Map<string, Set<() => void>>();
|
|
66
|
+
|
|
67
|
+
// The cookie is the domain's and says who is signed in; this says whether it
|
|
68
|
+
// was this origin that acquired a credential for them. A cookie cannot carry
|
|
69
|
+
// that — every sibling reads the same bytes — so it is kept per origin and
|
|
70
|
+
// composed in on read, under the same key the cookie uses.
|
|
71
|
+
readonly #local = new LocalStateStorage();
|
|
72
|
+
|
|
73
|
+
constructor(options: CookieStateStorageOptions) {
|
|
74
|
+
// Both sides, because this decides whether to skip the check below and drop
|
|
75
|
+
// the `Domain` attribute. Taken from the configured domain alone, a
|
|
76
|
+
// `domain: 'localhost'` on a real host would do both — accept the
|
|
77
|
+
// misconfiguration and write a host-only cookie — which is the silent
|
|
78
|
+
// failure the check exists to prevent.
|
|
79
|
+
const hostname = globalThis.location?.hostname;
|
|
80
|
+
const loopback =
|
|
81
|
+
isLoopbackHost(options.domain) && (hostname === undefined || isLoopbackHost(hostname));
|
|
82
|
+
|
|
83
|
+
// A browser silently ignores `document.cookie` when the domain is not this
|
|
84
|
+
// host or one above it, and this cookie is the state itself, so the sign-in
|
|
85
|
+
// would appear to be over the moment it was written. Refusing here names the
|
|
86
|
+
// cause; the silent version does not.
|
|
87
|
+
if (
|
|
88
|
+
!loopback &&
|
|
89
|
+
hostname !== undefined &&
|
|
90
|
+
hostname !== options.domain &&
|
|
91
|
+
!hostname.endsWith(`.${options.domain}`)
|
|
92
|
+
) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`A cookie cannot be scoped to '${options.domain}' from '${hostname}': the domain has to be this host or one above it`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const secure = globalThis.location?.protocol === 'https:';
|
|
99
|
+
this.#attributes = [
|
|
100
|
+
// Host-only on loopback: browsers reject `Domain=localhost`, and a
|
|
101
|
+
// host-only cookie already spans ports, which local multi-origin needs.
|
|
102
|
+
...(loopback ? [] : [`Domain=${options.domain}`]),
|
|
103
|
+
'Path=/',
|
|
104
|
+
'SameSite=Lax',
|
|
105
|
+
// Loopback is a secure context, but a `Secure` cookie over http is not
|
|
106
|
+
// reliably accepted.
|
|
107
|
+
...(secure ? ['Secure'] : []),
|
|
108
|
+
].join('; ');
|
|
109
|
+
|
|
110
|
+
// The check above cannot see a public suffix: 'app.co.uk' ends with '.co.uk',
|
|
111
|
+
// so `domain: 'co.uk'` passes it and the browser then refuses the cookie. No
|
|
112
|
+
// API exposes the list, so the only test that agrees with the browser is to
|
|
113
|
+
// write one and read it back — which catches cookies being switched off at
|
|
114
|
+
// the same time.
|
|
115
|
+
if (typeof document !== 'undefined') {
|
|
116
|
+
const probe = 'ic-cookie-probe';
|
|
117
|
+
let accepted = false;
|
|
118
|
+
try {
|
|
119
|
+
// No Max-Age, so even a cleanup that fails dies with the browser.
|
|
120
|
+
// biome-ignore lint/suspicious/noDocumentCookie: as elsewhere in this class.
|
|
121
|
+
document.cookie = `${probe}=1; ${this.#attributes}`;
|
|
122
|
+
accepted = readCookie(probe) !== null;
|
|
123
|
+
} finally {
|
|
124
|
+
// biome-ignore lint/suspicious/noDocumentCookie: as above.
|
|
125
|
+
document.cookie = `${probe}=; ${this.#attributes}; Max-Age=0`;
|
|
126
|
+
}
|
|
127
|
+
if (!accepted) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`A cookie scoped to '${options.domain}' cannot be written from '${hostname}': the browser refused it. The domain may be a public suffix, or cookies may be disabled.`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
public get(key: string): SessionState | null {
|
|
136
|
+
const raw = readCookie(key);
|
|
137
|
+
if (raw === null) return null;
|
|
138
|
+
|
|
139
|
+
const [principalText, expiration] = raw.split('|');
|
|
140
|
+
if (principalText === undefined || expiration === undefined) return null;
|
|
141
|
+
try {
|
|
142
|
+
const principal = Principal.fromText(principalText);
|
|
143
|
+
// Compared, not merely counted: a record can outlive the sign-in it was
|
|
144
|
+
// written for — an expired one is kept on purpose, so an application can
|
|
145
|
+
// say the session ended — and a sibling signing in as someone else then
|
|
146
|
+
// publishes a cookie this origin has no credential for. Asking only whether
|
|
147
|
+
// some local record exists would read that as held.
|
|
148
|
+
// The record's own fields are the shared truth, read as written. `held` is
|
|
149
|
+
// the only thing derived here, because it is the one fact a record every
|
|
150
|
+
// sibling reads cannot carry.
|
|
151
|
+
const local = this.#local.get(key);
|
|
152
|
+
return {
|
|
153
|
+
principal,
|
|
154
|
+
expiration: BigInt(expiration),
|
|
155
|
+
held: local !== null && local.principal.compareTo(principal) === 'eq',
|
|
156
|
+
};
|
|
157
|
+
} catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
public set(key: string, state: Omit<SessionState, 'held'>): void {
|
|
163
|
+
// The local record first, so nothing reads the cookie as held before it is.
|
|
164
|
+
this.#local.set(key, state);
|
|
165
|
+
const payload = `${state.principal.toText()}|${state.expiration.toString()}`;
|
|
166
|
+
// biome-ignore lint/suspicious/noDocumentCookie: the Cookie Store API is async, and this record is read synchronously alongside isAuthenticated().
|
|
167
|
+
document.cookie = `${encodeURIComponent(key)}=${encodeURIComponent(payload)}; ${this.#attributes}; Max-Age=${RECORD_MAX_AGE_SECONDS}`;
|
|
168
|
+
this.#fire(key);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
public remove(key: string): void {
|
|
172
|
+
this.#local.remove(key);
|
|
173
|
+
// biome-ignore lint/suspicious/noDocumentCookie: same reason as set().
|
|
174
|
+
document.cookie = `${encodeURIComponent(key)}=; ${this.#attributes}; Max-Age=0`;
|
|
175
|
+
this.#fire(key);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Drops this origin's claim on the sign-in, leaving the cookie for the siblings.
|
|
180
|
+
*
|
|
181
|
+
* An origin whose chain turns out to be dead cannot tell a revoked session from
|
|
182
|
+
* one a sibling replaced by signing in, and in the second case that sibling
|
|
183
|
+
* wrote this cookie a moment ago. So the record stands and this origin reads it
|
|
184
|
+
* as not held, which is what sends it to acquire one of its own.
|
|
185
|
+
*/
|
|
186
|
+
public discard(key: string): void {
|
|
187
|
+
this.#local.remove(key);
|
|
188
|
+
this.#fire(key);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Fires when the state changes, including when a sibling subdomain changes it.
|
|
193
|
+
*
|
|
194
|
+
* `document.cookie` raises no event and no `BroadcastChannel` crosses origins,
|
|
195
|
+
* so a sibling's sign-out is visible only by looking: the Cookie Store API
|
|
196
|
+
* where the browser has it, and otherwise a re-check whenever this page is
|
|
197
|
+
* shown or its window regains focus, which is when the user is about to act on
|
|
198
|
+
* the answer.
|
|
199
|
+
*/
|
|
200
|
+
public subscribe(key: string, listener: () => void): () => void {
|
|
201
|
+
// One logical change can arrive from several sources at once, so each is
|
|
202
|
+
// routed through a check that fires only when what is stored actually
|
|
203
|
+
// changed since the last one.
|
|
204
|
+
//
|
|
205
|
+
// Compared on what `get` answers and not on the cookie alone. `held` is the
|
|
206
|
+
// per-origin half, and `discard` changes only that — leaving the cookie for
|
|
207
|
+
// the siblings is the whole point of it — so watching the cookie would make
|
|
208
|
+
// this origin losing its claim the one change nothing reported.
|
|
209
|
+
let last = stateOf(this.get(key));
|
|
210
|
+
const check = (): void => {
|
|
211
|
+
const now = stateOf(this.get(key));
|
|
212
|
+
if (now !== last) {
|
|
213
|
+
last = now;
|
|
214
|
+
listener();
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
const listeners = this.#subscribers.get(key) ?? new Set();
|
|
218
|
+
listeners.add(check);
|
|
219
|
+
this.#subscribers.set(key, listeners);
|
|
220
|
+
|
|
221
|
+
const onVisible = (): void => {
|
|
222
|
+
if (document.visibilityState === 'visible') check();
|
|
223
|
+
};
|
|
224
|
+
// The local half is in `localStorage`, which raises `storage` in every tab
|
|
225
|
+
// of this origin but the one that wrote. That is how a sibling tab hears
|
|
226
|
+
// that this origin dropped its claim, which no cookie event can carry.
|
|
227
|
+
const onStorage = (event: StorageEvent): void => {
|
|
228
|
+
// `key` is null when a tab cleared the whole store, which changes this too.
|
|
229
|
+
if (event.key === key || event.key === null) check();
|
|
230
|
+
};
|
|
231
|
+
globalThis.addEventListener('storage', onStorage);
|
|
232
|
+
document.addEventListener('visibilitychange', onVisible);
|
|
233
|
+
globalThis.addEventListener('pageshow', check);
|
|
234
|
+
globalThis.addEventListener('focus', check);
|
|
235
|
+
|
|
236
|
+
const cookieStore = (globalThis as { cookieStore?: EventTarget }).cookieStore;
|
|
237
|
+
cookieStore?.addEventListener('change', check);
|
|
238
|
+
|
|
239
|
+
return () => {
|
|
240
|
+
listeners.delete(check);
|
|
241
|
+
globalThis.removeEventListener('storage', onStorage);
|
|
242
|
+
document.removeEventListener('visibilitychange', onVisible);
|
|
243
|
+
globalThis.removeEventListener('pageshow', check);
|
|
244
|
+
globalThis.removeEventListener('focus', check);
|
|
245
|
+
cookieStore?.removeEventListener('change', check);
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
#fire(key: string): void {
|
|
250
|
+
for (const check of [...(this.#subscribers.get(key) ?? [])]) check();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* What {@link CookieStateStorage.get} answers, as one comparable string.
|
|
256
|
+
*
|
|
257
|
+
* Both halves in it: the cookie every sibling reads, and the `held` this origin
|
|
258
|
+
* derives, because a change in either is a change to the answer.
|
|
259
|
+
*/
|
|
260
|
+
const stateOf = (state: SessionState | null): string =>
|
|
261
|
+
state === null ? '' : `${state.principal.toText()}|${state.expiration}|${state.held}`;
|
|
262
|
+
|
|
263
|
+
/** Reads a cookie value by name, or `null` if absent. */
|
|
264
|
+
function readCookie(name: string): string | null {
|
|
265
|
+
for (const entry of document.cookie.split(';')) {
|
|
266
|
+
const separator = entry.indexOf('=');
|
|
267
|
+
if (separator === -1) continue;
|
|
268
|
+
// Any cookie on this domain lands here, including ones this library did not
|
|
269
|
+
// write. A stray `%` makes `decodeURIComponent` throw, and one unrelated
|
|
270
|
+
// cookie must not be able to break reading the state.
|
|
271
|
+
try {
|
|
272
|
+
if (decodeURIComponent(entry.slice(0, separator).trim()) !== name) continue;
|
|
273
|
+
return decodeURIComponent(entry.slice(separator + 1).trim());
|
|
274
|
+
} catch {}
|
|
275
|
+
}
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { SignIdentity } from '@icp-sdk/core/agent';
|
|
2
|
+
import type { DelegationChain } from '@icp-sdk/core/identity';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* An identity, and the delegation that authorises it.
|
|
6
|
+
*
|
|
7
|
+
* The two are one record because a delegation is issued to a key: held apart
|
|
8
|
+
* they can disagree, and every reader then has to decide what a chain whose key
|
|
9
|
+
* is missing means. Stored together, the half that would be dangerous — a chain
|
|
10
|
+
* with no key to sign for it — cannot be written down at all.
|
|
11
|
+
*/
|
|
12
|
+
export interface Credential<T extends SignIdentity = SignIdentity> {
|
|
13
|
+
identity: T;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Absent only while a ceremony is in flight, under the session-pending slot: there
|
|
17
|
+
* is a key, and no delegation for it yet.
|
|
18
|
+
*/
|
|
19
|
+
chain?: DelegationChain;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Holds the material a client acts with, a credential per slot.
|
|
24
|
+
*
|
|
25
|
+
* Asynchronous, because using a credential means making a call and a
|
|
26
|
+
* non-extractable key needs a store that can hold one. What a page renders on is
|
|
27
|
+
* the state rather than this, so nothing here has to answer without awaiting.
|
|
28
|
+
*
|
|
29
|
+
* A store knows two things about its medium that the library cannot infer, and
|
|
30
|
+
* declares both.
|
|
31
|
+
*/
|
|
32
|
+
export interface CredentialStorage<T extends SignIdentity = SignIdentity> {
|
|
33
|
+
/** Whether another tab of this origin reads what this writes. */
|
|
34
|
+
readonly shared: boolean;
|
|
35
|
+
|
|
36
|
+
/** Whether what this writes survives the document being torn down. */
|
|
37
|
+
readonly durable: boolean;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A fresh identity of this store's own type, not persisted.
|
|
41
|
+
*
|
|
42
|
+
* Generation belongs to the store because the medium decides the key type: one
|
|
43
|
+
* that has to serialise needs a key whose private bytes are readable, and one
|
|
44
|
+
* that does not should not have them. Kept together so an application cannot
|
|
45
|
+
* pair a non-extractable key with a store that cannot hold one and find out at
|
|
46
|
+
* runtime.
|
|
47
|
+
*/
|
|
48
|
+
create(): Promise<T>;
|
|
49
|
+
|
|
50
|
+
/** The credential stored under `slot`, or `null` where there is none. */
|
|
51
|
+
get(slot: string): Promise<Credential<T> | null>;
|
|
52
|
+
|
|
53
|
+
set(slot: string, credential: Credential<T>): Promise<void>;
|
|
54
|
+
|
|
55
|
+
remove(slot: string): Promise<void>;
|
|
56
|
+
}
|
package/src/client/db.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type IDBPDatabase, openDB } from 'idb';
|
|
2
|
-
|
|
2
|
+
|
|
3
|
+
// Increment when the shape of what is stored changes.
|
|
4
|
+
export const DB_VERSION = 1;
|
|
3
5
|
|
|
4
6
|
type Database = IDBPDatabase<unknown>;
|
|
5
7
|
type IDBValidKey = string | number | Date | BufferSource | IDBValidKey[];
|
|
@@ -10,12 +12,8 @@ const _openDbStore = async (
|
|
|
10
12
|
dbName = AUTH_DB_NAME,
|
|
11
13
|
storeName = OBJECT_STORE_NAME,
|
|
12
14
|
version: number,
|
|
15
|
+
onTerminated?: () => void,
|
|
13
16
|
) => {
|
|
14
|
-
// Clear legacy stored delegations
|
|
15
|
-
if (globalThis.localStorage?.getItem(KEY_STORAGE_DELEGATION)) {
|
|
16
|
-
globalThis.localStorage.removeItem(KEY_STORAGE_DELEGATION);
|
|
17
|
-
globalThis.localStorage.removeItem(KEY_STORAGE_KEY);
|
|
18
|
-
}
|
|
19
17
|
return await openDB(dbName, version, {
|
|
20
18
|
upgrade: (database) => {
|
|
21
19
|
if (database.objectStoreNames.contains(storeName)) {
|
|
@@ -23,6 +21,7 @@ const _openDbStore = async (
|
|
|
23
21
|
}
|
|
24
22
|
database.createObjectStore(storeName);
|
|
25
23
|
},
|
|
24
|
+
terminated: onTerminated,
|
|
26
25
|
});
|
|
27
26
|
};
|
|
28
27
|
|
|
@@ -72,16 +71,61 @@ export class IdbKeyVal {
|
|
|
72
71
|
storeName = OBJECT_STORE_NAME,
|
|
73
72
|
version = DB_VERSION,
|
|
74
73
|
} = options ?? {};
|
|
75
|
-
const
|
|
76
|
-
|
|
74
|
+
const keyVal = new IdbKeyVal(dbName, storeName, version);
|
|
75
|
+
// Open eagerly so an unavailable database fails at creation time.
|
|
76
|
+
await keyVal._openDb();
|
|
77
|
+
return keyVal;
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
private _dbPromise: Promise<Database> | null = null;
|
|
81
|
+
|
|
79
82
|
// Do not use - instead prefer create
|
|
80
83
|
private constructor(
|
|
81
|
-
private
|
|
84
|
+
private _dbName: string,
|
|
82
85
|
private _storeName: string,
|
|
86
|
+
private _version: number,
|
|
83
87
|
) {}
|
|
84
88
|
|
|
89
|
+
// The browser can force-close the connection at any time; the `terminated`
|
|
90
|
+
// hook drops it so the next operation opens a fresh one.
|
|
91
|
+
private _openDb(): Promise<Database> {
|
|
92
|
+
if (this._dbPromise === null) {
|
|
93
|
+
const promise = _openDbStore(this._dbName, this._storeName, this._version, () => {
|
|
94
|
+
if (this._dbPromise === promise) {
|
|
95
|
+
this._dbPromise = null;
|
|
96
|
+
}
|
|
97
|
+
}).catch((error) => {
|
|
98
|
+
if (this._dbPromise === promise) {
|
|
99
|
+
this._dbPromise = null;
|
|
100
|
+
}
|
|
101
|
+
throw error;
|
|
102
|
+
});
|
|
103
|
+
this._dbPromise = promise;
|
|
104
|
+
}
|
|
105
|
+
return this._dbPromise;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Chrome can force-close a connection without firing `terminated`; the next
|
|
109
|
+
// transaction then throws an InvalidStateError. Drop the dead connection and
|
|
110
|
+
// retry once on a fresh one.
|
|
111
|
+
private async _withDb<T>(action: (db: Database) => Promise<T>): Promise<T> {
|
|
112
|
+
const promise = this._openDb();
|
|
113
|
+
const db = await promise;
|
|
114
|
+
try {
|
|
115
|
+
return await action(db);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
// Matched by name: a DOMException does not inherit from Error.
|
|
118
|
+
const name = error !== null && typeof error === 'object' && 'name' in error && error.name;
|
|
119
|
+
if (name !== 'InvalidStateError') {
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
if (this._dbPromise === promise) {
|
|
123
|
+
this._dbPromise = null;
|
|
124
|
+
}
|
|
125
|
+
return await action(await this._openDb());
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
85
129
|
/**
|
|
86
130
|
* Basic setter
|
|
87
131
|
* @param {IDBValidKey} key string | number | Date | BufferSource | IDBValidKey[]
|
|
@@ -89,7 +133,7 @@ export class IdbKeyVal {
|
|
|
89
133
|
* @returns void
|
|
90
134
|
*/
|
|
91
135
|
public async set<T>(key: IDBValidKey, value: T) {
|
|
92
|
-
return await _setValue<T>(
|
|
136
|
+
return await this._withDb((db) => _setValue<T>(db, this._storeName, key, value));
|
|
93
137
|
}
|
|
94
138
|
/**
|
|
95
139
|
* Basic getter
|
|
@@ -100,7 +144,7 @@ export class IdbKeyVal {
|
|
|
100
144
|
* await get<string>('exampleKey') -> 'exampleValue'
|
|
101
145
|
*/
|
|
102
146
|
public async get<T>(key: IDBValidKey): Promise<T | null> {
|
|
103
|
-
return (await _getValue<T>(
|
|
147
|
+
return await this._withDb(async (db) => (await _getValue<T>(db, this._storeName, key)) ?? null);
|
|
104
148
|
}
|
|
105
149
|
|
|
106
150
|
/**
|
|
@@ -109,6 +153,6 @@ export class IdbKeyVal {
|
|
|
109
153
|
* @returns void
|
|
110
154
|
*/
|
|
111
155
|
public async remove(key: IDBValidKey) {
|
|
112
|
-
return await _removeValue(
|
|
156
|
+
return await this._withDb((db) => _removeValue(db, this._storeName, key));
|
|
113
157
|
}
|
|
114
158
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calls back when the page is shown or the window regains focus.
|
|
3
|
+
*
|
|
4
|
+
* Its own module because it is the one part of the session machinery that needs a
|
|
5
|
+
* DOM. `AuthClient` runs outside a browser too, so nothing else may assume these
|
|
6
|
+
* APIs exist: where they do not, this and {@link watchActivity} return
|
|
7
|
+
* `undefined` and hook nothing.
|
|
8
|
+
*
|
|
9
|
+
* A backgrounded tab has its timers throttled, so a session's scheduled refresh
|
|
10
|
+
* may not run and its delegation lapses while nobody is looking. Coming back to
|
|
11
|
+
* the tab happens a second or two before the user's next click, which is room
|
|
12
|
+
* enough to mint in. Whether a mint is actually due is not decided here.
|
|
13
|
+
* @param onForeground - Invoked when the page becomes visible or regains focus.
|
|
14
|
+
* @returns A function that unhooks the listeners, or `undefined` where there is
|
|
15
|
+
* no DOM to hook.
|
|
16
|
+
*/
|
|
17
|
+
export function watchForeground(onForeground: () => void): (() => void) | undefined {
|
|
18
|
+
// Every API this hooks, checked one at a time: a partial DOM polyfill can
|
|
19
|
+
// define `document` without the event methods, and throwing there would
|
|
20
|
+
// contradict the promise that this hooks nothing where there is no DOM.
|
|
21
|
+
if (
|
|
22
|
+
typeof document === 'undefined' ||
|
|
23
|
+
typeof document.addEventListener !== 'function' ||
|
|
24
|
+
typeof document.removeEventListener !== 'function' ||
|
|
25
|
+
typeof globalThis.addEventListener !== 'function' ||
|
|
26
|
+
typeof globalThis.removeEventListener !== 'function'
|
|
27
|
+
) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const onVisible = (): void => {
|
|
32
|
+
if (document.visibilityState === 'visible') onForeground();
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
document.addEventListener('visibilitychange', onVisible);
|
|
36
|
+
// `focus` catches two visible windows side by side, where moving between them
|
|
37
|
+
// is not a visibility change. `pageshow` catches a page restored from the
|
|
38
|
+
// back-forward cache, which resumes with timers that never ran.
|
|
39
|
+
globalThis.addEventListener('focus', onForeground);
|
|
40
|
+
globalThis.addEventListener('pageshow', onForeground);
|
|
41
|
+
|
|
42
|
+
return () => {
|
|
43
|
+
document.removeEventListener('visibilitychange', onVisible);
|
|
44
|
+
globalThis.removeEventListener('focus', onForeground);
|
|
45
|
+
globalThis.removeEventListener('pageshow', onForeground);
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// `pointerdown` covers a mouse, a finger and a pen in one, which is the only
|
|
50
|
+
// press event a phone reliably produces. The list the retired idle timer
|
|
51
|
+
// listened to, put to the opposite use: it ended a session on their absence,
|
|
52
|
+
// and they now keep one alive by their presence.
|
|
53
|
+
const ACTIVITY_EVENTS = ['pointerdown', 'mousedown', 'mousemove', 'keydown', 'touchstart', 'wheel'];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Calls back when somebody is using the page.
|
|
57
|
+
*
|
|
58
|
+
* A user moving a mouse is very likely about to do something that needs a
|
|
59
|
+
* delegation, so this is the same claim {@link watchForeground} makes — this
|
|
60
|
+
* page is in front of a person right now — from the other direction. Whether a
|
|
61
|
+
* mint is actually due is not decided here, and the identity's pre-mint
|
|
62
|
+
* threshold is what keeps a resting hand from costing more than one mint per
|
|
63
|
+
* refresh interval.
|
|
64
|
+
*
|
|
65
|
+
* It matters beyond saving the first request a mint: a session bounded by how
|
|
66
|
+
* long it goes unminted would otherwise end under a user who is reading rather
|
|
67
|
+
* than clicking.
|
|
68
|
+
* @param onActivity - Invoked on any sign of use.
|
|
69
|
+
* @returns A function that unhooks the listeners, or `undefined` where there is
|
|
70
|
+
* no DOM to hook.
|
|
71
|
+
*/
|
|
72
|
+
export function watchActivity(onActivity: () => void): (() => void) | undefined {
|
|
73
|
+
if (
|
|
74
|
+
typeof document === 'undefined' ||
|
|
75
|
+
typeof document.addEventListener !== 'function' ||
|
|
76
|
+
typeof document.removeEventListener !== 'function'
|
|
77
|
+
) {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Passive: these never call `preventDefault`, and saying so keeps a listener on
|
|
82
|
+
// `mousemove` and `wheel` off the path that decides whether a scroll may start.
|
|
83
|
+
for (const event of ACTIVITY_EVENTS) {
|
|
84
|
+
document.addEventListener(event, onActivity, { passive: true });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return () => {
|
|
88
|
+
for (const event of ACTIVITY_EVENTS) {
|
|
89
|
+
document.removeEventListener(event, onActivity);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { DelegationChain, ECDSAKeyIdentity } from '@icp-sdk/core/identity';
|
|
2
|
+
import type { Credential, CredentialStorage } from './credential-storage.js';
|
|
3
|
+
import { type DBCreateOptions, IdbKeyVal } from './db.js';
|
|
4
|
+
|
|
5
|
+
/** What one slot holds in the database: a key handle, and the chain as JSON. */
|
|
6
|
+
interface StoredCredential {
|
|
7
|
+
keyPair: CryptoKeyPair;
|
|
8
|
+
chain?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Credentials in IndexedDB, which is what a browser offers that can hold a key
|
|
13
|
+
* whose private half is unreadable.
|
|
14
|
+
*
|
|
15
|
+
* A non-extractable `CryptoKeyPair` survives a structured clone, so what another
|
|
16
|
+
* tab reads is a handle that signs and cannot be exported — no key material is
|
|
17
|
+
* written anywhere.
|
|
18
|
+
* @see implements {@link CredentialStorage}
|
|
19
|
+
*/
|
|
20
|
+
export class IdbCredentialStorage implements CredentialStorage<ECDSAKeyIdentity> {
|
|
21
|
+
public readonly shared = true;
|
|
22
|
+
public readonly durable = true;
|
|
23
|
+
|
|
24
|
+
#options: DBCreateOptions;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param options - Database name, store name and version.
|
|
28
|
+
*/
|
|
29
|
+
constructor(options?: DBCreateOptions) {
|
|
30
|
+
this.#options = options ?? {};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Opened on first use, and forgotten again if opening failed, so a later call
|
|
34
|
+
// retries rather than reusing a rejected promise.
|
|
35
|
+
#dbPromise: Promise<IdbKeyVal> | null = null;
|
|
36
|
+
get #db(): Promise<IdbKeyVal> {
|
|
37
|
+
if (this.#dbPromise === null) {
|
|
38
|
+
const promise = IdbKeyVal.create(this.#options).catch((error) => {
|
|
39
|
+
if (this.#dbPromise === promise) {
|
|
40
|
+
this.#dbPromise = null;
|
|
41
|
+
}
|
|
42
|
+
throw error;
|
|
43
|
+
});
|
|
44
|
+
this.#dbPromise = promise;
|
|
45
|
+
}
|
|
46
|
+
return this.#dbPromise;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
public create(): Promise<ECDSAKeyIdentity> {
|
|
50
|
+
return ECDSAKeyIdentity.generate({ extractable: false });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
public async get(slot: string): Promise<Credential<ECDSAKeyIdentity> | null> {
|
|
54
|
+
const db = await this.#db;
|
|
55
|
+
const stored = await db.get<StoredCredential>(slot);
|
|
56
|
+
if (!stored) return null;
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const identity = await ECDSAKeyIdentity.fromKeyPair(stored.keyPair);
|
|
60
|
+
const chain = stored.chain === undefined ? undefined : DelegationChain.fromJSON(stored.chain);
|
|
61
|
+
return { identity, chain };
|
|
62
|
+
} catch {
|
|
63
|
+
// A record written by an incompatible version, or one the browser has
|
|
64
|
+
// truncated. Reporting nothing lets the caller acquire again, which is
|
|
65
|
+
// safer than throwing on a page load.
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
public async set(slot: string, credential: Credential<ECDSAKeyIdentity>): Promise<void> {
|
|
71
|
+
const db = await this.#db;
|
|
72
|
+
await db.set<StoredCredential>(slot, {
|
|
73
|
+
keyPair: credential.identity.getKeyPair(),
|
|
74
|
+
chain: credential.chain && JSON.stringify(credential.chain.toJSON()),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
public async remove(slot: string): Promise<void> {
|
|
79
|
+
const db = await this.#db;
|
|
80
|
+
await db.remove(slot);
|
|
81
|
+
}
|
|
82
|
+
}
|