import crypto from 'crypto' ; import { OpenIdConfig , ClientAccessToken , ClientTokenResponseBody , SubjectAccessToken , SubjectTokenResponseBody , TokenIntrospection , UserInfo , } from '../types' ; import { saveToLocalStorage , getFromLocalStorage , clearLocalStorage } from '.' ; const OPENID_CONFIG_PATH = '.well-known/openid-configuration.json' ; const TOKEN_EXPIRATION_SAFETY_BUFFER = 60000 ; interface Oauth2Options { webAuthFlowOptions : Partial < chrome . identity . WebAuthFlowOptions > ; clientAuthParams : string [ ] [ ] ; } type Oauth2OptionsArgument = Partial < Oauth2Options > ; const DEFAULT_OPTIONS = { webAuthFlowOptions : { interactive : true , } , clientAuthParams : [ ] , } ; export class Oauth2 { clientId : string ; domain : string ; options : Oauth2Options ; config ? : OpenIdConfig ; public constructor ( clientId , domain , options ) { this . clientId = clientId ; this . domain = domain ; this . options = { ... DEFAULT_OPTIONS , ... options } ; } /**
     * Request a new oauth2 access token from the clientId and clientScope specified in the constructor
     */ public authenticate ( params = [ ] ) { const { clientId } = this ; const { clientAuthParams } = this . options ; return this . getValidClientAccessTokenAndSave ( clientId , [ ... clientAuthParams , ... params , ] ) ; } /**
     * Fetch the client ID of an OAuth application using
     * [Dynamic Registration Endpoint](https://identity.docs.shopify.io/oauth/dynamic-registration-endpoint/).
     */ public async fetchClientId ( name ) { const config = await this . getConfig ( ) ; const baseUrl = config . issuer ; const token = await this . getClientAccessTokenFromStorage ( this . clientId ) ; const url = new URL ( `${ baseUrl }/oauth/client` ) ; url . search = new URLSearchParams ( [ [ 'name[]' , name ] ] ) . toString ( ) ; const response = await fetch ( url . href , { method : 'GET' , headers : { Authorization : `Bearer ${ token ! . accessToken }` , } , } ) ; const json = await response . json ( ) ; if ( ! response . ok ) { throw Error ( json . error_description ) ; } return json . applications [ name ] . client_id ; } param subjectId param scope public getSubjectAccessToken ( destination , subjectId , params ) { return this . getValidSubjectAccessTokenAndSave ( destination , subjectId , params ) ; } public async hasValidClientToken ( ) { const token = await this . getClientAccessTokenFromStorage ( this . clientId ) ; if ( typeof token !== 'undefined' ) { if ( this . isAccessTokenInvalid ( token ) ) { await this . authenticate ( ) ; } return true ; } return false ; } public async logoutUser ( ) { const token = await this . getClientAccessTokenFromStorage ( this . clientId ) ; const idToken = await getFromLocalStorage ( 'idToken' ) ; const config = await this . getConfig ( ) ; const baseUrl = config . issuer ; const url = new URL ( `${ baseUrl }/api/v1/logout` ) ; if ( ! idToken ) { throw Error ( 'Logout id token not found' ) ; } url . search = new URLSearchParams ( [ [ 'id_token_hint' , idToken ] ] ) . toString ( ) ; const response = await fetch ( url . href , { method : 'delete' , headers : { Authorization : `Bearer ${ token ! . accessToken }` } , } ) ; const json = await response . json ( ) ; if ( ! response . ok ) { throw Error ( json . error ) ; } this . deleteAccessToken ( ) ; } public async getUserInfo ( ) { const config = await this . getConfig ( ) ; const token = await this . getClientAccessTokenFromStorage ( this . clientId ) ; const url = new URL ( config . userinfo_endpoint ) ; const response = await fetch ( url . href , { headers : { Authorization : `Bearer ${ token ! . accessToken }` } , } ) ; if ( response . ok ) return response . json ( ) ; throw Error ( response . statusText ) ; } param { accessToken } public async introspectToken ( { accessToken , } ) { const config = await this . getConfig ( ) ; const url = new URL ( config . introspection_endpoint ) ; url . search = new URLSearchParams ( [ [ 'token' , accessToken ] ] ) . toString ( ) ; const response = await fetch ( url . href , { method : 'POST' , headers : { 'Content-Type' : 'application/x-www-form-urlencoded' , Authorization : `Bearer ${ accessToken }` , } , } ) ; if ( response . ok ) return response . json ( ) ; throw Error ( response . statusText ) ; } param id param scope param cb private async getValidClientAccessTokenAndSave ( id , params ) { let token = await this . getClientAccessTokenFromStorage ( id ) ; if ( typeof token === 'undefined' ) { token = await this . getNewClientAccessToken ( id , params ) ; } else if ( await this . isAccessTokenInvalid ( token ) ) { if ( token . refreshToken ) { try { token = await this . refreshClientAccessToken ( id , token . refreshToken ) ; } catch ( error ) { token = await this . getNewClientAccessToken ( id , params ) ; } } else { token = await this . getNewClientAccessToken ( id , params ) ; } } await saveToLocalStorage ( id , JSON . stringify ( token ) ) ; return token ; } param destination param scope param cb private async getValidSubjectAccessTokenAndSave ( destination , subjectId , params ) { let token = await this . getSubjectAccessTokenFromStorage ( destination ) ; if ( typeof token === 'undefined' ) { token = await this . exchangeToken ( destination , subjectId , params ) ; } else if ( await this . isAccessTokenInvalid ( token ) ) { token = await this . exchangeToken ( destination , subjectId , params ) ; } await saveToLocalStorage ( destination , JSON . stringify ( token ) ) ; return token ; } private deleteAccessToken ( ) { clearLocalStorage ( ) ; } param id param refreshToken private async refreshClientAccessToken ( id , refreshToken ) { const config = await this . getConfig ( ) ; const url = new URL ( config . token_endpoint ) ; url . search = new URLSearchParams ( [ [ 'grant_type' , 'refresh_token' ] , [ 'refresh_token' , refreshToken ] , [ 'client_id' , id ] , ] ) . toString ( ) ; const response = await fetch ( url . href , { method : 'POST' , headers : { 'Content-Type' : 'application/x-www-form-urlencoded' , } , } ) ; if ( response . ok ) return this . normalizeClientTokenResponse ( response ) ; throw Error ( response . statusText ) ; } param id param scope private async getNewClientAccessToken ( id , params ) { const { secret : codeVerifier , hashed : codeChallenge , } = this . generateRandomChallengePair ( ) ; const config = await this . getConfig ( ) ; const url = new URL ( config . authorization_endpoint ) ; url . search = new URLSearchParams ( [ [ 'redirect_uri' , this . getRedirectURL ( ) ] , [ 'client_id' , id ] , [ 'code_challenge' , codeChallenge ] , [ 'code_challenge_method' , 'S256' ] , [ 'response_type' , 'code' ] , ... params , ] ) . toString ( ) ; const resultUrl = await this . launchWebAuthFlow ( url . href ) ; const code = this . extractCode ( resultUrl ) ; return this . exchangeCodeForToken ( code , codeVerifier ) ; } param param0 private isAccessTokenInvalid ( token ) { const { accessTokenDate , expiresIn } = token ; return ( new Date ( ) . getTime ( ) >= accessTokenDate + expiresIn - TOKEN_EXPIRATION_SAFETY_BUFFER ) ; } /**
     * Fetches an OpenId configuration from a given domain, which contains details
     * used to make an oauth2 request from a given service, such as the authorization
     * url or token url. Only will make a network request for the config if it
     * hasn't already been done.
     */ private async getConfig ( ) { const { domain , config } = this ; const url = `https://${ domain }/${ OPENID_CONFIG_PATH }` ; if ( typeof config === 'undefined' ) { const result = await fetch ( url ) ; if ( ! result . ok ) throw Error ( result . statusText ) ; return ( this . config = await result . json ( ) ) ; } else { return config ; } } param id private async getClientAccessTokenFromStorage ( id ) { const data = await getFromLocalStorage ( id ) ; if ( typeof data === 'undefined' ) { return data ; } return JSON . parse ( data ) ; } param id private async getSubjectAccessTokenFromStorage ( id ) { const data = await getFromLocalStorage ( id ) ; if ( typeof data === 'undefined' ) { return data ; } return JSON . parse ( data ) ; } param accessToken param audienceId private async exchangeToken ( destination , subjectId , params ) { const config = await this . getConfig ( ) ; const { accessToken } = await this . authenticate ( ) ; const url = new URL ( config . token_endpoint ) ; url . search = new URLSearchParams ( [ [ 'grant_type' , 'urn:ietf:params:oauth:grant-type:token-exchange' ] , [ 'client_id' , this . clientId ] , [ 'audience' , subjectId ] , [ 'subject_token' , accessToken ] , [ 'subject_token_type' , 'urn:ietf:params:oauth:token-type:access_token' ] , [ 'destination' , destination ] , ... params , ] ) . toString ( ) ; const response = await fetch ( url . href , { method : 'POST' , headers : { 'Content-Type' : 'application/x-www-form-urlencoded' , } , } ) ; if ( response . ok ) return this . normalizeTokenExchangeResponse ( response ) ; throw Error ( response . statusText ) ; } param response private async normalizeClientTokenResponse ( response ) { const responseDateHeader = response . headers . get ( 'Date' ) ; const accessTokenDate = responseDateHeader ? new Date ( responseDateHeader ) . valueOf ( ) : new Date ( ) . valueOf ( ) ; const body = await response . json ( ) ; if ( body . id_token ) { saveToLocalStorage ( 'idToken' , body . id_token ) ; } return { accessToken : body . access_token , accessTokenDate , expiresIn : body . expires_in * 1000 , scope : body . scope , tokenType : body . token_type , issuedTokenType : body . issued_token_type , refreshToken : body . refresh_token , idToken : body . id_token , } ; } param response private async normalizeTokenExchangeResponse ( response ) { const responseDateHeader = response . headers . get ( 'Date' ) ; const accessTokenDate = responseDateHeader ? new Date ( responseDateHeader ) . valueOf ( ) : new Date ( ) . valueOf ( ) ; const body = await response . json ( ) ; return { accessToken : body . access_token , accessTokenDate , expiresIn : body . expires_in * 1000 , scope : body . scope , tokenType : body . token_type , issuedTokenType : body . issued_token_type , } ; } param url private launchWebAuthFlow ( url ) { const { webAuthFlowOptions } = this . options ; return new Promise ( ( resolve , reject ) => { chrome . identity . launchWebAuthFlow ( { ... webAuthFlowOptions , url } , callbackURL => { if ( chrome . runtime . lastError ) { return reject ( new Error ( chrome . runtime . lastError . message ) ) ; } return resolve ( callbackURL ) ; } ) ; } ) ; } param code param verifier private async exchangeCodeForToken ( code , verifier ) { const { clientId } = this ; const config = await this . getConfig ( ) ; const url = new URL ( config . token_endpoint ) ; url . search = new URLSearchParams ( [ [ 'redirect_uri' , this . getRedirectURL ( ) ] , [ 'grant_type' , 'authorization_code' ] , [ 'code_verifier' , verifier ] , [ 'client_id' , clientId ] , [ 'code' , code ] , ] ) . toString ( ) ; const response = await fetch ( url . href , { method : 'POST' } ) ; if ( response . ok ) return this . normalizeClientTokenResponse ( response ) ; throw Error ( response . statusText ) ; } param redirectURL private extractCode ( redirectURL ) { const { searchParams } = new URL ( redirectURL ) ; const error = searchParams . get ( 'error' ) ; const code = searchParams . get ( 'code' ) ; if ( error ) { throw new Error ( searchParams . get ( 'error_description' ) || error ) ; } if ( ! code ) { throw new Error ( 'RedirectURI code does not exist' ) ; } return code ; } private generateRandomChallengePair ( ) { const secret = this . base64URLEncode ( crypto . randomBytes ( 32 ) ) ; const hashed = this . base64URLEncode ( this . sha256 ( secret ) ) ; return { secret , hashed } ; } private getRedirectURL ( ) { return chrome . identity . getRedirectURL ( 'auth0' ) ; } private base64URLEncode ( str ) { return str . toString ( 'base64' ) . replace ( /\+/g , '-' ) . replace ( /\//g , '_' ) . replace ( /[=]/g , '' ) ; } private sha256 ( buffer ) { return crypto . createHash ( 'sha256' ) . update ( buffer ) . digest ( ) ; } }