UNPKG

authvisage-sdk

Version:
457 lines (444 loc) 13.7 kB
'use strict'; var buffer = require('buffer'); var z = require('zod'); function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; } var z__default = /*#__PURE__*/_interopDefault(z); // src/utils/environment.ts var isBrowser = () => { return typeof window !== "undefined"; }; // src/helpers/oauthStateHandler.ts var OAuthStateHandler = class { /** * Generates and stores a unique state value */ static generate() { if (!isBrowser()) { return ""; } const state = crypto.randomUUID(); localStorage.setItem(this.STATE_STORAGE_KEY, state); return state; } /** * Validates the returned state against the stored one */ static validate(state) { if (!isBrowser()) { return false; } const storedState = localStorage.getItem(this.STATE_STORAGE_KEY); localStorage.removeItem(this.STATE_STORAGE_KEY); return storedState === state; } }; OAuthStateHandler.STATE_STORAGE_KEY = "authVisage:state"; var PKCEHandler = class { /** * Generates a PKCE challenge pair */ static async _sha256Base64UrlEncode(inputStr) { if (!isBrowser()) { return ""; } const encoder = new TextEncoder(); const data = encoder.encode(inputStr); const hash = await crypto.subtle.digest("SHA-256", data); const base64 = buffer.Buffer.from(hash).toString("base64"); return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); } static async _storeCodeVerifier(codeVerifier) { if (!isBrowser()) { return; } localStorage.setItem(this.PKCE_STORAGE_KEY, codeVerifier); } static async getCodeVerifier() { if (!isBrowser()) { return null; } const codeVerifier = localStorage.getItem(this.PKCE_STORAGE_KEY); if (!codeVerifier) { throw new Error("Code verifier not found in local storage."); } return codeVerifier; } static async generate() { if (!isBrowser()) { return { codeVerifier: "", codeChallenge: "" }; } const codeVerifier = crypto.randomUUID(); const codeChallenge = await this._sha256Base64UrlEncode(codeVerifier); await this._storeCodeVerifier(codeVerifier); return { codeVerifier, codeChallenge }; } }; PKCEHandler.PKCE_STORAGE_KEY = "authVisage:pkce_verifier"; var clientOptionsSchema = z__default.default.object({ projectId: z__default.default.string({ required_error: "Project ID is required", invalid_type_error: "Project ID must be a string" }).uuid({ message: "Project ID must be a valid UUID" }), platformUrl: z__default.default.string({ required_error: "Platform URL is required", invalid_type_error: "Platform URL must be a string" }).url({ message: "Platform URL must be a valid URL" }).refine( (val) => { const webUrlPattern = /^https?:\/\/[^/]+/; return webUrlPattern.test(val); }, { message: "Platform URL must be a valid web URL" } ), backendUrl: z__default.default.string({ required_error: "Backend URL is required", invalid_type_error: "Backend URL must be a string" }).url({ message: "Backend URL must be a valid URL" }).refine( (val) => { const webUrlPattern = /^https?:\/\/[^/]+/; return webUrlPattern.test(val); }, { message: "Backend URL must be a valid web URL" } ), redirectUrl: z__default.default.string({ required_error: "Redirect URL is required", invalid_type_error: "Redirect URL must be a string" }).url({ message: "Redirect URL must be a valid URL" }).refine( (val) => { const webUrlPattern = /^https?:\/\/[^/]+/; return webUrlPattern.test(val); }, { message: "Redirect URL must be a valid web URL" } ) }).strict({ message: "Invalid client options structure" }); // src/utils/safe-await.ts var safeAwait = async (promise) => { try { const data = await promise; return [data, null]; } catch (err) { return [null, err]; } }; // src/utils/decode-jwt.ts var decodeJwt = (token) => { if (!token || typeof token !== "string") { throw new Error("Invalid token: token must be a non-empty string"); } const parts = token.split("."); if (parts.length !== 3) { throw new Error("Invalid token: JWT must have 3 parts separated by dots"); } const payload = parts[1]; if (!payload) { throw new Error("Invalid token: missing payload section"); } try { const decoded = atob(payload.replace(/-/g, "+").replace(/_/g, "/")); return JSON.parse(decoded); } catch (e) { throw new Error("Invalid token: failed to decode payload"); } }; // src/helpers/listenerManager.ts var ListenerManager = class { constructor() { this.listeners = /* @__PURE__ */ new Set(); } subscribe(callback) { this.listeners.add(callback); return () => this.listeners.delete(callback); } notify(value) { this.listeners.forEach((cb) => cb(value)); } }; // src/helpers/sessionPersistence.ts var SessionPersistence = class { /** * Generates and stores a unique state value */ static setState(state) { if (!isBrowser()) { return; } const stateString = JSON.stringify(state); localStorage.setItem(this.STATE_STORAGE_KEY, stateString); } /** * Retrieves the stored state value */ static getState() { if (!isBrowser()) { return null; } const stateString = localStorage.getItem(this.STATE_STORAGE_KEY); if (!stateString) { return null; } try { const state = JSON.parse(stateString); localStorage.removeItem(this.STATE_STORAGE_KEY); return state; } catch (error) { console.error("Failed to parse session state:", error); return null; } } /** * Clears the stored state value */ static clearState() { if (!isBrowser()) { return; } localStorage.removeItem(this.STATE_STORAGE_KEY); } }; SessionPersistence.STATE_STORAGE_KEY = "authVisage:sessionState"; // src/auth/tokenManager.ts var TokenManager = class { constructor(backendUrl) { this.backendUrl = backendUrl; this.listenerManager = new ListenerManager(); this.expirationTimer = null; } /** * Handle token expiration by setting a timer. * @param expiresIn - The time in seconds until the token expires. */ _handleTokenExpiration(expiresIn) { if (this.expirationTimer) { clearTimeout(this.expirationTimer); } if (expiresIn) { this.expirationTimer = setTimeout(() => { this.listenerManager.notify(null); }, expiresIn * 1e3); } } /** * Initializes the user session by validating and decoding the access token, notifying registered listeners, * and scheduling automatic token expiration handling. * * @param session - The token response containing the `access_token` and `expires_in` values. * @throws {Error} If the session does not include an `access_token`. * @returns A promise that resolves once the session is set and expiration handling is in place. */ async setSession(session) { if (!session.access_token) { throw new Error("Session must contain an access token."); } SessionPersistence.setState(session); const decodedToken = decodeJwt(session.access_token); this.listenerManager.notify(decodedToken); this._handleTokenExpiration(session.expires_in); } /** * Sends a refresh request to get a new access token. * Assumes the refresh token is stored in cookies. */ async getAccessToken() { const token = SessionPersistence.getState(); const [response, responseError] = await safeAwait( fetch(`${this.backendUrl}/oauth/refresh-token`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refresh_token: token == null ? void 0 : token.refresh_token }) }) ); if (responseError || !response.ok) { this.listenerManager.notify(null); return null; } const [data, error] = await safeAwait(response.json()); if (error) { this.listenerManager.notify(null); return null; } this.setSession(data); return data.access_token; } /** * Logs the current user out by sending a POST request to the backend logout endpoint. * * This method includes credentials with the request and throws an error if the response * status is not in the 200–299 range. On a successful logout, it notifies all registered * listeners with `null`. * * @returns A promise that resolves when the logout operation completes successfully. * @throws {Error} If the logout request fails or the response is not OK. */ async logout() { const token = SessionPersistence.getState(); const response = await fetch(`${this.backendUrl}/oauth/logout`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...token }) }); if (!response.ok) { throw new Error(`Failed to sign out: ${response.statusText}`); } SessionPersistence.clearState(); this.listenerManager.notify(null); } /** * Subscribes to authentication state changes. * * @param callback - Function to be invoked whenever the authentication state updates. * @returns A Promise that resolves to an unsubscribe function which, * when called, removes the listener. */ onAuthStateChange(callback) { const unsubscribe = this.listenerManager.subscribe(callback); this.getAccessToken(); return unsubscribe; } }; // src/auth/authVisageClient.ts var AuthVisageClient = class { constructor(options) { this.initialized = false; const { platformUrl, projectId, backendUrl, redirectUrl } = options; const { error } = clientOptionsSchema.safeParse(options); if (error) { const message = error.issues[0].message; throw new Error( `Invalid client options: ${message} (path: ${error.issues[0].path.join( "." )})` ); } this.projectId = projectId; this.platformUrl = platformUrl; this.backendUrl = backendUrl; this.redirectUrl = redirectUrl; this.auth = new TokenManager(backendUrl); if (isBrowser()) { setTimeout(() => { this._handleOAuthCallback().catch(console.error); this.initialized = true; }, 0); } } /** * Get Session id from the backend * @returns Session id */ async _getSessionId() { const response = await fetch(`${this.backendUrl}/oauth/create-session`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ project_id: this.projectId }) }); if (!response.ok) { throw new Error("Session retrieval failed." + response.statusText); } const [data, error] = await safeAwait(response.json()); if (error) { throw new Error("Failed to parse session ID response."); } return data.id; } /** * Constructs the OAuth authorization URL */ async _constructAuthUrl() { const state = OAuthStateHandler.generate(); const pkcePair = await PKCEHandler.generate(); const sessionId = await this._getSessionId(); const url = new URL(this.platformUrl + "/authorize"); url.searchParams.append("state", state); url.searchParams.append("project_id", this.projectId); url.searchParams.append("redirect_uri", this.redirectUrl); url.searchParams.append("code_challenge", pkcePair.codeChallenge); url.searchParams.append("code_challenge_method", "S256"); url.searchParams.append("oauth_session_id", sessionId); return url.toString(); } /** * Handles the OAuth callback and exchanges the authorization code for an access token */ async _handleOAuthCallback() { if (!isBrowser()) { console.warn("OAuth callback handling is only supported in browser."); return; } const urlParams = new URLSearchParams(window.location.search); const code = urlParams.get("code"); const returnedState = urlParams.get("state"); if (!code || !returnedState) { return; } if (!OAuthStateHandler.validate(returnedState)) { console.error("State validation failed! Possible CSRF attack."); return; } const codeVerifier = await PKCEHandler.getCodeVerifier(); if (!codeVerifier) { return; } const response = await fetch(`${this.backendUrl}/oauth/token`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code, code_verifier: codeVerifier }) }); if (!response.ok) { throw new Error(`Token exchange failed: ${response.statusText}`); } const [data, error] = await safeAwait(response.json()); if (error) { throw new Error("Failed to parse token response."); } if (!data.access_token) { throw new Error("Token response missing access_token"); } if (data.refresh_token) { this.auth.setSession(data); } return data.access_token; } /** * Initiates the face login process by redirecting the user to AuthVisage */ async faceLogin() { const [data, error] = await safeAwait(this._constructAuthUrl()); if (error || !data) { throw new Error(`Face login failed: ${error == null ? void 0 : error.message}`); } if (!isBrowser()) { throw new Error( "Face login can only be initiated in a browser environment." ); } window.location.assign(data); } }; exports.AuthVisageClient = AuthVisageClient; exports.TokenManager = TokenManager;