@icp-sdk/auth 5.0.0-beta.2 → 6.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/README.md +25 -13
- package/dist/esm/client/auth-client.d.ts +86 -116
- package/dist/esm/client/auth-client.js +310 -315
- package/dist/esm/client/auth-client.js.map +1 -1
- package/dist/esm/client/db.js +73 -73
- package/dist/esm/client/db.js.map +1 -1
- package/dist/esm/client/idle-manager.d.ts +11 -9
- package/dist/esm/client/idle-manager.js +97 -78
- package/dist/esm/client/idle-manager.js.map +1 -1
- package/dist/esm/client/index.d.ts +4 -4
- package/dist/esm/client/index.js +8 -15
- package/dist/esm/client/index.js.map +1 -1
- package/dist/esm/client/storage.d.ts +1 -1
- package/dist/esm/client/storage.js +86 -79
- package/dist/esm/client/storage.js.map +1 -1
- package/dist/esm/index.js +3 -4
- package/dist/esm/index.js.map +1 -1
- package/package.json +8 -9
- package/src/client/auth-client.ts +354 -472
- package/src/client/db.ts +1 -1
- package/src/client/idle-manager.ts +43 -25
- package/src/client/index.ts +4 -4
- package/src/client/storage.ts +1 -1
|
@@ -1,12 +1,5 @@
|
|
|
1
|
+
import { AnonymousIdentity, type Identity, type SignIdentity } from '@icp-sdk/core/agent';
|
|
1
2
|
import {
|
|
2
|
-
AnonymousIdentity,
|
|
3
|
-
type DerEncodedPublicKey,
|
|
4
|
-
type Identity,
|
|
5
|
-
type Signature,
|
|
6
|
-
type SignIdentity,
|
|
7
|
-
} from '@icp-sdk/core/agent';
|
|
8
|
-
import {
|
|
9
|
-
Delegation,
|
|
10
3
|
DelegationChain,
|
|
11
4
|
DelegationIdentity,
|
|
12
5
|
ECDSAKeyIdentity,
|
|
@@ -16,7 +9,9 @@ import {
|
|
|
16
9
|
type PartialIdentity,
|
|
17
10
|
} from '@icp-sdk/core/identity';
|
|
18
11
|
import type { Principal } from '@icp-sdk/core/principal';
|
|
19
|
-
import {
|
|
12
|
+
import { Signer } from '@icp-sdk/signer';
|
|
13
|
+
import { PostMessageTransport } from '@icp-sdk/signer/web';
|
|
14
|
+
import { IdleManager, type IdleManagerOptions } from './idle-manager.js';
|
|
20
15
|
import {
|
|
21
16
|
type AuthClientStorage,
|
|
22
17
|
IdbStorage,
|
|
@@ -25,592 +20,479 @@ import {
|
|
|
25
20
|
KEY_VECTOR,
|
|
26
21
|
LocalStorage,
|
|
27
22
|
type StoredKey,
|
|
28
|
-
} from './storage.
|
|
23
|
+
} from './storage.js';
|
|
29
24
|
|
|
30
25
|
const NANOSECONDS_PER_SECOND = BigInt(1_000_000_000);
|
|
31
26
|
const SECONDS_PER_HOUR = BigInt(3_600);
|
|
32
27
|
const NANOSECONDS_PER_HOUR = NANOSECONDS_PER_SECOND * SECONDS_PER_HOUR;
|
|
33
28
|
|
|
34
|
-
const IDENTITY_PROVIDER_DEFAULT = 'https://
|
|
35
|
-
const IDENTITY_PROVIDER_ENDPOINT = '#authorize';
|
|
36
|
-
|
|
29
|
+
const IDENTITY_PROVIDER_DEFAULT = 'https://id.ai/authorize';
|
|
37
30
|
const DEFAULT_MAX_TIME_TO_LIVE = BigInt(8) * NANOSECONDS_PER_HOUR;
|
|
38
31
|
|
|
39
32
|
const ECDSA_KEY_LABEL = 'ECDSA';
|
|
40
33
|
const ED25519_KEY_LABEL = 'Ed25519';
|
|
41
34
|
type BaseKeyType = typeof ECDSA_KEY_LABEL | typeof ED25519_KEY_LABEL;
|
|
42
35
|
|
|
43
|
-
|
|
36
|
+
// localStorage key used to cache the delegation expiration so that
|
|
37
|
+
// isAuthenticated() can answer synchronously without hitting IndexedDB.
|
|
38
|
+
const KEY_STORAGE_EXPIRATION = 'ic-delegation_expiration';
|
|
39
|
+
|
|
40
|
+
export type OpenIdProvider = 'google' | 'apple' | 'microsoft';
|
|
44
41
|
|
|
45
|
-
|
|
42
|
+
const OPENID_PROVIDER_URLS: Record<OpenIdProvider, string> = {
|
|
43
|
+
google: 'https://accounts.google.com',
|
|
44
|
+
apple: 'https://appleid.apple.com',
|
|
45
|
+
microsoft: 'https://login.microsoftonline.com/{tid}/v2.0',
|
|
46
|
+
};
|
|
46
47
|
|
|
47
48
|
/**
|
|
48
|
-
*
|
|
49
|
+
* Options for creating an {@link AuthClient}.
|
|
49
50
|
*/
|
|
50
51
|
export interface AuthClientCreateOptions {
|
|
51
52
|
/**
|
|
52
|
-
* An
|
|
53
|
+
* An identity to authenticate via delegation.
|
|
53
54
|
*/
|
|
54
55
|
identity?: SignIdentity | PartialIdentity;
|
|
56
|
+
|
|
55
57
|
/**
|
|
56
|
-
*
|
|
57
|
-
* @
|
|
58
|
+
* Persistent storage backend. Defaults to IndexedDB.
|
|
59
|
+
* @default IdbStorage
|
|
58
60
|
*/
|
|
59
61
|
storage?: AuthClientStorage;
|
|
60
62
|
|
|
61
63
|
/**
|
|
62
|
-
* Type to
|
|
64
|
+
* Type of session key to generate on each login.
|
|
63
65
|
*
|
|
64
|
-
*
|
|
65
|
-
* you should use `Ed25519` as the key type, as it can serialize to a string.
|
|
66
|
+
* Use `'Ed25519'` when your storage provider does not support `CryptoKey`.
|
|
66
67
|
* @default 'ECDSA'
|
|
67
68
|
*/
|
|
68
69
|
keyType?: BaseKeyType;
|
|
69
70
|
|
|
70
71
|
/**
|
|
71
|
-
*
|
|
72
|
+
* Idle timeout configuration.
|
|
72
73
|
* @default after 10 minutes, invalidates the identity
|
|
73
74
|
*/
|
|
74
75
|
idleOptions?: IdleOptions;
|
|
75
76
|
|
|
76
77
|
/**
|
|
77
|
-
*
|
|
78
|
+
* Identity provider URL.
|
|
79
|
+
* @default "https://id.ai/authorize"
|
|
80
|
+
*/
|
|
81
|
+
identityProvider?: string | URL;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Derivation origin for the identity provider.
|
|
85
|
+
* @see https://github.com/dfinity/internet-identity/blob/main/docs/internet-identity-spec.adoc
|
|
78
86
|
*/
|
|
79
|
-
|
|
87
|
+
derivationOrigin?: string | URL;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Window features string for the authentication popup.
|
|
91
|
+
* @example "toolbar=0,location=0,menubar=0,width=500,height=500,left=100,top=100"
|
|
92
|
+
*/
|
|
93
|
+
windowOpenerFeatures?: string;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* OpenID provider for one-click sign-in. When set, the identity provider
|
|
97
|
+
* URL includes an `openid` search param so the user authenticates via
|
|
98
|
+
* the chosen provider (e.g. Google) instead of seeing Internet Identity directly.
|
|
99
|
+
*/
|
|
100
|
+
openIdProvider?: OpenIdProvider;
|
|
80
101
|
}
|
|
81
102
|
|
|
82
103
|
export interface IdleOptions extends IdleManagerOptions {
|
|
83
104
|
/**
|
|
84
|
-
* Disables idle functionality
|
|
105
|
+
* Disables idle functionality entirely.
|
|
85
106
|
* @default false
|
|
86
107
|
*/
|
|
87
108
|
disableIdle?: boolean;
|
|
88
109
|
|
|
89
110
|
/**
|
|
90
|
-
* Disables default idle
|
|
111
|
+
* Disables the default idle callback (logout & reload).
|
|
91
112
|
* @default false
|
|
92
113
|
*/
|
|
93
114
|
disableDefaultIdleCallback?: boolean;
|
|
94
115
|
}
|
|
95
116
|
|
|
96
|
-
export type OnSuccessFunc =
|
|
97
|
-
| (() => void | Promise<void>)
|
|
98
|
-
| ((message: InternetIdentityAuthResponseSuccess) => void | Promise<void>);
|
|
117
|
+
export type OnSuccessFunc = () => void | Promise<void>;
|
|
99
118
|
|
|
100
119
|
export type OnErrorFunc = (error?: string) => void | Promise<void>;
|
|
101
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Options for {@link AuthClient.login}.
|
|
123
|
+
*/
|
|
102
124
|
export interface AuthClientLoginOptions {
|
|
103
125
|
/**
|
|
104
|
-
*
|
|
105
|
-
* @default
|
|
106
|
-
*/
|
|
107
|
-
identityProvider?: string | URL;
|
|
108
|
-
/**
|
|
109
|
-
* Expiration of the authentication in nanoseconds
|
|
110
|
-
* @default BigInt(8) hours * BigInt(3_600_000_000_000) nanoseconds
|
|
126
|
+
* Maximum lifetime of the delegation in nanoseconds.
|
|
127
|
+
* @default 8 hours
|
|
111
128
|
*/
|
|
112
129
|
maxTimeToLive?: bigint;
|
|
130
|
+
|
|
113
131
|
/**
|
|
114
|
-
*
|
|
115
|
-
*/
|
|
116
|
-
allowPinAuthentication?: boolean;
|
|
117
|
-
/**
|
|
118
|
-
* Origin for Identity Provider to use while generating the delegated identity. For II, the derivation origin must authorize this origin by setting a record at `<derivation-origin>/.well-known/ii-alternative-origins`.
|
|
119
|
-
* @see https://github.com/dfinity/internet-identity/blob/main/docs/internet-identity-spec.adoc
|
|
120
|
-
*/
|
|
121
|
-
derivationOrigin?: string | URL;
|
|
122
|
-
/**
|
|
123
|
-
* Auth Window feature config string
|
|
124
|
-
* @example "toolbar=0,location=0,menubar=0,width=500,height=500,left=100,top=100"
|
|
132
|
+
* Restrict the delegation to specific canisters.
|
|
125
133
|
*/
|
|
126
|
-
|
|
134
|
+
targets?: Principal[];
|
|
135
|
+
|
|
127
136
|
/**
|
|
128
|
-
*
|
|
137
|
+
* Called after a successful login.
|
|
129
138
|
*/
|
|
130
139
|
onSuccess?: OnSuccessFunc;
|
|
140
|
+
|
|
131
141
|
/**
|
|
132
|
-
*
|
|
142
|
+
* Called when login fails. When provided the error is **not** re-thrown,
|
|
143
|
+
* allowing the caller to handle it via this callback instead.
|
|
133
144
|
*/
|
|
134
145
|
onError?: OnErrorFunc;
|
|
135
|
-
/**
|
|
136
|
-
* Extra values to be passed in the login request during the authorize-ready phase
|
|
137
|
-
*/
|
|
138
|
-
customValues?: Record<string, unknown>;
|
|
139
146
|
}
|
|
140
147
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
+
/**
|
|
149
|
+
* Manages authentication and identity for Internet Computer web apps.
|
|
150
|
+
*
|
|
151
|
+
* @example
|
|
152
|
+
* const authClient = new AuthClient();
|
|
153
|
+
*
|
|
154
|
+
* if (authClient.isAuthenticated()) {
|
|
155
|
+
* const identity = await authClient.getIdentity();
|
|
156
|
+
* }
|
|
157
|
+
*
|
|
158
|
+
* await authClient.login({
|
|
159
|
+
* onSuccess: () => console.log('Logged in!'),
|
|
160
|
+
* });
|
|
161
|
+
*/
|
|
162
|
+
export class AuthClient {
|
|
163
|
+
#identity: Identity | PartialIdentity = new AnonymousIdentity();
|
|
164
|
+
#chain: DelegationChain | null = null;
|
|
165
|
+
#storage: AuthClientStorage;
|
|
166
|
+
#signer: Signer;
|
|
167
|
+
#options: AuthClientCreateOptions;
|
|
168
|
+
#initPromise: Promise<void> | null = null;
|
|
169
|
+
idleManager: IdleManager | undefined;
|
|
170
|
+
|
|
171
|
+
constructor(options: AuthClientCreateOptions = {}) {
|
|
172
|
+
this.#options = options;
|
|
173
|
+
this.#storage = options.storage ?? new IdbStorage();
|
|
148
174
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
targets?: Principal[];
|
|
156
|
-
};
|
|
157
|
-
signature: Uint8Array;
|
|
158
|
-
}[];
|
|
159
|
-
userPublicKey: Uint8Array;
|
|
160
|
-
authnMethod: 'passkey' | 'pin' | 'recovery';
|
|
161
|
-
}
|
|
175
|
+
const identityProviderUrl = new URL(
|
|
176
|
+
options.identityProvider?.toString() || IDENTITY_PROVIDER_DEFAULT,
|
|
177
|
+
);
|
|
178
|
+
if (options.openIdProvider) {
|
|
179
|
+
identityProviderUrl.searchParams.set('openid', OPENID_PROVIDER_URLS[options.openIdProvider]);
|
|
180
|
+
}
|
|
162
181
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
182
|
+
const transport = new PostMessageTransport({
|
|
183
|
+
url: identityProviderUrl.toString(),
|
|
184
|
+
windowOpenerFeatures: options.windowOpenerFeatures,
|
|
185
|
+
});
|
|
166
186
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
pubkey: Uint8Array;
|
|
172
|
-
expiration: bigint;
|
|
173
|
-
targets?: Principal[];
|
|
174
|
-
};
|
|
175
|
-
signature: Uint8Array;
|
|
176
|
-
}[];
|
|
177
|
-
userPublicKey: Uint8Array;
|
|
178
|
-
authnMethod: 'passkey' | 'pin' | 'recovery';
|
|
179
|
-
}
|
|
187
|
+
this.#signer = new Signer({
|
|
188
|
+
transport,
|
|
189
|
+
derivationOrigin: options.derivationOrigin?.toString(),
|
|
190
|
+
});
|
|
180
191
|
|
|
181
|
-
|
|
182
|
-
kind: 'authorize-client-failure';
|
|
183
|
-
text: string;
|
|
184
|
-
}
|
|
192
|
+
this.#registerDefaultIdleCallback();
|
|
185
193
|
|
|
186
|
-
|
|
187
|
-
|
|
194
|
+
// Eagerly start restoring a previous session from storage.
|
|
195
|
+
// The result is awaited in getIdentity() before returning.
|
|
196
|
+
this.#init();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Returns the current identity, restoring a previous session if available.
|
|
201
|
+
*/
|
|
202
|
+
async getIdentity(): Promise<Identity> {
|
|
203
|
+
await this.#init();
|
|
204
|
+
return this.#identity;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Checks whether the user has an active, non-expired session.
|
|
209
|
+
*/
|
|
210
|
+
isAuthenticated(): boolean {
|
|
211
|
+
// Uses a cached expiration in localStorage to avoid an async IndexedDB read.
|
|
212
|
+
const expiration = getExpirationFlag();
|
|
213
|
+
if (expiration === null) return false;
|
|
214
|
+
const nowNs = BigInt(Date.now()) * BigInt(1_000_000);
|
|
215
|
+
return nowNs < expiration;
|
|
216
|
+
}
|
|
188
217
|
|
|
189
|
-
/**
|
|
190
|
-
* Tool to manage authentication and identity
|
|
191
|
-
* @see {@link AuthClient}
|
|
192
|
-
*/
|
|
193
|
-
export class AuthClient {
|
|
194
218
|
/**
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
* @
|
|
198
|
-
* @param options.
|
|
199
|
-
* @
|
|
200
|
-
* @param options.
|
|
201
|
-
* @
|
|
202
|
-
* @
|
|
203
|
-
*
|
|
204
|
-
* @see {@link IdleOptions}
|
|
205
|
-
* Default behavior is to clear stored identity and reload the page when a user goes idle, unless you set the disableDefaultIdleCallback flag or pass in a custom idle callback.
|
|
219
|
+
* Opens the identity provider and requests a delegation.
|
|
220
|
+
*
|
|
221
|
+
* @param options - Login options.
|
|
222
|
+
* @param options.maxTimeToLive - Maximum lifetime of the delegation in nanoseconds.
|
|
223
|
+
* @param options.targets - Restrict the delegation to specific canisters.
|
|
224
|
+
* @param options.onSuccess - Called after a successful login.
|
|
225
|
+
* @param options.onError - Called when login fails. When provided the error is not re-thrown.
|
|
226
|
+
* @throws When authentication fails and no `onError` callback is provided.
|
|
227
|
+
*
|
|
206
228
|
* @example
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
* })
|
|
229
|
+
* await authClient.login({
|
|
230
|
+
* onSuccess: () => console.log('Logged in!'),
|
|
231
|
+
* onError: (err) => console.error(err),
|
|
232
|
+
* });
|
|
212
233
|
*/
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
234
|
+
async login(options?: AuthClientLoginOptions): Promise<void> {
|
|
235
|
+
try {
|
|
236
|
+
await this.#signer.openChannel();
|
|
216
237
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
key
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
await fallbackLocalStorage.remove(KEY_STORAGE_DELEGATION);
|
|
237
|
-
await fallbackLocalStorage.remove(KEY_STORAGE_KEY);
|
|
238
|
-
}
|
|
239
|
-
} catch (error) {
|
|
240
|
-
console.error(`error while attempting to recover localstorage: ${error}`);
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
if (maybeIdentityStorage) {
|
|
244
|
-
try {
|
|
245
|
-
if (typeof maybeIdentityStorage === 'object') {
|
|
246
|
-
if (keyType === ED25519_KEY_LABEL && typeof maybeIdentityStorage === 'string') {
|
|
247
|
-
key = Ed25519KeyIdentity.fromJSON(maybeIdentityStorage);
|
|
248
|
-
} else {
|
|
249
|
-
key = await ECDSAKeyIdentity.fromKeyPair(maybeIdentityStorage);
|
|
250
|
-
}
|
|
251
|
-
} else if (typeof maybeIdentityStorage === 'string') {
|
|
252
|
-
// This is a legacy identity, which is a serialized Ed25519KeyIdentity.
|
|
253
|
-
key = Ed25519KeyIdentity.fromJSON(maybeIdentityStorage);
|
|
254
|
-
}
|
|
255
|
-
} catch {
|
|
256
|
-
// Ignore this, this means that the localStorage value isn't a valid Ed25519KeyIdentity or ECDSAKeyIdentity
|
|
257
|
-
// serialization.
|
|
258
|
-
}
|
|
238
|
+
const maxTimeToLive = options?.maxTimeToLive ?? DEFAULT_MAX_TIME_TO_LIVE;
|
|
239
|
+
|
|
240
|
+
// Fresh key per login so each session has its own cryptographic identity.
|
|
241
|
+
const key =
|
|
242
|
+
this.#options.identity ?? (await generateKey(this.#options.keyType ?? ECDSA_KEY_LABEL));
|
|
243
|
+
|
|
244
|
+
const delegationChain = await this.#signer.requestDelegation({
|
|
245
|
+
publicKey: key.getPublicKey(),
|
|
246
|
+
targets: options?.targets,
|
|
247
|
+
maxTimeToLive,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
this.#chain = delegationChain;
|
|
251
|
+
|
|
252
|
+
// PartialIdentity only has the public key — no signing capability.
|
|
253
|
+
if ('toDer' in key) {
|
|
254
|
+
this.#identity = PartialDelegationIdentity.fromDelegation(key, this.#chain);
|
|
255
|
+
} else {
|
|
256
|
+
this.#identity = DelegationIdentity.fromDelegation(key, this.#chain);
|
|
259
257
|
}
|
|
260
|
-
}
|
|
261
258
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
const chainStorage = await storage.get(KEY_STORAGE_DELEGATION);
|
|
267
|
-
if (typeof chainStorage === 'object' && chainStorage !== null) {
|
|
268
|
-
throw new Error(
|
|
269
|
-
'Delegation chain is incorrectly stored. A delegation chain should be stored as a string.',
|
|
270
|
-
);
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
if (options.identity) {
|
|
274
|
-
identity = options.identity;
|
|
275
|
-
} else if (chainStorage) {
|
|
276
|
-
chain = DelegationChain.fromJSON(chainStorage);
|
|
277
|
-
|
|
278
|
-
// Verify that the delegation isn't expired.
|
|
279
|
-
if (!isDelegationValid(chain)) {
|
|
280
|
-
await _deleteStorage(storage);
|
|
281
|
-
key = null;
|
|
282
|
-
} else {
|
|
283
|
-
// If the key is a public key, then we create a PartialDelegationIdentity.
|
|
284
|
-
if ('toDer' in key) {
|
|
285
|
-
identity = PartialDelegationIdentity.fromDelegation(key, chain);
|
|
286
|
-
// otherwise, we create a DelegationIdentity.
|
|
287
|
-
} else {
|
|
288
|
-
identity = DelegationIdentity.fromDelegation(key, chain);
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
} catch (e) {
|
|
293
|
-
console.error(e);
|
|
294
|
-
// If there was a problem loading the chain, delete the key.
|
|
295
|
-
await _deleteStorage(storage);
|
|
296
|
-
key = null;
|
|
259
|
+
const idleOptions = this.#options?.idleOptions;
|
|
260
|
+
if (!this.idleManager && !idleOptions?.disableIdle) {
|
|
261
|
+
this.idleManager = IdleManager.create(idleOptions);
|
|
262
|
+
this.#registerDefaultIdleCallback();
|
|
297
263
|
}
|
|
298
|
-
}
|
|
299
|
-
let idleManager: IdleManager | undefined;
|
|
300
|
-
if (options.idleOptions?.disableIdle) {
|
|
301
|
-
idleManager = undefined;
|
|
302
|
-
}
|
|
303
|
-
// if there is a delegation chain or provided identity, setup idleManager
|
|
304
|
-
else if (chain || options.identity) {
|
|
305
|
-
idleManager = IdleManager.create(options.idleOptions);
|
|
306
|
-
}
|
|
307
264
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
265
|
+
// Persist so the session survives page reloads.
|
|
266
|
+
await persistChain(this.#storage, this.#chain);
|
|
267
|
+
await persistKey(this.#storage, key);
|
|
268
|
+
|
|
269
|
+
await options?.onSuccess?.();
|
|
270
|
+
} catch (error) {
|
|
271
|
+
// If an onError callback is provided, delegate error handling to the caller.
|
|
272
|
+
// Otherwise, re-throw so the error can be caught with try/catch or .catch().
|
|
273
|
+
if (options?.onError) {
|
|
274
|
+
await options.onError(error instanceof Error ? error.message : String(error));
|
|
312
275
|
} else {
|
|
313
|
-
|
|
314
|
-
console.warn(
|
|
315
|
-
`You are using a custom storage provider that may not support CryptoKey storage. If you are using a custom storage provider that does not support CryptoKey storage, you should use '${ED25519_KEY_LABEL}' as the key type, as it can serialize to a string`,
|
|
316
|
-
);
|
|
317
|
-
}
|
|
318
|
-
key = await ECDSAKeyIdentity.generate();
|
|
276
|
+
throw error;
|
|
319
277
|
}
|
|
320
|
-
await persistKey(storage, key);
|
|
321
278
|
}
|
|
322
|
-
|
|
323
|
-
return new AuthClient(identity, key, chain, storage, idleManager, options);
|
|
324
279
|
}
|
|
325
280
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
private _idpWindow?: Window,
|
|
335
|
-
// The event handler for processing events from the IdP.
|
|
336
|
-
private _eventHandler?: (event: MessageEvent) => void,
|
|
337
|
-
) {
|
|
338
|
-
this._registerDefaultIdleCallback();
|
|
339
|
-
}
|
|
281
|
+
/**
|
|
282
|
+
* Clears the stored session and resets the client to an anonymous state.
|
|
283
|
+
*
|
|
284
|
+
* @param options - Logout options.
|
|
285
|
+
* @param options.returnTo - URL to navigate to after logout.
|
|
286
|
+
*/
|
|
287
|
+
async logout(options: { returnTo?: string } = {}): Promise<void> {
|
|
288
|
+
await deleteStorage(this.#storage);
|
|
340
289
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
location.reload();
|
|
351
|
-
});
|
|
290
|
+
this.#identity = new AnonymousIdentity();
|
|
291
|
+
this.#chain = null;
|
|
292
|
+
|
|
293
|
+
if (options.returnTo) {
|
|
294
|
+
try {
|
|
295
|
+
window.history.pushState({}, '', options.returnTo);
|
|
296
|
+
} catch {
|
|
297
|
+
window.location.href = options.returnTo;
|
|
298
|
+
}
|
|
352
299
|
}
|
|
353
300
|
}
|
|
354
301
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
const delegations = message.delegations.map((signedDelegation) => {
|
|
360
|
-
return {
|
|
361
|
-
delegation: new Delegation(
|
|
362
|
-
signedDelegation.delegation.pubkey,
|
|
363
|
-
signedDelegation.delegation.expiration,
|
|
364
|
-
signedDelegation.delegation.targets,
|
|
365
|
-
),
|
|
366
|
-
signature: signedDelegation.signature as Signature,
|
|
367
|
-
};
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
const delegationChain = DelegationChain.fromDelegations(
|
|
371
|
-
delegations,
|
|
372
|
-
message.userPublicKey as DerEncodedPublicKey,
|
|
373
|
-
);
|
|
374
|
-
|
|
375
|
-
const key = this._key;
|
|
376
|
-
if (!key) {
|
|
377
|
-
return;
|
|
302
|
+
// Memoized — only runs #hydrate once, returns the same promise on repeat calls.
|
|
303
|
+
#init(): Promise<void> {
|
|
304
|
+
if (!this.#initPromise) {
|
|
305
|
+
this.#initPromise = this.#hydrate();
|
|
378
306
|
}
|
|
307
|
+
return this.#initPromise;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Attempts to restore a previous session (key + delegation chain) from
|
|
311
|
+
// storage. If found and still valid, sets #identity and #chain so the
|
|
312
|
+
// client is ready to use without a new login().
|
|
313
|
+
async #hydrate(): Promise<void> {
|
|
314
|
+
const key =
|
|
315
|
+
this.#options.identity ??
|
|
316
|
+
(await restoreKey(this.#storage, this.#options.keyType ?? ECDSA_KEY_LABEL));
|
|
317
|
+
if (!key) return;
|
|
379
318
|
|
|
380
|
-
|
|
319
|
+
const chain = await restoreChain(this.#storage);
|
|
320
|
+
if (!chain) return;
|
|
381
321
|
|
|
322
|
+
this.#chain = chain;
|
|
382
323
|
if ('toDer' in key) {
|
|
383
|
-
this
|
|
324
|
+
this.#identity = PartialDelegationIdentity.fromDelegation(key, chain);
|
|
384
325
|
} else {
|
|
385
|
-
this
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
this._idpWindow?.close();
|
|
389
|
-
const idleOptions = this._createOptions?.idleOptions;
|
|
390
|
-
// create the idle manager on a successful login if we haven't disabled it
|
|
391
|
-
// and it doesn't already exist.
|
|
392
|
-
if (!this.idleManager && !idleOptions?.disableIdle) {
|
|
393
|
-
this.idleManager = IdleManager.create(idleOptions);
|
|
394
|
-
this._registerDefaultIdleCallback();
|
|
326
|
+
this.#identity = DelegationIdentity.fromDelegation(key, chain);
|
|
395
327
|
}
|
|
396
328
|
|
|
397
|
-
this.
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
if (this._chain) {
|
|
401
|
-
await this._storage.set(KEY_STORAGE_DELEGATION, JSON.stringify(this._chain.toJSON()));
|
|
329
|
+
if (!this.#options.idleOptions?.disableIdle && !this.idleManager) {
|
|
330
|
+
this.idleManager = IdleManager.create(this.#options.idleOptions);
|
|
331
|
+
this.#registerDefaultIdleCallback();
|
|
402
332
|
}
|
|
403
|
-
|
|
404
|
-
// Ensure the stored key in persistent storage matches the in-memory key that
|
|
405
|
-
// was used to obtain the delegation. This avoids key/delegation mismatches
|
|
406
|
-
// across multiple tabs overwriting each other's cached keys.
|
|
407
|
-
await persistKey(this._storage, this._key);
|
|
408
|
-
|
|
409
|
-
// onSuccess should be the last thing to do to avoid consumers
|
|
410
|
-
// interfering by navigating or refreshing the page
|
|
411
|
-
onSuccess?.(message);
|
|
412
333
|
}
|
|
413
334
|
|
|
414
|
-
|
|
415
|
-
|
|
335
|
+
#registerDefaultIdleCallback() {
|
|
336
|
+
const idleOptions = this.#options?.idleOptions;
|
|
337
|
+
if (!idleOptions?.onIdle && !idleOptions?.disableDefaultIdleCallback) {
|
|
338
|
+
this.idleManager?.registerCallback(() => {
|
|
339
|
+
this.logout();
|
|
340
|
+
location.reload();
|
|
341
|
+
});
|
|
342
|
+
}
|
|
416
343
|
}
|
|
344
|
+
}
|
|
417
345
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
346
|
+
/**
|
|
347
|
+
* Generates a new session key.
|
|
348
|
+
* @param keyType - The key algorithm to use.
|
|
349
|
+
*/
|
|
350
|
+
async function generateKey(keyType: BaseKeyType): Promise<SignIdentity> {
|
|
351
|
+
if (keyType === ED25519_KEY_LABEL) {
|
|
352
|
+
return Ed25519KeyIdentity.generate();
|
|
424
353
|
}
|
|
354
|
+
return await ECDSAKeyIdentity.generate();
|
|
355
|
+
}
|
|
425
356
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
* @example
|
|
438
|
-
* const authClient = await AuthClient.create();
|
|
439
|
-
* authClient.login({
|
|
440
|
-
* identityProvider: 'http://<canisterID>.127.0.0.1:8000',
|
|
441
|
-
* maxTimeToLive: BigInt (7) * BigInt(24) * BigInt(3_600_000_000_000), // 1 week
|
|
442
|
-
* windowOpenerFeatures: "toolbar=0,location=0,menubar=0,width=500,height=500,left=100,top=100",
|
|
443
|
-
* onSuccess: () => {
|
|
444
|
-
* console.log('Login Successful!');
|
|
445
|
-
* },
|
|
446
|
-
* onError: (error) => {
|
|
447
|
-
* console.error('Login Failed: ', error);
|
|
448
|
-
* }
|
|
449
|
-
* });
|
|
450
|
-
*/
|
|
451
|
-
public async login(options?: AuthClientLoginOptions): Promise<void> {
|
|
452
|
-
// Merge the passed options with the options set during creation
|
|
453
|
-
const loginOptions = mergeLoginOptions(this._createOptions?.loginOptions, options);
|
|
454
|
-
|
|
455
|
-
// Set default maxTimeToLive to 8 hours
|
|
456
|
-
const maxTimeToLive = loginOptions?.maxTimeToLive ?? DEFAULT_MAX_TIME_TO_LIVE;
|
|
357
|
+
/**
|
|
358
|
+
* Saves a session key to storage.
|
|
359
|
+
* @param storage - The storage backend.
|
|
360
|
+
* @param key - The key to persist.
|
|
361
|
+
*/
|
|
362
|
+
async function persistKey(
|
|
363
|
+
storage: AuthClientStorage,
|
|
364
|
+
key: SignIdentity | PartialIdentity,
|
|
365
|
+
): Promise<void> {
|
|
366
|
+
await storage.set(KEY_STORAGE_KEY, serializeKey(key));
|
|
367
|
+
}
|
|
457
368
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
this._eventHandler = this._getEventHandler(identityProviderUrl, {
|
|
472
|
-
maxTimeToLive,
|
|
473
|
-
...loginOptions,
|
|
474
|
-
});
|
|
475
|
-
window.addEventListener('message', this._eventHandler);
|
|
476
|
-
|
|
477
|
-
// Open a new window with the IDP provider.
|
|
478
|
-
this._idpWindow =
|
|
479
|
-
window.open(
|
|
480
|
-
identityProviderUrl.toString(),
|
|
481
|
-
'idpWindow',
|
|
482
|
-
loginOptions?.windowOpenerFeatures,
|
|
483
|
-
) ?? undefined;
|
|
484
|
-
|
|
485
|
-
// Check if the _idpWindow is closed by user.
|
|
486
|
-
const checkInterruption = (): void => {
|
|
487
|
-
// The _idpWindow is opened and not yet closed by the client
|
|
488
|
-
if (this._idpWindow) {
|
|
489
|
-
if (this._idpWindow.closed) {
|
|
490
|
-
this._handleFailure(ERROR_USER_INTERRUPT, loginOptions?.onError);
|
|
491
|
-
} else {
|
|
492
|
-
setTimeout(checkInterruption, INTERRUPT_CHECK_INTERVAL);
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
};
|
|
496
|
-
checkInterruption();
|
|
369
|
+
/**
|
|
370
|
+
* Loads a session key from storage. Falls back to migrating a legacy
|
|
371
|
+
* key from localStorage if nothing is found in the primary store.
|
|
372
|
+
* @param storage - The storage backend.
|
|
373
|
+
* @param keyType - The expected key algorithm (determines deserialization).
|
|
374
|
+
*/
|
|
375
|
+
async function restoreKey(
|
|
376
|
+
storage: AuthClientStorage,
|
|
377
|
+
keyType: BaseKeyType,
|
|
378
|
+
): Promise<SignIdentity | PartialIdentity | null> {
|
|
379
|
+
let stored = await storage.get(KEY_STORAGE_KEY);
|
|
380
|
+
if (!stored) {
|
|
381
|
+
stored = await migrateFromLocalStorage(storage, keyType);
|
|
497
382
|
}
|
|
383
|
+
if (!stored) return null;
|
|
498
384
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
// IDP is ready. Send a message to request authorization.
|
|
511
|
-
const request: InternetIdentityAuthRequest = {
|
|
512
|
-
kind: 'authorize-client',
|
|
513
|
-
sessionPublicKey: new Uint8Array(this._key?.getPublicKey().toDer()),
|
|
514
|
-
maxTimeToLive: options?.maxTimeToLive,
|
|
515
|
-
allowPinAuthentication: options?.allowPinAuthentication,
|
|
516
|
-
derivationOrigin: options?.derivationOrigin?.toString(),
|
|
517
|
-
// Pass any custom values to the IDP.
|
|
518
|
-
...options?.customValues,
|
|
519
|
-
};
|
|
520
|
-
this._idpWindow?.postMessage(request, identityProviderUrl.origin);
|
|
521
|
-
break;
|
|
522
|
-
}
|
|
523
|
-
case 'authorize-client-success':
|
|
524
|
-
// Create the delegation chain and store it.
|
|
525
|
-
try {
|
|
526
|
-
await this._handleSuccess(message, options?.onSuccess);
|
|
527
|
-
} catch (err) {
|
|
528
|
-
this._handleFailure((err as Error).message, options?.onError);
|
|
529
|
-
}
|
|
530
|
-
break;
|
|
531
|
-
case 'authorize-client-failure':
|
|
532
|
-
this._handleFailure(message.text, options?.onError);
|
|
533
|
-
break;
|
|
534
|
-
default:
|
|
535
|
-
break;
|
|
536
|
-
}
|
|
537
|
-
};
|
|
385
|
+
try {
|
|
386
|
+
// CryptoKeyPair (object) → ECDSA, JSON string → Ed25519
|
|
387
|
+
if (typeof stored === 'object') {
|
|
388
|
+
return await ECDSAKeyIdentity.fromKeyPair(stored);
|
|
389
|
+
}
|
|
390
|
+
return Ed25519KeyIdentity.fromJSON(stored);
|
|
391
|
+
} catch {
|
|
392
|
+
// The stored value may be corrupt or from an incompatible version.
|
|
393
|
+
// Returning null lets the caller fall through to key generation,
|
|
394
|
+
// which is safer than crashing on startup.
|
|
395
|
+
return null;
|
|
538
396
|
}
|
|
397
|
+
}
|
|
539
398
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
399
|
+
/**
|
|
400
|
+
* Converts a key into a format suitable for storage.
|
|
401
|
+
* @param key - The key to serialize.
|
|
402
|
+
*/
|
|
403
|
+
function serializeKey(key: SignIdentity | PartialIdentity): StoredKey {
|
|
404
|
+
if (key instanceof ECDSAKeyIdentity) return key.getKeyPair();
|
|
405
|
+
if (key instanceof Ed25519KeyIdentity) return JSON.stringify(key.toJSON());
|
|
406
|
+
throw new Error('Unsupported key type');
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Saves the delegation chain and caches its earliest expiration
|
|
411
|
+
* in localStorage so {@link AuthClient.isAuthenticated} can check it synchronously.
|
|
412
|
+
* @param storage - The storage backend.
|
|
413
|
+
* @param chain - The delegation chain to persist.
|
|
414
|
+
*/
|
|
415
|
+
async function persistChain(storage: AuthClientStorage, chain: DelegationChain): Promise<void> {
|
|
416
|
+
await storage.set(KEY_STORAGE_DELEGATION, JSON.stringify(chain.toJSON()));
|
|
546
417
|
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
418
|
+
let earliest: bigint | null = null;
|
|
419
|
+
for (const { delegation } of chain.delegations) {
|
|
420
|
+
if (earliest === null || delegation.expiration < earliest) {
|
|
421
|
+
earliest = delegation.expiration;
|
|
550
422
|
}
|
|
551
|
-
this._eventHandler = undefined;
|
|
552
423
|
}
|
|
424
|
+
if (earliest !== null) {
|
|
425
|
+
localStorage.setItem(KEY_STORAGE_EXPIRATION, earliest.toString());
|
|
426
|
+
}
|
|
427
|
+
}
|
|
553
428
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
429
|
+
/**
|
|
430
|
+
* Loads the delegation chain from storage. Returns `null` and wipes
|
|
431
|
+
* storage if the chain is expired or corrupted.
|
|
432
|
+
* @param storage - The storage backend.
|
|
433
|
+
*/
|
|
434
|
+
async function restoreChain(storage: AuthClientStorage): Promise<DelegationChain | null> {
|
|
435
|
+
try {
|
|
436
|
+
const raw = await storage.get(KEY_STORAGE_DELEGATION);
|
|
437
|
+
if (!raw || typeof raw !== 'string') return null;
|
|
438
|
+
|
|
439
|
+
const chain = DelegationChain.fromJSON(raw);
|
|
440
|
+
if (!isDelegationValid(chain)) {
|
|
441
|
+
await deleteStorage(storage);
|
|
442
|
+
return null;
|
|
567
443
|
}
|
|
444
|
+
return chain;
|
|
445
|
+
} catch (e) {
|
|
446
|
+
console.error(e);
|
|
447
|
+
await deleteStorage(storage);
|
|
448
|
+
return null;
|
|
568
449
|
}
|
|
569
450
|
}
|
|
570
451
|
|
|
571
|
-
|
|
452
|
+
/**
|
|
453
|
+
* Clears all session data from storage.
|
|
454
|
+
* @param storage - The storage backend.
|
|
455
|
+
*/
|
|
456
|
+
async function deleteStorage(storage: AuthClientStorage): Promise<void> {
|
|
572
457
|
await storage.remove(KEY_STORAGE_KEY);
|
|
573
458
|
await storage.remove(KEY_STORAGE_DELEGATION);
|
|
574
459
|
await storage.remove(KEY_VECTOR);
|
|
460
|
+
localStorage.removeItem(KEY_STORAGE_EXPIRATION);
|
|
575
461
|
}
|
|
576
462
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
return undefined;
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
const customValues =
|
|
586
|
-
loginOptions?.customValues || otherLoginOptions?.customValues
|
|
587
|
-
? {
|
|
588
|
-
...loginOptions?.customValues,
|
|
589
|
-
...otherLoginOptions?.customValues,
|
|
590
|
-
}
|
|
591
|
-
: undefined;
|
|
592
|
-
|
|
593
|
-
return {
|
|
594
|
-
...loginOptions,
|
|
595
|
-
...otherLoginOptions,
|
|
596
|
-
customValues,
|
|
597
|
-
};
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
function toStoredKey(key: SignIdentity | PartialIdentity): StoredKey {
|
|
601
|
-
if (key instanceof ECDSAKeyIdentity) {
|
|
602
|
-
return key.getKeyPair();
|
|
603
|
-
}
|
|
604
|
-
if (key instanceof Ed25519KeyIdentity) {
|
|
605
|
-
return JSON.stringify(key.toJSON());
|
|
606
|
-
}
|
|
607
|
-
throw new Error('Unsupported key type');
|
|
463
|
+
/** Reads the cached delegation expiration from localStorage (nanoseconds). */
|
|
464
|
+
function getExpirationFlag(): bigint | null {
|
|
465
|
+
const value = localStorage.getItem(KEY_STORAGE_EXPIRATION);
|
|
466
|
+
if (value === null) return null;
|
|
467
|
+
return BigInt(value);
|
|
608
468
|
}
|
|
609
469
|
|
|
610
|
-
|
|
470
|
+
/**
|
|
471
|
+
* One-time migration: moves a legacy session stored in localStorage
|
|
472
|
+
* into the primary storage, then cleans up the old entries.
|
|
473
|
+
* @param storage - The target storage backend.
|
|
474
|
+
* @param keyType - The expected key algorithm (only ECDSA keys are migrated).
|
|
475
|
+
*/
|
|
476
|
+
async function migrateFromLocalStorage(
|
|
611
477
|
storage: AuthClientStorage,
|
|
612
|
-
|
|
613
|
-
): Promise<
|
|
614
|
-
|
|
615
|
-
|
|
478
|
+
keyType: BaseKeyType,
|
|
479
|
+
): Promise<StoredKey | null> {
|
|
480
|
+
try {
|
|
481
|
+
const fallback = new LocalStorage();
|
|
482
|
+
const localChain = await fallback.get(KEY_STORAGE_DELEGATION);
|
|
483
|
+
const localKey = await fallback.get(KEY_STORAGE_KEY);
|
|
484
|
+
|
|
485
|
+
if (!localChain || !localKey || keyType !== ECDSA_KEY_LABEL) return null;
|
|
486
|
+
|
|
487
|
+
console.log('Discovered an identity stored in localstorage. Migrating to IndexedDB');
|
|
488
|
+
await storage.set(KEY_STORAGE_DELEGATION, localChain);
|
|
489
|
+
await storage.set(KEY_STORAGE_KEY, localKey);
|
|
490
|
+
await fallback.remove(KEY_STORAGE_DELEGATION);
|
|
491
|
+
await fallback.remove(KEY_STORAGE_KEY);
|
|
492
|
+
|
|
493
|
+
return localKey;
|
|
494
|
+
} catch (error) {
|
|
495
|
+
console.error(`error while attempting to recover localstorage: ${error}`);
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
616
498
|
}
|