UNPKG

@stacks/auth

Version:

Authentication for Stacks apps.

414 lines (380 loc) 14 kB
/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ // todo: fix eslint rule, rename UserSession? import { AppConfig } from './appConfig'; import { SessionOptions } from './sessionData'; import { InstanceDataStore, LocalStorageStore, SessionDataStore } from './sessionStore'; import { decodeToken } from 'jsontokens'; import { verifyAuthResponse } from './verification'; import * as authMessages from './messages'; import { utils } from '@noble/secp256k1'; import { decryptContent, encryptContent, EncryptContentOptions } from '@stacks/encryption'; import { getAddressFromDID } from './dids'; import { createFetchFn, FetchFn, GAIA_URL, getGlobalObject, HIRO_MAINNET_URL, InvalidStateError, isLaterVersion, Logger, LoginFailedError, MissingParameterError, nextHour, } from '@stacks/common'; import { extractProfile } from '@stacks/profile'; import { AuthScope, DEFAULT_PROFILE } from './constants'; import { UserData } from './userData'; import { protocolEchoReplyDetection } from './protocolEchoDetection'; /** * * Represents an instance of a signed in user for a particular app. * * A signed in user has access to two major pieces of information * about the user, the user's private key for that app and the location * of the user's gaia storage bucket for the app. * * A user can be signed in either directly through the interactive * sign in process or by directly providing the app private key. * * */ export class UserSession { appConfig: AppConfig; store: SessionDataStore; /** * Creates a UserSession object * * @param options */ constructor(options?: { appConfig?: AppConfig; sessionStore?: SessionDataStore; sessionOptions?: SessionOptions; }) { let runningInBrowser = true; if (typeof window === 'undefined' && typeof self === 'undefined') { // Logger.debug('UserSession: not running in browser') runningInBrowser = false; } if (options && options.appConfig) { this.appConfig = options.appConfig; } else if (runningInBrowser) { this.appConfig = new AppConfig(); } else { throw new MissingParameterError('You need to specify options.appConfig'); } if (options && options.sessionStore) { this.store = options.sessionStore; } else if (runningInBrowser) { if (options) { this.store = new LocalStorageStore(options.sessionOptions); } else { this.store = new LocalStorageStore(); } } else if (options) { this.store = new InstanceDataStore(options.sessionOptions); } else { this.store = new InstanceDataStore(); } } /** * Generates an authentication request that can be sent to the Blockstack * browser for the user to approve sign in. This authentication request can * then be used for sign in by passing it to the [[redirectToSignInWithAuthRequest]] * method. * * *Note*: This method should only be used if you want to use a customized authentication * flow. Typically, you'd use [[redirectToSignIn]] which is the default sign in method. * * @param transitKey A HEX encoded transit private key. * @param redirectURI Location to redirect the user to after sign in approval. * @param manifestURI Location of this app's manifest file. * @param scopes The permissions this app is requesting. The default is `store_write`. * @param appDomain The origin of the app. * @param expiresAt The time at which this request is no longer valid. * @param extraParams Any extra parameters to pass to the authenticator. Use this to * pass options that aren't part of the Blockstack authentication specification, * but might be supported by special authenticators. * * @returns {String} the authentication request token */ makeAuthRequestToken( transitKey?: string, redirectURI?: string, manifestURI?: string, scopes?: (AuthScope | string)[], appDomain?: string, expiresAt: number = nextHour().getTime(), extraParams: any = {} ): string { const appConfig = this.appConfig; if (!appConfig) { throw new InvalidStateError('Missing AppConfig'); } transitKey = transitKey || this.generateAndStoreTransitKey(); redirectURI = redirectURI || appConfig.redirectURI(); manifestURI = manifestURI || appConfig.manifestURI(); scopes = scopes || appConfig.scopes; appDomain = appDomain || appConfig.appDomain; return authMessages.makeAuthRequestToken( transitKey, redirectURI, manifestURI, scopes, appDomain, expiresAt, extraParams ); } /** * Generates a ECDSA keypair to * use as the ephemeral app transit private key * and store in the session. * * @returns {String} the hex encoded private key * */ generateAndStoreTransitKey(): string { const sessionData = this.store.getSessionData(); const transitKey = authMessages.generateTransitKey(); sessionData.transitKey = transitKey; this.store.setSessionData(sessionData); return transitKey; } /** * Retrieve the authentication token from the URL query * @return {String} the authentication token if it exists otherwise `null` */ getAuthResponseToken(): string { const search = getGlobalObject('location', { throwIfUnavailable: true, usageDesc: 'getAuthResponseToken', })?.search; const params = new URLSearchParams(search); return params.get('authResponse') ?? ''; } /** * Check if there is a authentication request that hasn't been handled. * * Also checks for a protocol echo reply (which if detected then the page * will be automatically redirected after this call). * * @return {Boolean} `true` if there is a pending sign in, otherwise `false` */ isSignInPending() { try { const isProtocolEcho = protocolEchoReplyDetection(); if (isProtocolEcho) { Logger.info( 'protocolEchoReply detected from isSignInPending call, the page is about to redirect.' ); return true; } } catch (error) { Logger.error(`Error checking for protocol echo reply isSignInPending: ${error}`); } return !!this.getAuthResponseToken(); } /** * Check if a user is currently signed in. * * @returns {Boolean} `true` if the user is signed in, `false` if not. */ isUserSignedIn() { return !!this.store.getSessionData().userData; } /** * Try to process any pending sign in request by returning a `Promise` that resolves * to the user data object if the sign in succeeds. * * @param {String} authResponseToken - the signed authentication response token * @returns {Promise} that resolves to the user data object if successful and rejects * if handling the sign in request fails or there was no pending sign in request. */ async handlePendingSignIn( authResponseToken: string = this.getAuthResponseToken(), fetchFn: FetchFn = createFetchFn() ): Promise<UserData> { const sessionData = this.store.getSessionData(); if (sessionData.userData) { throw new LoginFailedError('Existing user session found.'); } const transitKey = this.store.getSessionData().transitKey; // let nameLookupURL; let coreNode = this.appConfig && this.appConfig.coreNode; if (!coreNode) { coreNode = HIRO_MAINNET_URL; } const tokenPayload = decodeToken(authResponseToken).payload; if (typeof tokenPayload === 'string') { throw new Error('Unexpected token payload type of string'); } const isValid = await verifyAuthResponse(authResponseToken); if (!isValid) { throw new LoginFailedError('Invalid authentication response.'); } // TODO: real version handling let appPrivateKey: string = tokenPayload.private_key as string; let coreSessionToken: string = tokenPayload.core_token as string; if (isLaterVersion(tokenPayload.version as string, '1.1.0')) { if (transitKey !== undefined && transitKey != null) { if (tokenPayload.private_key !== undefined && tokenPayload.private_key !== null) { try { appPrivateKey = (await authMessages.decryptPrivateKey( transitKey, tokenPayload.private_key as string )) as string; } catch (e) { Logger.warn('Failed decryption of appPrivateKey, will try to use as given'); if (!utils.isValidPrivateKey(tokenPayload.private_key as string)) { throw new LoginFailedError( 'Failed decrypting appPrivateKey. Usually means' + ' that the transit key has changed during login.' ); } } } if (coreSessionToken !== undefined && coreSessionToken !== null) { try { coreSessionToken = (await authMessages.decryptPrivateKey( transitKey, coreSessionToken )) as string; } catch (e) { Logger.info('Failed decryption of coreSessionToken, will try to use as given'); } } } else { throw new LoginFailedError( 'Authenticating with protocol > 1.1.0 requires transit' + ' key, and none found.' ); } } let hubUrl = GAIA_URL; let gaiaAssociationToken: string; if ( isLaterVersion(tokenPayload.version as string, '1.2.0') && tokenPayload.hubUrl !== null && tokenPayload.hubUrl !== undefined ) { hubUrl = tokenPayload.hubUrl as string; } if ( isLaterVersion(tokenPayload.version as string, '1.3.0') && tokenPayload.associationToken !== null && tokenPayload.associationToken !== undefined ) { gaiaAssociationToken = tokenPayload.associationToken as string; } const userData: UserData = { profile: tokenPayload.profile, email: tokenPayload.email as string, decentralizedID: tokenPayload.iss, identityAddress: getAddressFromDID(tokenPayload.iss), appPrivateKey, coreSessionToken, authResponseToken, hubUrl, appPrivateKeyFromWalletSalt: tokenPayload.appPrivateKeyFromWalletSalt as string, coreNode: tokenPayload.blockstackAPIUrl as string, // @ts-expect-error gaiaAssociationToken, }; const profileURL = tokenPayload.profile_url as string; if (!userData.profile && profileURL) { const response = await fetchFn(profileURL); if (!response.ok) { // return blank profile if we fail to fetch userData.profile = Object.assign({}, DEFAULT_PROFILE); } else { const responseText = await response.text(); const wrappedProfile = JSON.parse(responseText); userData.profile = extractProfile(wrappedProfile[0].token); } } else { userData.profile = tokenPayload.profile; } sessionData.userData = userData; this.store.setSessionData(sessionData); return userData; } /** * Retrieves the user data object. The user's profile is stored in the key [[Profile]]. * * @returns {Object} User data object. */ loadUserData() { const userData = this.store.getSessionData().userData; if (!userData) { throw new InvalidStateError('No user data found. Did the user sign in?'); } return userData; } /** * Encrypts the data provided with the app public key. * @param {string | Uint8Array} content the data to encrypt * @param options * @param {string} options.publicKey the hex string of the ECDSA public * key to use for encryption. If not provided, will use user's appPrivateKey. * * @returns {string} Stringified ciphertext object */ encryptContent(content: string | Uint8Array, options?: EncryptContentOptions): Promise<string> { const opts = Object.assign({}, options); if (!opts.privateKey) { opts.privateKey = this.loadUserData().appPrivateKey; } return encryptContent(content, opts); } /** * Decrypts data encrypted with `encryptContent` with the * transit private key. * @param {string | Uint8Array} content - encrypted content. * @param options * @param {string} options.privateKey - The hex string of the ECDSA private * key to use for decryption. If not provided, will use user's appPrivateKey. * @returns {string | Uint8Array} decrypted content. */ decryptContent(content: string, options?: { privateKey?: string }): Promise<Uint8Array | string> { const opts = Object.assign({}, options); if (!opts.privateKey) { opts.privateKey = this.loadUserData().appPrivateKey; } return decryptContent(content, opts); } /** * Sign the user out and optionally redirect to given location. * @param redirectURL * Location to redirect user to after sign out. * Only used in environments with `window` available */ signUserOut( redirectURL?: string // TODO: this is not used? // caller?: UserSession ) { this.store.deleteSessionData(); if (redirectURL) { if (typeof location !== 'undefined' && location.href) { location.href = redirectURL; } // TODO: Invalid left-hand side in assignment expression // // eslint-disable-next-line @typescript-eslint/ban-ts-ignore // // @ts-ignore // getGlobalObject('location', { // throwIfUnavailable: true, // usageDesc: 'signUserOut', // })?.href = redirectURL; } } } // Add method aliases for backwards compatibility export interface UserSession { /** @deprecated {@link makeAuthRequest} was renamed to {@link makeAuthRequestToken} */ makeAuthRequest( ...args: Parameters<typeof UserSession.prototype.makeAuthRequestToken> ): ReturnType<typeof UserSession.prototype.makeAuthRequestToken>; } // eslint-disable-next-line @typescript-eslint/unbound-method UserSession.prototype.makeAuthRequest = UserSession.prototype.makeAuthRequestToken;