import { AxiosError } from 'axios' import type { AxiosAuthRefreshRequestConfig } from 'axios-auth-refresh' import { AuthenticationBadRequest, AuthenticationFailed, AuthenticationServerError, AuthenticationUnconfirmedEmail, AuthRecoverRequest, AuthTokenRequest, AuthTokenResponse, Base64String, IdentityCreateRequest, IdentityCreationBadRequest, IdentityCreationConflict, IdentityCreationFailed, IdentityResendConfirmEmailRequest, IdentityResponse, IdentityUpdateRequest, M2MTokenRequest, QRCodeRequest, QRCodeResponse, Tokens, Uuid, WhoAmIResponse, } from '../models' import { APIService } from './api' export interface GuardRequestConfig extends AxiosAuthRefreshRequestConfig { useRefreshToken: boolean } export class GuardService { private identityCache: Record private whoAmICache: Record constructor(private api: APIService, private baseURL: string) { this.api.setAuthRefreshFn(this.authRefresh.bind(this)) // This is the default behavior for User JWT tokens. If you want other kind of refresh you shall overwrite this call this.identityCache = {} this.whoAmICache = {} } /** * Will replace access and refresh tokens with `tokens` * * Note: * ```typescript * setTokens({accessToken: undefined, refreshToken: 'aTokenValue'}) // will erase accessToken and set refreshToken with 'aTokenValue' * setTokens({refreshToken: 'aTokenValue'}) // will keep actual value of accessToken and set refreshToken with 'aTokenValue' * * ``` * @param tokens */ public setTokens(tokens: Tokens) { this.api.setTokens({ ...this.api.getTokens(), ...tokens }) } /** * Allow to retrieve a M2M token for a service * * @param req The credentials required to get an access token * @returns AuthTokenResponse */ public async m2mToken(req: M2MTokenRequest): Promise { let resp: AuthTokenResponse | undefined try { let config: AxiosAuthRefreshRequestConfig = { skipAuthRefresh: true, } resp = await this.api.post(`${this.baseURL}/v1/m2m/token`, req, config) this.api.setTokens({ accessToken: resp.accessToken, }) } catch (e) { console.error('Error while posting m2m token:', e) if ((e as any).isAxiosError) { const code = (e as AxiosError).response?.status switch (code) { case 400: throw new AuthenticationBadRequest() case 500: throw new AuthenticationServerError() case 401: default: throw new AuthenticationFailed() } } throw new AuthenticationFailed() } return resp } /** * Allow to retrieve an access token and a refresh token in order * to do authenticated request afterward * * @param req The credentials required to get an access token * @returns AuthTokenResponse */ public async authToken(req: AuthTokenRequest): Promise { let resp: AuthTokenResponse try { let config: AxiosAuthRefreshRequestConfig = { skipAuthRefresh: true, } resp = await this.api.post(`${this.baseURL}/v1/auth/token`, req, config) this.api.setTokens({ accessToken: resp.accessToken, refreshToken: resp.refreshToken, }) } catch (e) { console.error('Error while posting auth token:', e) if ((e as any).isAxiosError) { const code = (e as AxiosError).response?.status switch (code) { case 400: throw new AuthenticationBadRequest() case 424: throw new AuthenticationUnconfirmedEmail() case 500: throw new AuthenticationServerError() case 401: default: throw new AuthenticationFailed() } } throw new AuthenticationFailed() } return resp } /** * Get new access and refresh token * * @returns AuthTokenResponse */ public async authRefresh(refreshToken?: string): Promise { let config: GuardRequestConfig = { skipAuthRefresh: true, useRefreshToken: true, } return this.api.put(`${this.baseURL}/v1/auth/token`, null, config) } /** * Call guard to overwrite existing refresh token cookie * * @returns void */ public async authLogout(): Promise { return this.api.get(`${this.baseURL}/v1/auth/logout`) } /** * Call guard to attempt account recovery * * @param req The email address / practice of the account to recover * @returns void */ public async authRecover(req: AuthRecoverRequest): Promise { return this.api.post(`${this.baseURL}/v1/auth/recover`, req) } /** * Allow to create a new identity. The identity will then need to be confirmed * via an email link * * @param req the information about the new identity to create * @returns IdentityResponse */ public async identityCreate(req: IdentityCreateRequest): Promise { let resp: IdentityResponse try { resp = await this.api.post(`${this.baseURL}/v1/identities`, req) this.api.setTokens({ refreshToken: resp.refreshToken, }) } catch (e) { if ((e as any).isAxiosError) { const code = (e as AxiosError).response?.status switch (code) { case 400: throw new IdentityCreationBadRequest() case 409: throw new IdentityCreationConflict() case 500: default: throw new IdentityCreationFailed() } } throw new IdentityCreationFailed() } return resp } /** * Retrieve an identity. Will return public fields only when requested * without authentication * * @param identityID Unique id of the identity to retrieve * @param skipCache (default: false) will skip identity cache (not even update it) * @returns IdentityResponse */ public async identityGet(identityID: Uuid, skipCache = false): Promise { const tokens = this.api.getTokens() const cacheKey = (tokens.accessToken ?? '') + (tokens.refreshToken ?? '') + identityID if (skipCache || !tokens.accessToken || !this.identityCache[cacheKey]) { const identity = await this.api.get(`${this.baseURL}/v1/identities/${identityID}`) if (skipCache) return identity this.identityCache[cacheKey] = identity } return this.identityCache[cacheKey] } /** * Get information about the current authenticated user * * @param refreshCache if true it will refresh the whoAmI cache (default: false) * @returns WhoAmIResponse */ public async whoAmI(refreshCache: boolean = false): Promise { const cacheKey = this.api.getTokens().accessToken ?? '' if (!this.whoAmICache[cacheKey] || refreshCache) { this.whoAmICache[cacheKey] = await this.api.get(`${this.baseURL}/v1/auth/whoami`) } return this.whoAmICache[cacheKey] } /** * Update an existing identity * * @param identityID unique id of identity to update * @param req update request * @returns IdentityResponse */ public async identityUpdate(identityID: Uuid, req: IdentityUpdateRequest): Promise { return this.api.put(`${this.baseURL}/v1/identities/${identityID}`, req) } /** * Return base64 data representing a QR code that the * current identity need in order to use MFA * * @param identityID unique id of the identity * @param password the identity password (already hashed and in base64) * @returns QRCodeResponse */ public async identityMFAQRCode(identityID: Uuid, password: Base64String): Promise { const req: QRCodeRequest = { password } return this.api.post(`${this.baseURL}/v1/identities/${identityID}/mfa`, req, { headers: { Accept: 'application/json' }, }) } /** * Attempt to resend the email confirmation email * * @param req IdentityResendConfirmEmailRequest * @return void */ public async identitySendConfirmEmail(req: IdentityResendConfirmEmailRequest): Promise { return this.api.post(`${this.baseURL}/v1/identity/confirm`, req) } /** * Notifies the guard and confirms the phishing attempt * @param attemptUuid the guard logged attempt id * @returns void */ public async authConfirmPhishingAttempts(attemptUuid: Uuid): Promise { return this.api.put(`${this.baseURL}/v1/auth/attempts/${attemptUuid}`, {}) } /** * Get an identity using a customer email (format: customer+[b64Hash]@orohealth.me) * * @param email the customer email * @returns IdentityResponse */ public async identityGetByCustomerEmail(email: string): Promise { return this.identityGetByHash(email.substring(email.indexOf('+') + 1, email.indexOf('@'))) } /** * Get an identity using a base64 hash * * @param b64Hash base64 hash of the identity * @returns IdentityResponse */ public async identityGetByHash(b64Hash: string): Promise { //TODO: Right now this maps directly to the IdentityGet call. //Eventually, with the mapping table method, this would lead to another //call (ie: /v1/mapping/[b64Hash]) which would return a blob to decrypt //which would contain the real identityID to call IdentityGet with. //The hash comes in base64 format but it isn't URL safe soe we have to convert //to base64URL (see https://en.wikipedia.org/wiki/Base64#The_URL_applications) return this.identityGet(b64Hash.replace(/\+/g, '-').replace(/\//g, '_')) } }