@icp-sdk/auth 10.0.2 → 11.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -123
- package/dist/esm/client/auth-client.d.ts +105 -14
- package/dist/esm/client/auth-client.js +162 -14
- package/dist/esm/client/auth-client.js.map +1 -1
- package/dist/esm/client/index.d.ts +2 -1
- package/dist/esm/client/index.js +1 -1
- package/dist/esm/client/index.js.map +1 -1
- package/dist/esm/client/notification-consent.d.ts +102 -0
- package/dist/esm/client/notification-consent.js +143 -0
- package/dist/esm/client/notification-consent.js.map +1 -0
- package/dist/esm/client/sso-status.d.ts +91 -0
- package/dist/esm/client/sso-status.js +207 -0
- package/dist/esm/client/sso-status.js.map +1 -0
- package/dist/esm/client/sso.d.ts +3 -26
- package/dist/esm/client/sso.js +75 -89
- package/dist/esm/client/sso.js.map +1 -1
- package/package.json +1 -1
- package/src/client/auth-client.ts +192 -16
- package/src/client/index.ts +9 -1
- package/src/client/notification-consent.ts +214 -0
- package/src/client/sso-status.ts +257 -0
- package/src/client/sso.ts +77 -92
|
@@ -13,17 +13,29 @@ import {
|
|
|
13
13
|
import { Principal } from '@icp-sdk/core/principal';
|
|
14
14
|
import { Signer } from '@icp-sdk/signer';
|
|
15
15
|
import { PostMessageTransport, UrlTransport } from '@icp-sdk/signer/web';
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
AccountMismatchError,
|
|
18
|
+
chainAuthorisesKey,
|
|
19
|
+
SessionGoneError,
|
|
20
|
+
} from './app-delegation-source.js';
|
|
17
21
|
import { type HeldLock, stealLock, stealMintLock } from './app-lock.js';
|
|
18
22
|
import { fromBase64, toBase64 } from './base64.js';
|
|
19
23
|
import type { Credential, CredentialStorage } from './credential-storage.js';
|
|
20
24
|
import { watchActivity, watchForeground } from './foreground-refresh.js';
|
|
21
25
|
import { IdbCredentialStorage } from './idb-credential-storage.js';
|
|
26
|
+
import {
|
|
27
|
+
NotificationConsent,
|
|
28
|
+
type NotificationConsentStatus,
|
|
29
|
+
// Aliased: the method below is this library's name for the operation, and this
|
|
30
|
+
// is the request it sends.
|
|
31
|
+
requestNotificationConsent as sendConsentRequest,
|
|
32
|
+
} from './notification-consent.js';
|
|
22
33
|
import { requestSessionDelegation } from './session-delegation.js';
|
|
23
34
|
import { SessionIdentity } from './session-identity.js';
|
|
24
35
|
import { SessionMinter } from './session-minter.js';
|
|
25
36
|
import { type Slots, slotsFor } from './slots.js';
|
|
26
37
|
import { normalizeSsoDomain } from './sso.js';
|
|
38
|
+
import { createSsoDomainService, type SsoStatus, SsoStatusChecker } from './sso-status.js';
|
|
27
39
|
import { LocalStateStorage, type SessionState, type StateStorage } from './state-storage.js';
|
|
28
40
|
|
|
29
41
|
/**
|
|
@@ -235,8 +247,17 @@ export type AuthClientCreateOptions = AuthClientBaseOptions &
|
|
|
235
247
|
* When set, the identity provider URL includes an `sso` search param so
|
|
236
248
|
* the user authenticates via that organization's own provider.
|
|
237
249
|
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
250
|
+
* The value is trimmed, lowercased and IDNA-encoded, and has to be a
|
|
251
|
+
* domain and nothing else: no scheme, port, path, query, fragment or
|
|
252
|
+
* userinfo. A loopback host (`localhost`, `127.0.0.1`, or a name under
|
|
253
|
+
* `.localhost`) is accepted too, with a port, for a local mock provider.
|
|
254
|
+
*
|
|
255
|
+
* The client checks the domain with Internet Identity as soon as it is
|
|
256
|
+
* built, and {@link AuthClient.getSsoStatus} reports the answer. A value
|
|
257
|
+
* that is not a domain is reported as `invalid`, with the reason, rather
|
|
258
|
+
* than thrown, so a
|
|
259
|
+
* client can be built from whatever the user typed; a sign-in with it
|
|
260
|
+
* opens Internet Identity, which explains the error on its own screen.
|
|
240
261
|
*/
|
|
241
262
|
ssoDomain?: string;
|
|
242
263
|
}
|
|
@@ -384,6 +405,8 @@ export class AuthClient {
|
|
|
384
405
|
// interaction rather than two.
|
|
385
406
|
#interactions = 0;
|
|
386
407
|
#channelLock: HeldLock | undefined;
|
|
408
|
+
// Set only for a client built with `ssoDomain`.
|
|
409
|
+
readonly #sso: SsoStatusChecker | undefined;
|
|
387
410
|
|
|
388
411
|
constructor(options: AuthClientCreateOptions = {}) {
|
|
389
412
|
this.#options = options;
|
|
@@ -467,7 +490,18 @@ export class AuthClient {
|
|
|
467
490
|
identityProviderUrl.searchParams.set('openid', OPENID_PROVIDER_URLS[options.openIdProvider]);
|
|
468
491
|
}
|
|
469
492
|
if (options.ssoDomain !== undefined) {
|
|
470
|
-
|
|
493
|
+
let domain: string | { invalid: string };
|
|
494
|
+
try {
|
|
495
|
+
domain = normalizeSsoDomain(options.ssoDomain);
|
|
496
|
+
} catch (error) {
|
|
497
|
+
domain = { invalid: error instanceof Error ? error.message : String(error) };
|
|
498
|
+
}
|
|
499
|
+
// A value that is not a domain goes to Internet Identity as typed, which
|
|
500
|
+
// explains it on its own screen.
|
|
501
|
+
identityProviderUrl.searchParams.set(
|
|
502
|
+
'sso',
|
|
503
|
+
typeof domain === 'string' ? domain : options.ssoDomain.trim(),
|
|
504
|
+
);
|
|
471
505
|
// The SSO ceremony starts before a delegation is requested, so the
|
|
472
506
|
// channel carries the derivation origin too late to resolve the client.
|
|
473
507
|
if (options.derivationOrigin !== undefined) {
|
|
@@ -476,6 +510,14 @@ export class AuthClient {
|
|
|
476
510
|
options.derivationOrigin.toString(),
|
|
477
511
|
);
|
|
478
512
|
}
|
|
513
|
+
const canisterId = this.#canisterId;
|
|
514
|
+
this.#sso = new SsoStatusChecker(
|
|
515
|
+
domain,
|
|
516
|
+
() => createSsoDomainService({ canisterId, agentOptions: options.agentOptions }),
|
|
517
|
+
() => {
|
|
518
|
+
for (const listener of [...this.#listeners]) listener();
|
|
519
|
+
},
|
|
520
|
+
);
|
|
479
521
|
}
|
|
480
522
|
// `prompt` and `hint` are Internet Identity extensions, so they ride on the
|
|
481
523
|
// authorize URL rather than in the ICRC request. Baking them in here, as
|
|
@@ -621,6 +663,45 @@ export class AuthClient {
|
|
|
621
663
|
return this.#status;
|
|
622
664
|
}
|
|
623
665
|
|
|
666
|
+
/**
|
|
667
|
+
* Whether the organization domain this client was built with can be signed
|
|
668
|
+
* in with, or `undefined` for a client built without `ssoDomain`.
|
|
669
|
+
*
|
|
670
|
+
* Synchronous, and the same object until the answer changes, like
|
|
671
|
+
* {@link getStatus}; {@link subscribe} says when to read it again. A client
|
|
672
|
+
* built with `ssoDomain` starts checking as soon as it is built: it asks
|
|
673
|
+
* Internet Identity to resolve the domain, then reads the result
|
|
674
|
+
* until it is final, so `checking` lasts until Internet Identity answers.
|
|
675
|
+
* Building the client also warms Internet Identity's cache for the domain, so
|
|
676
|
+
* a sign-in that follows starts without waiting on the fetch.
|
|
677
|
+
*
|
|
678
|
+
* {@link signIn} proceeds in every state: Internet Identity resolves the
|
|
679
|
+
* domain itself during the sign-in, and explains a failure, a value that is
|
|
680
|
+
* not a domain included, on its own screen.
|
|
681
|
+
*
|
|
682
|
+
* @example
|
|
683
|
+
* const client = new AuthClient({ ssoDomain: input.value });
|
|
684
|
+
* client.subscribe(() => render(client.getSsoStatus()));
|
|
685
|
+
* render(client.getSsoStatus());
|
|
686
|
+
*/
|
|
687
|
+
getSsoStatus(): SsoStatus | undefined {
|
|
688
|
+
return this.#sso?.status;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Checks the organization domain again, for a "Try again" button.
|
|
693
|
+
*
|
|
694
|
+
* The status goes back to `checking` and then to the new answer. Calling it
|
|
695
|
+
* while an `unavailable` answer's `retryAfter` is still in the future is
|
|
696
|
+
* harmless: Internet Identity does not fetch again before then, so the answer
|
|
697
|
+
* comes back the same at once. Nothing re-checks on its own once `retryAfter`
|
|
698
|
+
* passes. Does nothing for an `invalid` domain, which no retry can fix, or for
|
|
699
|
+
* a client built without `ssoDomain`.
|
|
700
|
+
*/
|
|
701
|
+
refreshSsoStatus(): void {
|
|
702
|
+
this.#sso?.refresh();
|
|
703
|
+
}
|
|
704
|
+
|
|
624
705
|
/** Reads the record and builds the answer `getStatus` hands out. */
|
|
625
706
|
#readStatus(): SessionStatus {
|
|
626
707
|
const record = this.#stateStorage.get(this.#slots.state);
|
|
@@ -637,22 +718,24 @@ export class AuthClient {
|
|
|
637
718
|
}
|
|
638
719
|
|
|
639
720
|
/**
|
|
640
|
-
* Watches who is signed in here, and
|
|
721
|
+
* Watches who is signed in here, and the SSO status of a client built with
|
|
722
|
+
* `ssoDomain`, and returns a function that stops watching.
|
|
641
723
|
*
|
|
642
|
-
* `getStatus()` and the predicates beside
|
|
643
|
-
* rendering on them needs to be told when to read
|
|
644
|
-
* for reasons that are nothing to do with this
|
|
645
|
-
* out, a sibling subdomain publishing a sign-in,
|
|
646
|
-
* re-issuing silently
|
|
724
|
+
* `getStatus()`, `getSsoStatus()` and the predicates beside them are
|
|
725
|
+
* snapshots, so an application rendering on them needs to be told when to read
|
|
726
|
+
* again. The record changes for reasons that are nothing to do with this
|
|
727
|
+
* client: another tab signing out, a sibling subdomain publishing a sign-in,
|
|
728
|
+
* a peer client on this page re-issuing silently. The SSO status changes when
|
|
729
|
+
* Internet Identity answers. This is how both arrive.
|
|
647
730
|
*
|
|
648
|
-
* Fired once the
|
|
649
|
-
*
|
|
650
|
-
*
|
|
731
|
+
* Fired once the new answer is readable, so a listener sees what it was told
|
|
732
|
+
* about. It says that something changed and not what: a listener reads the
|
|
733
|
+
* answer it wants, `getStatus()` or `getSsoStatus()`.
|
|
651
734
|
*
|
|
652
735
|
* What it does not cover is the identity being replaced under an application
|
|
653
736
|
* that holds one — an app delegation rotating is deliberately invisible, and
|
|
654
737
|
* nothing about who is signed in has changed when it does.
|
|
655
|
-
* @param listener - Called after the record changes.
|
|
738
|
+
* @param listener - Called after the record or the SSO status changes.
|
|
656
739
|
* @returns A function that unregisters it.
|
|
657
740
|
*/
|
|
658
741
|
subscribe(listener: () => void): () => void {
|
|
@@ -667,10 +750,12 @@ export class AuthClient {
|
|
|
667
750
|
|
|
668
751
|
/**
|
|
669
752
|
* Releases what this client hooked: the browser listeners, the state
|
|
670
|
-
* subscription,
|
|
671
|
-
* discarding a client, so nothing it
|
|
753
|
+
* subscription, the refresh the identity has scheduled, and an SSO domain
|
|
754
|
+
* check still in progress. Call it when discarding a client, so nothing it
|
|
755
|
+
* registered outlives it.
|
|
672
756
|
*/
|
|
673
757
|
dispose(): void {
|
|
758
|
+
this.#sso?.dispose();
|
|
674
759
|
// Recorded, because the constructor starts the restore without awaiting it:
|
|
675
760
|
// a client disposed while one is in flight would otherwise have an identity
|
|
676
761
|
// installed afterwards, scheduling refreshes nobody can stop.
|
|
@@ -1116,6 +1201,97 @@ export class AuthClient {
|
|
|
1116
1201
|
}
|
|
1117
1202
|
}
|
|
1118
1203
|
|
|
1204
|
+
/**
|
|
1205
|
+
* Asks the user whether this application may notify them.
|
|
1206
|
+
*
|
|
1207
|
+
* Opens the identity provider the way a sign-in does, because consent is the
|
|
1208
|
+
* user's answer and nothing else can stand in for it. Where they agree, the
|
|
1209
|
+
* browser they answered on is registered for delivery.
|
|
1210
|
+
*
|
|
1211
|
+
* A refusal is an answer rather than a failure: it returns `granted: false`,
|
|
1212
|
+
* and an application renders that. Calling it again where consent is already
|
|
1213
|
+
* held returns what is recorded without asking a second time.
|
|
1214
|
+
*
|
|
1215
|
+
* @returns Whether this application may notify the user.
|
|
1216
|
+
* @throws {InteractionRequiredError} Where the client was built with
|
|
1217
|
+
* `prompt: 'none'`, which no consent ceremony can be run under.
|
|
1218
|
+
* @throws {NotificationsUnsupportedError} Where this identity provider does
|
|
1219
|
+
* not send notifications at all.
|
|
1220
|
+
* @example
|
|
1221
|
+
* const { granted } = await authClient.requestNotificationConsent();
|
|
1222
|
+
*/
|
|
1223
|
+
async requestNotificationConsent(): Promise<NotificationConsentStatus> {
|
|
1224
|
+
// The channel lock and not the sign-in lock, for the reason `requestAttributes`
|
|
1225
|
+
// takes it: this opens the signer channel, which an origin has one of, and
|
|
1226
|
+
// writes nothing anyone else reads.
|
|
1227
|
+
this.#beginInteraction();
|
|
1228
|
+
try {
|
|
1229
|
+
const response = sendConsentRequest(this.#signer, this.#options.derivationOrigin?.toString());
|
|
1230
|
+
this.#takeChannelLock();
|
|
1231
|
+
return await response;
|
|
1232
|
+
} finally {
|
|
1233
|
+
this.#endInteraction();
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/**
|
|
1238
|
+
* Withdraws this application's consent to notify the user.
|
|
1239
|
+
*
|
|
1240
|
+
* A canister call made as the session, so it needs no ceremony and opens
|
|
1241
|
+
* nothing: the user already decided, and this records that they changed their
|
|
1242
|
+
* mind. Idempotent — consent that is already withdrawn is not an error.
|
|
1243
|
+
*
|
|
1244
|
+
* @throws {SessionGoneError} Where this origin holds no live session.
|
|
1245
|
+
*/
|
|
1246
|
+
async revokeNotificationConsent(): Promise<void> {
|
|
1247
|
+
await (await this.#consent()).revoke();
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
/**
|
|
1251
|
+
* Whether this application may notify the signed-in user.
|
|
1252
|
+
*
|
|
1253
|
+
* What a notifications control is painted from. A query, so the answer is
|
|
1254
|
+
* uncertified, and it reports the consent alone: whether a notification would
|
|
1255
|
+
* reach the user also depends on the device they hold, which this does not
|
|
1256
|
+
* speak for.
|
|
1257
|
+
*
|
|
1258
|
+
* @returns Whether this application may notify the user.
|
|
1259
|
+
* @throws {SessionGoneError} Where this origin holds no live session.
|
|
1260
|
+
* @example
|
|
1261
|
+
* const { granted } = await authClient.getNotificationConsentStatus();
|
|
1262
|
+
*/
|
|
1263
|
+
async getNotificationConsentStatus(): Promise<NotificationConsentStatus> {
|
|
1264
|
+
return (await this.#consent()).status();
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
/**
|
|
1268
|
+
* Builds the consent calls from the session this origin holds.
|
|
1269
|
+
*
|
|
1270
|
+
* Read at the call rather than kept, so a session replaced since the last call
|
|
1271
|
+
* is not the one spoken for, and a sign-out between them is reported as what it
|
|
1272
|
+
* is instead of answered from a stale actor.
|
|
1273
|
+
*/
|
|
1274
|
+
async #consent(): Promise<NotificationConsent> {
|
|
1275
|
+
// Wait for the constructor's session restore, which is what puts the
|
|
1276
|
+
// credential these calls are made with where this can read it.
|
|
1277
|
+
await this.#init();
|
|
1278
|
+
|
|
1279
|
+
// The chain is checked and not just its presence: `#init()` resolves once, so a
|
|
1280
|
+
// session that ran out while the page stayed open is still in the slot, and a
|
|
1281
|
+
// call made with it would fail as a rejected signature rather than as what it is.
|
|
1282
|
+
const session = await this.#credentialStorage.get(this.#slots.session);
|
|
1283
|
+
if (session === null || session.chain === undefined || !isDelegationValid(session.chain)) {
|
|
1284
|
+
throw new SessionGoneError();
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
return NotificationConsent.create({
|
|
1288
|
+
sessionKey: session.identity,
|
|
1289
|
+
sessionChain: session.chain,
|
|
1290
|
+
canisterId: this.#canisterId,
|
|
1291
|
+
agentOptions: this.#options.agentOptions,
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1119
1295
|
/**
|
|
1120
1296
|
* Runs and journals a piece of your own async work so its result stays stable
|
|
1121
1297
|
* across the `'redirect'` flow.
|
package/src/client/index.ts
CHANGED
|
@@ -14,6 +14,14 @@ export { watchActivity, watchForeground } from './foreground-refresh.js';
|
|
|
14
14
|
export { IdbCredentialStorage } from './idb-credential-storage.js';
|
|
15
15
|
export { LocalCredentialStorage } from './local-credential-storage.js';
|
|
16
16
|
export { MemoryCredentialStorage } from './memory-credential-storage.js';
|
|
17
|
+
export {
|
|
18
|
+
NotificationConsent,
|
|
19
|
+
type NotificationConsentOptions,
|
|
20
|
+
type NotificationConsentService,
|
|
21
|
+
type NotificationConsentStatus,
|
|
22
|
+
NotificationsUnsupportedError,
|
|
23
|
+
requestNotificationConsent,
|
|
24
|
+
} from './notification-consent.js';
|
|
17
25
|
export { InteractionRequiredError, requestSessionDelegation } from './session-delegation.js';
|
|
18
26
|
export { SessionIdentity, type SessionIdentityOptions } from './session-identity.js';
|
|
19
27
|
export { SessionMinter, type SessionMinterOptions } from './session-minter.js';
|
|
@@ -21,7 +29,7 @@ export {
|
|
|
21
29
|
DEFAULT_SHARED_MEMORY_NAME,
|
|
22
30
|
SharedMemoryCredentialStorage,
|
|
23
31
|
} from './shared-memory-credential-storage.js';
|
|
24
|
-
export {
|
|
32
|
+
export type { SsoStatus } from './sso-status.js';
|
|
25
33
|
export {
|
|
26
34
|
LocalStateStorage,
|
|
27
35
|
MemoryStateStorage,
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { Actor, HttpAgent, type HttpAgentOptions, type SignIdentity } from '@icp-sdk/core/agent';
|
|
2
|
+
import type { IDL } from '@icp-sdk/core/candid';
|
|
3
|
+
import { type DelegationChain, DelegationIdentity } from '@icp-sdk/core/identity';
|
|
4
|
+
import type { Principal } from '@icp-sdk/core/principal';
|
|
5
|
+
import type { Signer } from '@icp-sdk/signer';
|
|
6
|
+
import { SessionGoneError } from './app-delegation-source.js';
|
|
7
|
+
import { InteractionRequiredError } from './session-delegation.js';
|
|
8
|
+
import { assertChainReaches } from './session-minter.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The method Internet Identity answers with the user's decision about being
|
|
12
|
+
* notified. Named for its provider because a JSON-RPC method travels over a
|
|
13
|
+
* transport shared with other signers, unlike a canister method.
|
|
14
|
+
*/
|
|
15
|
+
const NOTIFICATION_CONSENT_METHOD = 'ii_notification_consent';
|
|
16
|
+
|
|
17
|
+
/** The code Internet Identity denies a `prompt: 'none'` request with. */
|
|
18
|
+
const INTERACTION_REQUIRED_CODE = 3002;
|
|
19
|
+
|
|
20
|
+
/** JSON-RPC's own code for a method the receiver does not implement. */
|
|
21
|
+
const METHOD_NOT_FOUND_CODE = -32601;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Whether the user has allowed this application to notify them.
|
|
25
|
+
*
|
|
26
|
+
* A record rather than a bare boolean, mirroring what the canister answers: a
|
|
27
|
+
* second fact about the decision can then be reported without a breaking change
|
|
28
|
+
* to anything that already reads `granted`.
|
|
29
|
+
*/
|
|
30
|
+
export interface NotificationConsentStatus {
|
|
31
|
+
granted: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Thrown where the identity provider does not send notifications at all.
|
|
36
|
+
*
|
|
37
|
+
* Its own error because its remedy is its own: there is nothing to retry and no
|
|
38
|
+
* ceremony to run, so an application hides the control rather than reporting a
|
|
39
|
+
* failure to the user. A deployment either carries the feature or does not.
|
|
40
|
+
*/
|
|
41
|
+
export class NotificationsUnsupportedError extends Error {
|
|
42
|
+
constructor(message = 'This Internet Identity does not send notifications') {
|
|
43
|
+
super(message);
|
|
44
|
+
this.name = 'NotificationsUnsupportedError';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Asks the user, through the identity provider, whether this application may
|
|
50
|
+
* notify them, and registers the browser for delivery where they agree.
|
|
51
|
+
*
|
|
52
|
+
* A refusal is an answer: it comes back as `granted: false` rather than as an
|
|
53
|
+
* error, because the user decided and the application has something to render.
|
|
54
|
+
*
|
|
55
|
+
* @param signer - The channel to the identity provider.
|
|
56
|
+
* @param derivationOrigin - The origin the account is derived for, where the
|
|
57
|
+
* application signs in under one other than its own.
|
|
58
|
+
* @returns What the provider recorded, read back rather than reported from the
|
|
59
|
+
* screen.
|
|
60
|
+
* @throws {InteractionRequiredError} Where the request asked not to render.
|
|
61
|
+
* @throws {NotificationsUnsupportedError} Where the provider does not notify.
|
|
62
|
+
*/
|
|
63
|
+
export async function requestNotificationConsent(
|
|
64
|
+
signer: Signer,
|
|
65
|
+
derivationOrigin?: string,
|
|
66
|
+
): Promise<NotificationConsentStatus> {
|
|
67
|
+
const response = await signer.sendRequest({
|
|
68
|
+
jsonrpc: '2.0' as const,
|
|
69
|
+
id: globalThis.crypto.randomUUID(),
|
|
70
|
+
method: NOTIFICATION_CONSENT_METHOD,
|
|
71
|
+
// Every member is optional, so an application with nothing to pass sends an
|
|
72
|
+
// empty object rather than no `params` at all.
|
|
73
|
+
params: derivationOrigin === undefined ? {} : { icrc95DerivationOrigin: derivationOrigin },
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
if ('error' in response) {
|
|
77
|
+
if (response.error.code === METHOD_NOT_FOUND_CODE) {
|
|
78
|
+
throw new NotificationsUnsupportedError(response.error.message);
|
|
79
|
+
}
|
|
80
|
+
if (response.error.code === INTERACTION_REQUIRED_CODE) {
|
|
81
|
+
throw new InteractionRequiredError(response.error.message);
|
|
82
|
+
}
|
|
83
|
+
throw new Error(response.error.message);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const result = response.result as Record<string, unknown> | undefined;
|
|
87
|
+
if (typeof result?.granted !== 'boolean') {
|
|
88
|
+
throw new Error('Invalid response: missing granted');
|
|
89
|
+
}
|
|
90
|
+
return { granted: result.granted };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** What the canister answers either consent call with. */
|
|
94
|
+
type NotificationConsentError = { NoSuchSession: null } | { InternalCanisterError: string };
|
|
95
|
+
type Result<T> = { Ok: T } | { Err: NotificationConsentError };
|
|
96
|
+
|
|
97
|
+
/** The two Internet Identity methods an application makes about its own consent. */
|
|
98
|
+
export interface NotificationConsentService {
|
|
99
|
+
app_notification_consent_status(): Promise<Result<{ is_granted: boolean }>>;
|
|
100
|
+
app_revoke_notification_consent(): Promise<Result<null>>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Written out for the same reason as the session methods in `session-minter.ts`:
|
|
105
|
+
* this package carries no Internet Identity declarations.
|
|
106
|
+
*/
|
|
107
|
+
const idlFactory: IDL.InterfaceFactory = ({ IDL }) => {
|
|
108
|
+
const ConsentError = IDL.Variant({
|
|
109
|
+
NoSuchSession: IDL.Null,
|
|
110
|
+
InternalCanisterError: IDL.Text,
|
|
111
|
+
});
|
|
112
|
+
const Status = IDL.Record({ is_granted: IDL.Bool });
|
|
113
|
+
return IDL.Service({
|
|
114
|
+
app_notification_consent_status: IDL.Func(
|
|
115
|
+
[],
|
|
116
|
+
[IDL.Variant({ Ok: Status, Err: ConsentError })],
|
|
117
|
+
['query'],
|
|
118
|
+
),
|
|
119
|
+
app_revoke_notification_consent: IDL.Func(
|
|
120
|
+
[],
|
|
121
|
+
[IDL.Variant({ Ok: IDL.Null, Err: ConsentError })],
|
|
122
|
+
[],
|
|
123
|
+
),
|
|
124
|
+
});
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Unwraps a canister result, turning the one terminal error into
|
|
129
|
+
* {@link SessionGoneError} and the other into a plain failure.
|
|
130
|
+
*/
|
|
131
|
+
const unwrap = <T>(result: Result<T>, failed: string): T => {
|
|
132
|
+
if ('Ok' in result) return result.Ok;
|
|
133
|
+
if ('NoSuchSession' in result.Err) throw new SessionGoneError();
|
|
134
|
+
// A candid variant, so interpolating it directly renders "[object Object]" and
|
|
135
|
+
// throws away the canister's own explanation of what went wrong.
|
|
136
|
+
const detail = result.Err.InternalCanisterError;
|
|
137
|
+
throw new Error(`${failed}${detail === '' ? '' : `: ${detail}`}`);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export interface NotificationConsentOptions {
|
|
141
|
+
/** The key the session chain delegates to. Signs the calls made here. */
|
|
142
|
+
sessionKey: SignIdentity;
|
|
143
|
+
|
|
144
|
+
/** The chain proving those calls come from the session. Restricted to II. */
|
|
145
|
+
sessionChain: DelegationChain;
|
|
146
|
+
|
|
147
|
+
/** The canister these calls go to. */
|
|
148
|
+
canisterId: Principal;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Options for the agent making them. `identity` is not among them: the agent
|
|
152
|
+
* signs as the session, which is what both calls are authorised by.
|
|
153
|
+
*/
|
|
154
|
+
agentOptions?: Omit<HttpAgentOptions, 'identity'>;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Reads and withdraws an application's consent, at the Internet Identity canister. */
|
|
158
|
+
export class NotificationConsent {
|
|
159
|
+
readonly #service: NotificationConsentService;
|
|
160
|
+
|
|
161
|
+
private constructor(service: NotificationConsentService) {
|
|
162
|
+
this.#service = service;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Builds the calls as the session.
|
|
167
|
+
*
|
|
168
|
+
* As the session and not as the application: Internet Identity authorises both
|
|
169
|
+
* methods by the principal the session signs with, which is the one that mints
|
|
170
|
+
* app delegations rather than any of the ones minted from it.
|
|
171
|
+
*/
|
|
172
|
+
static async create(options: NotificationConsentOptions): Promise<NotificationConsent> {
|
|
173
|
+
const { canisterId } = options;
|
|
174
|
+
assertChainReaches(options.sessionChain, canisterId);
|
|
175
|
+
const agent = await HttpAgent.create({
|
|
176
|
+
...options.agentOptions,
|
|
177
|
+
// Last, so no agent option can replace the session these calls are made as.
|
|
178
|
+
identity: DelegationIdentity.fromDelegation(options.sessionKey, options.sessionChain),
|
|
179
|
+
});
|
|
180
|
+
return new NotificationConsent(
|
|
181
|
+
Actor.createActor<NotificationConsentService>(idlFactory, { agent, canisterId }),
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Whether this application may notify the signed-in user.
|
|
187
|
+
*
|
|
188
|
+
* A query, so the answer is uncertified: it is what a control is painted from,
|
|
189
|
+
* not something to rest a decision on.
|
|
190
|
+
*/
|
|
191
|
+
async status(): Promise<NotificationConsentStatus> {
|
|
192
|
+
const status = unwrap(
|
|
193
|
+
await this.#service.app_notification_consent_status(),
|
|
194
|
+
'Internet Identity could not report this notification consent',
|
|
195
|
+
);
|
|
196
|
+
return { granted: status.is_granted };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Withdraws this application's consent to notify the signed-in user.
|
|
201
|
+
*
|
|
202
|
+
* Idempotent at the canister: consent that is already withdrawn is `Ok`, because
|
|
203
|
+
* the caller wanted it gone and it is gone. What comes back as an error is the
|
|
204
|
+
* canister failing to write, and it is raised rather than dropped — an
|
|
205
|
+
* application that told a user it had turned notifications off should be able to
|
|
206
|
+
* find out that it had not.
|
|
207
|
+
*/
|
|
208
|
+
async revoke(): Promise<void> {
|
|
209
|
+
unwrap(
|
|
210
|
+
await this.#service.app_revoke_notification_consent(),
|
|
211
|
+
'Internet Identity could not withdraw this notification consent',
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|