UNPKG

@authlink/client

Version:

Official client SDK for integrating with the Authlink Identity Provider

214 lines (213 loc) 9.5 kB
import { generateRandomString, createPkcePair, validateIdToken } from "./utils"; import { StateMismatchError, SilentAuthorizationError, CodeVerifierMissingError, TokenExchangeError, NonceMissingError, IdTokenMissingError } from "./errors"; export class IdentityProviderClient { constructor(baseUrl, clientId, cleanupDelayMs = 5000) { // Key names for storing values in session storage this.codeVerifierKey = "__code_verifier"; this.stateKey = "__state"; this.nonceKey = "__nonce"; this.baseUrl = baseUrl; this.clientId = clientId; this.cleanupDelayMs = Math.max(1000, cleanupDelayMs); } /** * Initiates the authorization process by redirecting the user to the authorization server. * @param options The authorization options. * @returns A promise that resolves when the authorization process is initiated. * */ async authorize(options = {}) { let state = options.state; let nonce = options.nonce; let codeChallenge = options.codeChallenge; let codeVerifier; // If state, nonce, or codeChallenge are not provided, generate them if (!state) state = generateRandomString(32); if (!nonce) nonce = generateRandomString(32); if (!codeChallenge) { const { codeVerifier: verifier, codeChallenge: challenge } = await createPkcePair(); codeChallenge = challenge; codeVerifier = verifier; } sessionStorage.setItem(this.stateKey, state); sessionStorage.setItem(this.nonceKey, nonce); if (codeVerifier) sessionStorage.setItem(this.codeVerifierKey, codeVerifier); // Construct the authorization URL const params = new URLSearchParams({ responseType: options.responseType ?? "code", responseMode: options.responseMode ?? "query", prompt: options.prompt ?? "login", clientId: options.clientId ?? this.clientId, redirectUri: options.redirectUri ?? window.location.origin, scope: options.scope ?? "openid", state, nonce, codeChallenge, codeChallengeMethod: "S256" }); window.location.href = `${this.baseUrl}/connect/authorize?${params.toString()}`; } /** * Initiates the silent authorization process by creating an invisible iframe. * @param options The authorization options. * @returns A promise that resolves to the authorization code. * */ async silentAuthorize(options = {}) { return new Promise(async (resolve, reject) => { let state = options.state; let nonce = options.nonce; let codeChallenge = options.codeChallenge; let codeVerifier; // If state, nonce, or codeChallenge are not provided, generate them if (!state) state = generateRandomString(32); if (!nonce) nonce = generateRandomString(32); if (!codeChallenge) { const { codeVerifier: verifier, codeChallenge: challenge } = await createPkcePair(); codeChallenge = challenge; codeVerifier = verifier; } sessionStorage.setItem(this.stateKey, state); sessionStorage.setItem(this.nonceKey, nonce); if (codeVerifier) sessionStorage.setItem(this.codeVerifierKey, codeVerifier); // Construct the authorization URL const params = new URLSearchParams({ responseType: options.responseType ?? "code", responseMode: options.responseMode ?? "web_message", prompt: options.prompt ?? "none", clientId: options.clientId ?? this.clientId, redirectUri: options.redirectUri ?? window.location.origin, scope: options.scope ?? "openid", state, nonce, codeChallenge, codeChallengeMethod: "S256" }); // Create an invisible iframe to initiate the authorization process const iframe = document.createElement("iframe"); iframe.style.display = "none"; iframe.src = `${this.baseUrl}/connect/authorize?${params.toString()}`; document.body.appendChild(iframe); const handler = (event) => { // Check if the message is from the expected origin if (event.origin !== this.baseUrl) return; // Check if the message contains the expected data const { data } = event; if (data.type !== "authorization_response") return; // Remove the iframe and event listener window.removeEventListener("message", handler); document.body.removeChild(iframe); if (data.error) reject(new SilentAuthorizationError(data.error, data.errorDescription)); else if (data.code) resolve(data.code); else reject(); }; window.addEventListener("message", handler); }); } /** * Checks if the state returned from the authorization server matches the stored state * @param state The state returned from the authorization server * @returns True if the state is valid, false otherwise * */ isValidState(state) { const storedState = sessionStorage.getItem(this.stateKey); return state === storedState; } /** * Logs out the user by redirecting to the identity provider's logout endpoint * @param options The logout options. * */ logout(options = {}) { let state = options.state; if (!state) state = generateRandomString(32); sessionStorage.setItem(this.stateKey, state); const params = new URLSearchParams({ clientId: options.clientId ?? this.clientId, postLogoutRedirectUri: options.postLogoutRedirectUri ?? window.location.origin, state }); if (options.idTokenHint) params.append("idTokenHint", options.idTokenHint); window.location.href = `${this.baseUrl}/connect/logout?${params.toString()}`; } /** * Exchanges the authorization code or refresh token for new tokens * @param options The token exchange options. * @returns A promise that resolves to the token response. * */ async token(options = {}) { // Create the request body let request = { grantType: options.grantType ?? "authorization_code", clientId: options.clientId ?? this.clientId, redirectUri: options.redirectUri ?? window.location.origin }; if (request.grantType === "authorization_code") { // Get the code verifier from the session storage const codeVerifier = sessionStorage.getItem(this.codeVerifierKey); if (!codeVerifier) throw new CodeVerifierMissingError(); // Get the state from the cookie const state = options.state ?? new URLSearchParams(window.location.search).get("state") ?? ""; if (options.validateState && !this.isValidState(state)) throw new StateMismatchError(); // Add the code and code verifier to the request request.code = options.code ?? new URLSearchParams(window.location.search).get("code") ?? ""; request.codeVerifier = codeVerifier; } else if (request.grantType === "refresh_token") request.refreshToken = options.refreshToken ?? ""; // If a token exchange handler is provided, use it to get the token // Otherwise, make a request to the token endpoint let response; if (options.tokenExchangeHandler) response = await options.tokenExchangeHandler(request); else { const res = await fetch(`${this.baseUrl}/connect/token`, { method: "POST", headers: { "Content-Type": "application/json", "X-Client-Id": this.clientId }, credentials: "include", body: new URLSearchParams({ ...request }) }); if (!res.ok) { const error = await res.text(); throw new TokenExchangeError(res.status, error); } response = await res.json(); } // Validate the nonce if (request.grantType === "authorization_code") { const nonce = sessionStorage.getItem(this.nonceKey); if (!nonce) throw new NonceMissingError(); const idToken = response.idToken; if (!idToken) throw new IdTokenMissingError(); // Validate the ID token await validateIdToken(idToken, this.baseUrl, this.clientId, nonce); setTimeout(() => { sessionStorage.removeItem(this.stateKey); sessionStorage.removeItem(this.codeVerifierKey); sessionStorage.removeItem(this.nonceKey); }, this.cleanupDelayMs); } return response; } }