UNPKG

@dfinity/auth-client

Version:

JavaScript and TypeScript library to provide a simple integration with an IC Internet Identity

383 lines 17.9 kB
import { AnonymousIdentity, SignIdentity, } from '@dfinity/agent'; import { Delegation, DelegationChain, isDelegationValid, DelegationIdentity, Ed25519KeyIdentity, ECDSAKeyIdentity, PartialDelegationIdentity, PartialIdentity, } from '@dfinity/identity'; import { Principal } from '@dfinity/principal'; import { IdleManager } from "./idleManager.js"; import { IdbStorage, isBrowser, KEY_STORAGE_DELEGATION, KEY_STORAGE_KEY, KEY_VECTOR, LocalStorage, } from "./storage.js"; export { IdbStorage, LocalStorage, KEY_STORAGE_DELEGATION, KEY_STORAGE_KEY, } from "./storage.js"; export { IdbKeyVal } from "./db.js"; const NANOSECONDS_PER_SECOND = BigInt(1_000_000_000); const SECONDS_PER_HOUR = BigInt(3_600); const NANOSECONDS_PER_HOUR = NANOSECONDS_PER_SECOND * SECONDS_PER_HOUR; const IDENTITY_PROVIDER_DEFAULT = 'https://identity.internetcomputer.org'; const IDENTITY_PROVIDER_ENDPOINT = '#authorize'; const DEFAULT_MAX_TIME_TO_LIVE = BigInt(8) * NANOSECONDS_PER_HOUR; const ECDSA_KEY_LABEL = 'ECDSA'; const ED25519_KEY_LABEL = 'Ed25519'; const INTERRUPT_CHECK_INTERVAL = 500; export const ERROR_USER_INTERRUPT = 'UserInterrupt'; export * from "./idleManager.js"; /** * Tool to manage authentication and identity * @see {@link AuthClient} */ export class AuthClient { _identity; _key; _chain; _storage; idleManager; _createOptions; _idpWindow; _eventHandler; /** * Create an AuthClient to manage authentication and identity * @param {AuthClientCreateOptions} options - Options for creating an {@link AuthClient} * @see {@link AuthClientCreateOptions} * @param options.identity Optional Identity to use as the base * @see {@link SignIdentity} * @param options.storage Storage mechanism for delegation credentials * @see {@link AuthClientStorage} * @param options.keyType Type of key to use for the base key * @param {IdleOptions} options.idleOptions Configures an {@link IdleManager} * @see {@link IdleOptions} * 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. * @example * const authClient = await AuthClient.create({ * idleOptions: { * disableIdle: true * } * }) */ static async create(options = {}) { const storage = options.storage ?? new IdbStorage(); const keyType = options.keyType ?? ECDSA_KEY_LABEL; let key = null; if (options.identity) { key = options.identity; } else { let maybeIdentityStorage = await storage.get(KEY_STORAGE_KEY); if (!maybeIdentityStorage && isBrowser) { // Attempt to migrate from localstorage try { const fallbackLocalStorage = new LocalStorage(); const localChain = await fallbackLocalStorage.get(KEY_STORAGE_DELEGATION); const localKey = await fallbackLocalStorage.get(KEY_STORAGE_KEY); // not relevant for Ed25519 if (localChain && localKey && keyType === ECDSA_KEY_LABEL) { console.log('Discovered an identity stored in localstorage. Migrating to IndexedDB'); await storage.set(KEY_STORAGE_DELEGATION, localChain); await storage.set(KEY_STORAGE_KEY, localKey); maybeIdentityStorage = localChain; // clean up await fallbackLocalStorage.remove(KEY_STORAGE_DELEGATION); await fallbackLocalStorage.remove(KEY_STORAGE_KEY); } } catch (error) { console.error('error while attempting to recover localstorage: ' + error); } } if (maybeIdentityStorage) { try { if (typeof maybeIdentityStorage === 'object') { if (keyType === ED25519_KEY_LABEL && typeof maybeIdentityStorage === 'string') { key = Ed25519KeyIdentity.fromJSON(maybeIdentityStorage); } else { key = await ECDSAKeyIdentity.fromKeyPair(maybeIdentityStorage); } } else if (typeof maybeIdentityStorage === 'string') { // This is a legacy identity, which is a serialized Ed25519KeyIdentity. key = Ed25519KeyIdentity.fromJSON(maybeIdentityStorage); } } catch { // Ignore this, this means that the localStorage value isn't a valid Ed25519KeyIdentity or ECDSAKeyIdentity // serialization. } } } let identity = new AnonymousIdentity(); let chain = null; if (key) { try { const chainStorage = await storage.get(KEY_STORAGE_DELEGATION); if (typeof chainStorage === 'object' && chainStorage !== null) { throw new Error('Delegation chain is incorrectly stored. A delegation chain should be stored as a string.'); } if (options.identity) { identity = options.identity; } else if (chainStorage) { chain = DelegationChain.fromJSON(chainStorage); // Verify that the delegation isn't expired. if (!isDelegationValid(chain)) { await _deleteStorage(storage); key = null; } else { // If the key is a public key, then we create a PartialDelegationIdentity. if ('toDer' in key) { identity = PartialDelegationIdentity.fromDelegation(key, chain); // otherwise, we create a DelegationIdentity. } else { identity = DelegationIdentity.fromDelegation(key, chain); } } } } catch (e) { console.error(e); // If there was a problem loading the chain, delete the key. await _deleteStorage(storage); key = null; } } let idleManager; if (options.idleOptions?.disableIdle) { idleManager = undefined; } // if there is a delegation chain or provided identity, setup idleManager else if (chain || options.identity) { idleManager = IdleManager.create(options.idleOptions); } if (!key) { // Create a new key (whether or not one was in storage). if (keyType === ED25519_KEY_LABEL) { key = Ed25519KeyIdentity.generate(); await storage.set(KEY_STORAGE_KEY, JSON.stringify(key.toJSON())); } else { if (options.storage && keyType === ECDSA_KEY_LABEL) { console.warn(`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`); } key = await ECDSAKeyIdentity.generate(); await storage.set(KEY_STORAGE_KEY, key.getKeyPair()); } } return new this(identity, key, chain, storage, idleManager, options); } constructor(_identity, _key, _chain, _storage, idleManager, _createOptions, // A handle on the IdP window. _idpWindow, // The event handler for processing events from the IdP. _eventHandler) { this._identity = _identity; this._key = _key; this._chain = _chain; this._storage = _storage; this.idleManager = idleManager; this._createOptions = _createOptions; this._idpWindow = _idpWindow; this._eventHandler = _eventHandler; this._registerDefaultIdleCallback(); } _registerDefaultIdleCallback() { const idleOptions = this._createOptions?.idleOptions; /** * Default behavior is to clear stored identity and reload the page. * By either setting the disableDefaultIdleCallback flag or passing in a custom idle callback, we will ignore this config */ if (!idleOptions?.onIdle && !idleOptions?.disableDefaultIdleCallback) { this.idleManager?.registerCallback(() => { this.logout(); location.reload(); }); } } async _handleSuccess(message, onSuccess) { const delegations = message.delegations.map(signedDelegation => { return { delegation: new Delegation(signedDelegation.delegation.pubkey, signedDelegation.delegation.expiration, signedDelegation.delegation.targets), signature: signedDelegation.signature, }; }); const delegationChain = DelegationChain.fromDelegations(delegations, message.userPublicKey); const key = this._key; if (!key) { return; } this._chain = delegationChain; if ('toDer' in key) { this._identity = PartialDelegationIdentity.fromDelegation(key, this._chain); } else { this._identity = DelegationIdentity.fromDelegation(key, this._chain); } this._idpWindow?.close(); const idleOptions = this._createOptions?.idleOptions; // create the idle manager on a successful login if we haven't disabled it // and it doesn't already exist. if (!this.idleManager && !idleOptions?.disableIdle) { this.idleManager = IdleManager.create(idleOptions); this._registerDefaultIdleCallback(); } this._removeEventListener(); delete this._idpWindow; if (this._chain) { await this._storage.set(KEY_STORAGE_DELEGATION, JSON.stringify(this._chain.toJSON())); } // onSuccess should be the last thing to do to avoid consumers // interfering by navigating or refreshing the page onSuccess?.(message); } getIdentity() { return this._identity; } async isAuthenticated() { return (!this.getIdentity().getPrincipal().isAnonymous() && this._chain !== null && isDelegationValid(this._chain)); } /** * AuthClient Login - Opens up a new window to authenticate with Internet Identity * @param {AuthClientLoginOptions} options - Options for logging in, merged with the options set during creation if any. Note: we only perform a shallow merge for the `customValues` property. * @param options.identityProvider Identity provider * @param options.maxTimeToLive Expiration of the authentication in nanoseconds * @param options.allowPinAuthentication If present, indicates whether or not the Identity Provider should allow the user to authenticate and/or register using a temporary key/PIN identity. Authenticating dapps may want to prevent users from using Temporary keys/PIN identities because Temporary keys/PIN identities are less secure than Passkeys (webauthn credentials) and because Temporary keys/PIN identities generally only live in a browser database (which may get cleared by the browser/OS). * @param options.derivationOrigin Origin for Identity Provider to use while generating the delegated identity * @param options.windowOpenerFeatures Configures the opened authentication window * @param options.onSuccess Callback once login has completed * @param options.onError Callback in case authentication fails * @param options.customValues Extra values to be passed in the login request during the authorize-ready phase. Note: we only perform a shallow merge for the `customValues` property. * @example * const authClient = await AuthClient.create(); * authClient.login({ * identityProvider: 'http://<canisterID>.127.0.0.1:8000', * maxTimeToLive: BigInt (7) * BigInt(24) * BigInt(3_600_000_000_000), // 1 week * windowOpenerFeatures: "toolbar=0,location=0,menubar=0,width=500,height=500,left=100,top=100", * onSuccess: () => { * console.log('Login Successful!'); * }, * onError: (error) => { * console.error('Login Failed: ', error); * } * }); */ async login(options) { // Merge the passed options with the options set during creation const loginOptions = mergeLoginOptions(this._createOptions?.loginOptions, options); // Set default maxTimeToLive to 8 hours const maxTimeToLive = loginOptions?.maxTimeToLive ?? DEFAULT_MAX_TIME_TO_LIVE; // Create the URL of the IDP. (e.g. https://XXXX/#authorize) const identityProviderUrl = new URL(loginOptions?.identityProvider?.toString() || IDENTITY_PROVIDER_DEFAULT); // Set the correct hash if it isn't already set. identityProviderUrl.hash = IDENTITY_PROVIDER_ENDPOINT; // If `login` has been called previously, then close/remove any previous windows // and event listeners. this._idpWindow?.close(); this._removeEventListener(); // Add an event listener to handle responses. this._eventHandler = this._getEventHandler(identityProviderUrl, { maxTimeToLive, ...loginOptions, }); window.addEventListener('message', this._eventHandler); // Open a new window with the IDP provider. this._idpWindow = window.open(identityProviderUrl.toString(), 'idpWindow', loginOptions?.windowOpenerFeatures) ?? undefined; // Check if the _idpWindow is closed by user. const checkInterruption = () => { // The _idpWindow is opened and not yet closed by the client if (this._idpWindow) { if (this._idpWindow.closed) { this._handleFailure(ERROR_USER_INTERRUPT, loginOptions?.onError); } else { setTimeout(checkInterruption, INTERRUPT_CHECK_INTERVAL); } } }; checkInterruption(); } _getEventHandler(identityProviderUrl, options) { return async (event) => { if (event.origin !== identityProviderUrl.origin) { // Ignore any event that is not from the identity provider return; } const message = event.data; switch (message.kind) { case 'authorize-ready': { // IDP is ready. Send a message to request authorization. const request = { kind: 'authorize-client', sessionPublicKey: new Uint8Array(this._key?.getPublicKey().toDer()), maxTimeToLive: options?.maxTimeToLive, allowPinAuthentication: options?.allowPinAuthentication, derivationOrigin: options?.derivationOrigin?.toString(), // Pass any custom values to the IDP. ...options?.customValues, }; this._idpWindow?.postMessage(request, identityProviderUrl.origin); break; } case 'authorize-client-success': // Create the delegation chain and store it. try { await this._handleSuccess(message, options?.onSuccess); } catch (err) { this._handleFailure(err.message, options?.onError); } break; case 'authorize-client-failure': this._handleFailure(message.text, options?.onError); break; default: break; } }; } _handleFailure(errorMessage, onError) { this._idpWindow?.close(); onError?.(errorMessage); this._removeEventListener(); delete this._idpWindow; } _removeEventListener() { if (this._eventHandler) { window.removeEventListener('message', this._eventHandler); } this._eventHandler = undefined; } async logout(options = {}) { await _deleteStorage(this._storage); // Reset this auth client to a non-authenticated state. this._identity = new AnonymousIdentity(); this._chain = null; if (options.returnTo) { try { window.history.pushState({}, '', options.returnTo); } catch { window.location.href = options.returnTo; } } } } async function _deleteStorage(storage) { await storage.remove(KEY_STORAGE_KEY); await storage.remove(KEY_STORAGE_DELEGATION); await storage.remove(KEY_VECTOR); } function mergeLoginOptions(loginOptions, otherLoginOptions) { if (!loginOptions && !otherLoginOptions) { return undefined; } const customValues = loginOptions?.customValues || otherLoginOptions?.customValues ? { ...loginOptions?.customValues, ...otherLoginOptions?.customValues, } : undefined; return { ...loginOptions, ...otherLoginOptions, customValues, }; } //# sourceMappingURL=index.js.map