import URLSearchParams from "core-js-pure/features/url-search-params" ; import { JSONObject , AuthResponse , SSOLoginOptions , Session , Identity , FullOAuthAuthorizationURLOptions , _PresignUploadRequest , _PresignUploadResponse , _PresignUploadFormResponse , _OIDCConfiguration , _OIDCTokenResponse , _OIDCTokenRequest , OAuthError , ChallengeResponse , } from "./types" ; import { decodeError , SkygearError } from "./error" ; import { decodeAuthResponse , decodeSession , decodeIdentity , _decodeAuthResponseFromOIDCUserinfo , } from "./encoding" ; import { _encodeBase64FromString } from "./base64" ; const refreshTokenWindow = 0.7 ; internal export function _removeTrailingSlash ( s ) { return s . replace ( /\/+$/g , "" ) ; } internal param appEndpoint param gearSubdomain export function _gearEndpoint ( appEndpoint , gearSubdomain ) { const gearEndpoint = appEndpoint . replace ( /^(http:\/\/|https:\/\/|\/\/)(.*)$/g , `$1${ gearSubdomain }.$2` ) ; if ( gearEndpoint === appEndpoint ) { throw new Error ( "invalid app endpoint" ) ; } return gearEndpoint ; } function extractSingleKeyValue ( o , errorMessage ) { const keys = Object . keys ( o ) ; if ( keys . length !== 1 ) { throw new Error ( errorMessage ) ; } return [ keys [ 0 ] , o [ keys [ 0 ] ] ] ; } public export abstract class BaseAPIClient { apiKey : string ; appEndpoint : string ; authEndpoint : string ; assetEndpoint : string ; internal _accessToken : string | null ; internal _shouldRefreshTokenAt : number ; fetchFunction ? : typeof fetch ; requestClass ? : typeof Request ; refreshTokenFunction ? : ( ) => Promise < boolean > ; userAgent ? : string ; getExtraSessionInfo ? : ( ) => Promise < JSONObject | null > ; private config ? : _OIDCConfiguration ; constructor ( ) { this . apiKey = "" ; this . appEndpoint = "" ; this . authEndpoint = "" ; this . assetEndpoint = "" ; this . _accessToken = null ; this . _shouldRefreshTokenAt = 0 ; } setShouldNotRefreshToken ( ) { this . _shouldRefreshTokenAt = 0 ; } setShouldRefreshTokenNow ( ) { this . _shouldRefreshTokenAt = new Date ( ) . getTime ( ) ; } setAccessTokenAndExpiresIn ( accessToken , expires_in ? ) { this . _accessToken = accessToken ; if ( expires_in ) { this . _shouldRefreshTokenAt = new Date ( ) . getTime ( ) + expires_in * 1000 * refreshTokenWindow ; } else { this . _shouldRefreshTokenAt = 0 ; } } async setEndpoint ( appEndpoint , authEndpoint ? , assetEndpoint ? ) { if ( ! appEndpoint ) { throw new Error ( "appEndpoint is required" ) ; } this . appEndpoint = _removeTrailingSlash ( appEndpoint ) ; this . authEndpoint = authEndpoint ? authEndpoint : _gearEndpoint ( this . appEndpoint , "accounts" ) ; this . assetEndpoint = assetEndpoint ? assetEndpoint : _gearEndpoint ( this . appEndpoint , "assets" ) ; } protected async prepareHeaders ( ) { const headers = { "x-skygear-api-key" : this . apiKey } ; if ( this . _accessToken ) { headers [ "authorization" ] = `bearer ${ this . _accessToken }` ; } if ( this . userAgent !== undefined ) { headers [ "user-agent" ] = this . userAgent ; } if ( this . getExtraSessionInfo ) { const extraSessionInfo = await this . getExtraSessionInfo ( ) ; if ( extraSessionInfo ) { headers [ "x-skygear-extra-info" ] = _encodeBase64FromString ( JSON . stringify ( extraSessionInfo ) ) ; } } return headers ; } internal async _fetch ( url , init ? ) { if ( ! this . fetchFunction ) { throw new Error ( "missing fetchFunction in api client" ) ; } if ( ! this . requestClass ) { throw new Error ( "missing requestClass in api client" ) ; } const request = new this . requestClass ( url , init ) ; return this . fetchFunction ( request ) ; } async fetch ( endpoint , input , init ? , options = { } ) { if ( this . fetchFunction == null ) { throw new Error ( "missing fetchFunction in api client" ) ; } if ( this . requestClass == null ) { throw new Error ( "missing requestClass in api client" ) ; } const { autoRefreshToken = ! ! this . refreshTokenFunction } = options ; if ( typeof input !== "string" ) { throw new Error ( "only string path is allowed for fetch input" ) ; } const shouldRefreshToken = this . _accessToken && this . _shouldRefreshTokenAt && this . _shouldRefreshTokenAt < new Date ( ) . getTime ( ) ; if ( shouldRefreshToken && autoRefreshToken ) { if ( ! this . refreshTokenFunction ) { throw new Error ( "missing refreshTokenFunction in api client" ) ; } await this . refreshTokenFunction ( ) ; } const url = endpoint + "/" + input . replace ( /^\// , "" ) ; const request = new this . requestClass ( url , init ) ; const headers = await this . prepareHeaders ( ) ; for ( const key of Object . keys ( headers ) ) { request . headers . set ( key , headers [ key ] ) ; } return this . fetchFunction ( request ) ; } protected async request ( method , endpoint , path , options = { } ) { const { json , query , autoRefreshToken } = options ; let p = path ; if ( query != null && query . length > 0 ) { const params = new URLSearchParams ( ) ; for ( let i = 0 ; i < query . length ; ++ i ) { params . append ( query [ i ] [ 0 ] , query [ i ] [ 1 ] ) ; } p += "?" + params . toString ( ) ; } const headers = { } ; if ( json != null ) { headers [ "content-type" ] = "application/json" ; } const response = await this . fetch ( endpoint , p , { method , headers , mode : "cors" , credentials : "include" , body : json && JSON . stringify ( json ) } , { autoRefreshToken } ) ; let jsonBody ; try { jsonBody = await response . json ( ) ; } catch ( err ) { if ( response . status < 200 || response . status >= 300 ) { throw new SkygearError ( "unexpected status code" , "InternalError" , "UnexpectedError" , { status_code : response . status } ) ; } else { throw new SkygearError ( "failed to decode response JSON" , "InternalError" , "UnexpectedError" ) ; } } if ( jsonBody [ "result" ] ) { return jsonBody [ "result" ] ; } else if ( jsonBody [ "error" ] ) { throw decodeError ( jsonBody [ "error" ] ) ; } throw decodeError ( ) ; } protected async post ( endpoint , path , options ? ) { return this . request ( "POST" , endpoint , path , options ) ; } protected async get ( endpoint , path , options ? ) { return this . request ( "GET" , endpoint , path , options ) ; } protected async del ( endpoint , path , options ) { return this . request ( "DELETE" , endpoint , path , options ) ; } protected async postAuth ( path , options ? ) { return this . request ( "POST" , this . authEndpoint , path , options ) ; } protected async getAuth ( path , options ? ) { return this . request ( "GET" , this . authEndpoint , path , options ) ; } protected async delAuth ( path , options ) { return this . request ( "DELETE" , this . authEndpoint , path , options ) ; } protected async postAsset ( path , options ? ) { return this . request ( "POST" , this . assetEndpoint , path , options ) ; } protected async getAsset ( path , options ? ) { return this . request ( "GET" , this . assetEndpoint , path , options ) ; } protected async delAsset ( path , options ) { return this . request ( "DELETE" , this . assetEndpoint , path , options ) ; } protected async postAndReturnAuthResponse ( path , options ? ) { const response = await this . postAuth ( path , options ) ; return decodeAuthResponse ( response ) ; } async signup ( loginIDs , password , options ? ) { const ids = [ ] ; if ( Array . isArray ( loginIDs ) ) { for ( const obj of loginIDs ) { for ( const key of Object . keys ( obj ) ) { const value = obj [ key ] ; ids . push ( { key , value } ) ; } } } else { for ( const key of Object . keys ( loginIDs ) ) { const value = loginIDs [ key ] ; ids . push ( { key , value } ) ; } } const payload = { password , login_ids : ids , metadata : options && options . metadata } ; return this . postAndReturnAuthResponse ( "/_auth/signup" , { json : payload } ) ; } async login ( loginID , password , options ? ) { const payload = { password , login_id : loginID , login_id_key : options && options . loginIDKey } ; return this . postAndReturnAuthResponse ( "/_auth/login" , { json : payload } ) ; } async logout ( ) { await this . postAuth ( "/_auth/logout" , { json : { } } ) ; } async me ( ) { return this . postAndReturnAuthResponse ( "/_auth/me" , { json : { } } ) ; } async changePassword ( newPassword , oldPassword ) { const payload = { password : newPassword , old_password : oldPassword } ; return this . postAndReturnAuthResponse ( "/_auth/change_password" , { json : payload } ) ; } async updateMetadata ( metadata ) { const payload = { metadata } ; return this . postAndReturnAuthResponse ( "/_auth/update_metadata" , { json : payload } ) ; } async requestForgotPasswordEmail ( email ) { const payload = { email } ; await this . postAuth ( "/_auth/forgot_password" , { json : payload } ) ; } async resetPassword ( form ) { const payload = { user_id : form . userID , code : form . code , expire_at : form . expireAt , new_password : form . newPassword } ; await this . postAuth ( "/_auth/forgot_password/reset_password" , { json : payload } ) ; } async requestEmailVerification ( email ) { const payload = { login_id_type : "email" , login_id : email } ; await this . postAuth ( "/_auth/verify_request" , { json : payload } ) ; } async requestPhoneVerification ( phone ) { const payload = { login_id_type : "phone" , login_id : phone } ; await this . postAuth ( "/_auth/verify_request" , { json : payload } ) ; } async verifyWithCode ( code ) { const payload = { code } ; await this . postAuth ( "/_auth/verify_code" , { json : payload } ) ; } async loginWithCustomToken ( token , options ? ) { const payload = { token , on_user_duplicate : options && options . onUserDuplicate } ; return this . postAndReturnAuthResponse ( "/_auth/sso/custom_token/login" , { json : payload } ) ; } async oauthAuthorizationURL ( options ) { const { providerID , uxMode , onUserDuplicate , codeChallenge , action , } = options ; const encoded = encodeURIComponent ( providerID ) ; let path = "" ; switch ( action ) { case "login" : path = `/_auth/sso/${ encoded }/login_auth_url` ; break ; case "link" : path = `/_auth/sso/${ encoded }/link_auth_url` ; break ; default : throw new Error ( "unreachable" ) ; } const callbackURL = ( "callbackURL" in options && options . callbackURL ) || ( typeof window !== "undefined" && window . location . href ) ; if ( ! callbackURL ) { throw new Error ( "callbackURL is required" ) ; } const payload = { callback_url : callbackURL , ux_mode : uxMode , on_user_duplicate : onUserDuplicate , code_challenge : codeChallenge } ; return this . postAuth ( path , { json : payload } ) ; } async oauthHandler ( options ) { const { providerID , code , scope , state } = options ; const encoded = encodeURIComponent ( providerID ) ; const path = `/_auth/sso/${ encoded }/auth_handler` ; return this . getAuth ( path , { query : [ [ "code" , code ] , [ "scope" , scope ] , [ "state" , state ] ] } ) ; } async getOAuthResult ( options ) { const { authorizationCode , codeVerifier } = options ; const payload = { authorization_code : authorizationCode , code_verifier : codeVerifier } ; return this . postAndReturnAuthResponse ( "/_auth/sso/auth_result" , { json : payload } ) ; } async deleteOAuthProvider ( providerID ) { const encoded = encodeURIComponent ( providerID ) ; await this . postAuth ( `/_auth/sso/${ encoded }/unlink` , { json : { } } ) ; } async loginOAuthProviderWithAccessToken ( providerID , accessToken , options ? ) { const encoded = encodeURIComponent ( providerID ) ; const payload = { access_token : accessToken , on_user_duplicate : options && options . onUserDuplicate } ; return this . postAndReturnAuthResponse ( `/_auth/sso/${ encoded }/login` , { json : payload } ) ; } async linkOAuthProviderWithAccessToken ( providerID , accessToken ) { const encoded = encodeURIComponent ( providerID ) ; const payload = { access_token : accessToken } ; return this . postAndReturnAuthResponse ( `/_auth/sso/${ encoded }/link` , { json : payload } ) ; } async listSessions ( ) { const response = await this . postAuth ( "/_auth/session/list" , { json : { } } ) ; return ( response . sessions as any [ ] ) . map ( decodeSession ) ; } async getSession ( id ) { const payload = { session_id : id } ; const response = await this . postAuth ( "/_auth/session/get" , { json : payload } ) ; return decodeSession ( response . session ) ; } async revokeSession ( id ) { const payload = { session_id : id } ; return this . postAuth ( "/_auth/session/revoke" , { json : payload } ) ; } async revokeOtherSessions ( ) { return this . postAuth ( "/_auth/session/revoke_all" , { json : { } } ) ; } async listIdentities ( ) { const response = await this . postAuth ( "/_auth/identity/list" , { json : { } } ) ; return ( response . identities as any [ ] ) . map ( decodeIdentity ) ; } async addLoginID ( ... loginIDs ) { const mappedLoginIDs = loginIDs . map ( loginID => { const [ key , value ] = extractSingleKeyValue ( loginID , "must provide exactly one login ID" ) ; return { key , value } ; } ) ; return this . postAuth ( "/_auth/login_id/add" , { json : { login_ids : mappedLoginIDs } } ) ; } async removeLoginID ( loginID ) { const [ key , value ] = extractSingleKeyValue ( loginID , "must provide exactly one login ID" ) ; return this . postAuth ( "/_auth/login_id/remove" , { json : { key , value } } ) ; } async updateLoginID ( oldLoginID , newLoginID ) { const [ oldKey , oldValue ] = extractSingleKeyValue ( oldLoginID , "must provide exactly one old login ID" ) ; const [ newKey , newValue ] = extractSingleKeyValue ( newLoginID , "must provide exactly one new login ID" ) ; return this . postAndReturnAuthResponse ( "/_auth/login_id/update" , { json : { old_login_id : { key : oldKey , value : oldValue } , new_login_id : { key : newKey , value : newValue } } } ) ; } internal async _presignUpload ( req ) { return this . postAsset ( "/_asset/presign_upload" , { json : req } ) ; } internal async _presignUploadForm ( ) { return this . postAsset ( "/_asset/presign_upload_form" , { json : { } } ) ; } internal async _fetchOIDCRequest ( url , init ? ) { const resp = await this . _fetch ( url , init ) ; if ( resp . status === 200 ) { return resp ; } let errJSON ; try { errJSON = await resp . json ( ) ; } catch { throw new SkygearError ( "failed to decode response JSON" , "InternalError" , "UnexpectedError" , { status_code : resp . status } ) ; } const oauthError = { error : errJSON [ "error" ] , error_description : errJSON [ "error_description" ] } ; throw oauthError ; } internal async _fetchOIDCJSON ( url , init ? ) { const resp = await this . _fetchOIDCRequest ( url , init ) ; let jsonBody ; try { jsonBody = await resp . json ( ) ; } catch { throw new SkygearError ( "failed to decode response JSON" , "InternalError" , "UnexpectedError" ) ; } return jsonBody ; } internal async _fetchOIDCConfiguration ( ) { if ( ! this . config ) { this . config = ( await this . _fetchOIDCJSON ( `${ this . authEndpoint }/.well-known/openid-configuration` ) ) as _OIDCConfiguration ; } return this . config ; } internal async _oidcTokenRequest ( req ) { const config = await this . _fetchOIDCConfiguration ( ) ; const query = new URLSearchParams ( ) ; query . append ( "grant_type" , req . grant_type ) ; query . append ( "client_id" , req . client_id ) ; if ( req . code ) { query . append ( "code" , req . code ) ; } if ( req . redirect_uri ) { query . append ( "redirect_uri" , req . redirect_uri ) ; } if ( req . code_verifier ) { query . append ( "code_verifier" , req . code_verifier ) ; } if ( req . refresh_token ) { query . append ( "refresh_token" , req . refresh_token ) ; } if ( req . jwt ) { query . append ( "jwt" , req . jwt ) ; } return this . _fetchOIDCJSON ( config . token_endpoint , { method : "POST" , headers : { "content-type" : "application/x-www-form-urlencoded" } , body : query . toString ( ) } ) ; } internal async _oidcUserInfoRequest ( accessToken ? ) { const headers = { } ; if ( accessToken ) { headers [ "authorization" ] = `bearer ${ accessToken }` ; } const config = await this . _fetchOIDCConfiguration ( ) ; const userinfo = await this . _fetchOIDCJSON ( config . userinfo_endpoint , { method : "GET" , headers : headers , mode : "cors" , credentials : "include" } ) ; const result = _decodeAuthResponseFromOIDCUserinfo ( userinfo ) ; return result ; } internal async _oidcRevocationRequest ( refreshToken ) { const config = await this . _fetchOIDCConfiguration ( ) ; const query = new URLSearchParams ( { token : refreshToken } ) ; await this . _fetchOIDCRequest ( config . revocation_endpoint , { method : "POST" , headers : { "content-type" : "application/x-www-form-urlencoded" } , body : query . toString ( ) } ) ; } async oauthChallenge ( purpose ) { return this . postAuth ( "/oauth2/challenge" , { json : { purpose } } ) ; } }