authvisage-sdk
Version:
authvisage client sdk
457 lines (444 loc) • 13.7 kB
JavaScript
'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;