UNPKG

@unqtech/age-verification-mitid

Version:

Frontend SDK for age verification via UNQVerify using MitID

589 lines (575 loc) 20.3 kB
"use strict"; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getOwnPropSymbols = Object.getOwnPropertySymbols; var __hasOwnProp = Object.prototype.hasOwnProperty; var __propIsEnum = Object.prototype.propertyIsEnumerable; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __spreadValues = (a, b) => { for (var prop in b || (b = {})) if (__hasOwnProp.call(b, prop)) __defNormalProp(a, prop, b[prop]); if (__getOwnPropSymbols) for (var prop of __getOwnPropSymbols(b)) { if (__propIsEnum.call(b, prop)) __defNormalProp(a, prop, b[prop]); } return a; }; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); var __async = (__this, __arguments, generator) => { return new Promise((resolve, reject) => { var fulfilled = (value) => { try { step(generator.next(value)); } catch (e) { reject(e); } }; var rejected = (value) => { try { step(generator.throw(value)); } catch (e) { reject(e); } }; var step = (x) => x.done ? resolve(x.value) : Promise.resolve(x.value).then(fulfilled, rejected); step((generator = generator.apply(__this, __arguments)).next()); }); }; // src/index.ts var src_exports = {}; __export(src_exports, { getVerifiedAge: () => getVerifiedAge, handleRedirectResult: () => handleRedirectResult, init: () => init, isVerified: () => isVerified, resetVerification: () => resetVerification, startVerificationWithPopup: () => startVerificationWithPopup, startVerificationWithRedirect: () => startVerificationWithRedirect }); module.exports = __toCommonJS(src_exports); // src/state.ts var config = null; function setConfig(c) { if (!c.publicKey || typeof c.publicKey !== "string") { throw new Error("[UNQVerify] publicKey is required and must be a string"); } if (!c.publicKey.startsWith("pk_test_") && !c.publicKey.startsWith("pk_live_")) { throw new Error("[UNQVerify] publicKey must start with 'pk_test_' or 'pk_live_'"); } if (!c.redirectUri || typeof c.redirectUri !== "string") { throw new Error("[UNQVerify] redirectUri is required and must be a string"); } if (!c.redirectUri.startsWith("https://") && !c.redirectUri.startsWith("http://localhost")) { throw new Error("[UNQVerify] redirectUri must be a valid HTTPS URL (or http://localhost for development)"); } if (typeof c.ageToVerify !== "number" || c.ageToVerify < 1 || c.ageToVerify > 150) { throw new Error("[UNQVerify] ageToVerify must be a number between 1 and 150"); } if (typeof c.onVerified !== "function") { throw new Error("[UNQVerify] onVerified callback is required and must be a function"); } config = c; } function getConfig() { if (!config) throw new Error("[UNQVerify] SDK not initialized"); return config; } // src/verification/buildUrl.ts function buildOidcUrl(config2) { const isTestKey = config2.publicKey.startsWith("pk_test_"); const baseUrl = isTestKey ? "https://test.api.aldersverificering.dk" : "https://api.aldersverificering.dk"; const query = new URLSearchParams({ RedirectUri: config2.redirectUri, AgeToVerify: config2.ageToVerify.toString() }); return `${baseUrl}/api/v1/oidc/create?${query.toString()}`; } // src/verification/callApi.ts function callVerificationApi(url, publicKey) { return __async(this, null, function* () { const res = yield fetch(url, { method: "POST", headers: { "Content-Type": "application/json", "x-public-key": publicKey } }); if (!res.ok) { const errorBody = yield res.text().catch(() => "Unable to read error response"); throw new Error(`API error: ${res.status} - ${errorBody}`); } const redirectUrl = yield res.text(); if (!redirectUrl.startsWith("https://") && !redirectUrl.startsWith("http://localhost")) { throw new Error("Invalid redirect URL returned from API"); } return redirectUrl; }); } // src/utils/cookies.ts function getCookie(name) { const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); if (parts.length === 2) return parts.pop().split(";").shift() || null; return null; } function setCookie(name, value, expiresInSeconds) { let cookie = `${name}=${value}; path=/; SameSite=Lax; Secure`; if (expiresInSeconds) { const expiryDate = new Date(Date.now() + expiresInSeconds * 1e3); cookie += `; expires=${expiryDate.toUTCString()}`; } document.cookie = cookie; } function deleteCookie(name) { document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax; Secure`; } // src/constants.ts var VERIFICATION_COOKIE_KEY = "unqverify_token"; var MIN_COOKIE_LIFETIME_SECONDS = 3600; var POPUP_POLL_INTERVAL_MS = 1e3; var POPUP_TIMEOUT_MS = 5 * 60 * 1e3; // src/verify/isVerified.ts var import_jwt_decode = require("jwt-decode"); function isVerified() { try { const token = getCookie(VERIFICATION_COOKIE_KEY); if (!token) return false; const decoded = (0, import_jwt_decode.jwtDecode)(token); if (!decoded.aldersverificeringdk_verification_result) return false; if (typeof decoded.exp !== "number" || !Number.isFinite(decoded.exp)) return false; const now = Math.floor(Date.now() / 1e3); if (decoded.exp <= now) return false; return true; } catch (err) { console.warn("[UNQVerify] Failed to parse verification token:", err); return false; } } // src/verification/actions.ts var import_jwt_decode2 = require("jwt-decode"); function debugLog(config2, ...args) { if (config2.debug) console.debug("[UNQVerify debug]", ...args); } function routeOutcome(config2, type, outcome) { var _a, _b, _c, _d; if (type === "denied") { (_a = config2.onDenied) == null ? void 0 : _a.call(config2, outcome); } else if (type === "cancelled") { (_b = config2.onCancelled) == null ? void 0 : _b.call(config2, outcome); } else { (_c = config2.onError) == null ? void 0 : _c.call(config2, outcome); } (_d = config2.onFailure) == null ? void 0 : _d.call(config2, new Error(outcome.message)); } var VALID_ERROR_CODES = /* @__PURE__ */ new Set([ "UNDER_AGE", "POPUP_CLOSED", "USER_CANCELLED", "POPUP_BLOCKED", "POPUP_TIMEOUT", "UNTRUSTED_ORIGIN", "NETWORK_ERROR", "TOKEN_INVALID", "UNKNOWN_ERROR" ]); function mapIncomingCode(raw) { if (typeof raw === "string" && VALID_ERROR_CODES.has(raw)) { return raw; } return "UNKNOWN_ERROR"; } var popupInFlight = false; function startVerificationWithRedirect() { return __async(this, null, function* () { let config2 = null; try { config2 = getConfig(); } catch (err) { console.error("[UNQVerify] Failed to retrieve configuration:", err); throw err; } if (isVerified()) { const token = getCookie(VERIFICATION_COOKIE_KEY); if (token) { let decoded = {}; try { decoded = (0, import_jwt_decode2.jwtDecode)(token); } catch (e) { console.warn( "[UNQVerify] Failed to decode token claims for onVerified payload" ); } config2.onVerified(__spreadValues({ token }, decoded)); } else { console.warn( "[UNQVerify] Token missing even though isVerified() was true." ); routeOutcome(config2, "error", { code: "TOKEN_INVALID", message: "User appears verified, but token cookie is missing." }); } return; } try { const oidcUrl = buildOidcUrl(config2); const redirectUrl = yield callVerificationApi(oidcUrl, config2.publicKey); if (!redirectUrl || typeof redirectUrl !== "string") { throw new Error("Invalid redirect URL returned from verification API."); } window.location.href = redirectUrl; } catch (err) { console.error("[UNQVerify] Failed to start verification:", err); routeOutcome(config2, "error", { code: "NETWORK_ERROR", message: err instanceof Error ? err.message : String(err), raw: err }); } }); } function startVerificationWithPopup(providedPopup) { return __async(this, null, function* () { if (popupInFlight) { console.warn("[UNQVerify] Popup verification already in progress \u2014 ignoring duplicate call."); return; } popupInFlight = true; const config2 = getConfig(); let popup = null; let shouldClosePopup = false; let completed = false; let intervalId = null; let listener = () => { }; const isTestKey = config2.publicKey.startsWith("pk_test_"); const expectedOrigin = isTestKey ? "https://test.aldersverificering.dk" : "https://aldersverificering.dk"; let redirectOrigin = null; try { redirectOrigin = new URL(config2.redirectUri).origin; } catch (e) { } const trustedOrigins = [expectedOrigin]; if (redirectOrigin) trustedOrigins.push(redirectOrigin); function finalize(action) { if (completed) return; completed = true; popupInFlight = false; if (intervalId !== null) clearInterval(intervalId); window.removeEventListener("message", listener); if (shouldClosePopup && popup) { try { popup.close(); } catch (e) { } } action(); } listener = (event) => { var _a, _b; const raw = event.data; const messageType = raw == null ? void 0 : raw["type"]; if (messageType !== "UNQVERIFY_RESULT" && messageType !== "UNQVERIFY_FAILURE") { debugLog( config2, "Ignored non-SDK message \u2014 type:", messageType, "origin:", event.origin ); return; } if (!trustedOrigins.includes(event.origin)) { console.warn( "[UNQVerify] Received message from untrusted origin:", event.origin ); debugLog(config2, "Trusted origins:", trustedOrigins); return; } if (completed) { debugLog(config2, "Ignored duplicate/late message:", messageType); return; } if (messageType === "UNQVERIFY_RESULT") { const payload = (_a = raw == null ? void 0 : raw["payload"]) != null ? _a : {}; finalize(() => { try { config2.onVerified(payload); window.dispatchEvent(new CustomEvent("unqverify:updated")); } catch (err) { routeOutcome(config2, "error", { code: "UNKNOWN_ERROR", message: err instanceof Error ? err.message : String(err), raw: err }); } }); } else { const data = raw; const code = mapIncomingCode(data == null ? void 0 : data.code); const message = (_b = data == null ? void 0 : data.error) != null ? _b : "Verification failed in popup"; finalize(() => { routeOutcome( config2, code === "UNDER_AGE" ? "denied" : "error", { code, message, raw: event.data } ); }); } }; try { const url = buildOidcUrl(config2); const redirectUrl = yield callVerificationApi(url, config2.publicKey); if (providedPopup && !providedPopup.closed) { popup = providedPopup; popup.location.href = redirectUrl; shouldClosePopup = false; } else { popup = window.open(redirectUrl, "unqverify-popup", "width=500,height=650"); shouldClosePopup = true; if (!popup) { popupInFlight = false; console.warn("[UNQVerify] Popup blocked"); routeOutcome(config2, "error", { code: "POPUP_BLOCKED", message: "Popup blocked by browser. Please allow popups and try again." }); return; } } window.addEventListener("message", listener); const maxCheckTime = POPUP_TIMEOUT_MS; const startTime = Date.now(); intervalId = setInterval(() => { const timedOut = Date.now() - startTime > maxCheckTime; if ((popup == null ? void 0 : popup.closed) || timedOut) { const code = timedOut ? "POPUP_TIMEOUT" : "POPUP_CLOSED"; const message = timedOut ? "Verification timed out." : "User closed the popup before completing verification."; finalize(() => { routeOutcome(config2, "cancelled", { code, message }); }); } }, POPUP_POLL_INTERVAL_MS); } catch (err) { popupInFlight = false; if (shouldClosePopup && popup) { try { popup.close(); } catch (e) { } } console.error("[UNQVerify] Failed to initiate popup verification:", err); routeOutcome(config2, "error", { code: "NETWORK_ERROR", message: err instanceof Error ? err.message : String(err), raw: err }); } }); } // src/verify/decodeAndStoreToken.ts var import_jose = require("jose"); var import_jwt_decode3 = require("jwt-decode"); function getJwksUrlFromIssuer(issuer) { if (issuer === "https://test.aldersverificering.dk") { return "https://test.api.aldersverificering.dk/well-known/openid-configuration/jwks"; } if (issuer === "https://aldersverificering.dk") { return "https://api.aldersverificering.dk/well-known/openid-configuration/jwks"; } return null; } function decodeAndStoreToken(token) { return __async(this, null, function* () { try { const decoded = (0, import_jwt_decode3.jwtDecode)(token); const jwksUrl = getJwksUrlFromIssuer(decoded.iss); if (!jwksUrl) { console.warn("[UNQVerify] Unknown issuer:", decoded.iss); return { ok: false, code: "TOKEN_INVALID", message: `Unknown issuer: ${decoded.iss}` }; } let verifiedPayload; try { const JWKS = (0, import_jose.createRemoteJWKSet)(new URL(jwksUrl)); const { payload } = yield (0, import_jose.jwtVerify)(token, JWKS, { algorithms: ["RS256"] }); verifiedPayload = payload; } catch (err) { console.error("[UNQVerify] Failed to verify JWT with JWKS:", err); return { ok: false, code: "TOKEN_INVALID", message: "JWT cryptographic verification failed", raw: err }; } if (verifiedPayload.iss !== decoded.iss) { console.warn("[UNQVerify] Issuer mismatch after verification"); return { ok: false, code: "TOKEN_INVALID", message: "Issuer mismatch after verification" }; } const { aldersverificeringdk_verification_result, exp } = verifiedPayload; if (typeof exp !== "number") { console.warn("[UNQVerify] Missing or invalid exp claim"); return { ok: false, code: "TOKEN_INVALID", message: "Missing or invalid exp claim" }; } if (!aldersverificeringdk_verification_result) { console.warn("[UNQVerify] Token is valid but user did not meet the age requirement"); return { ok: false, code: "UNDER_AGE", message: "User does not meet the age requirement" }; } const secondsToExpiry = exp - Math.floor(Date.now() / 1e3); if (secondsToExpiry <= 0) { console.warn("[UNQVerify] Token has expired"); return { ok: false, code: "TOKEN_INVALID", message: "Token has expired" }; } const cookieExpiry = Math.max(secondsToExpiry, MIN_COOKIE_LIFETIME_SECONDS); setCookie(VERIFICATION_COOKIE_KEY, token, cookieExpiry); return { ok: true }; } catch (err) { console.error("[UNQVerify] Unexpected error in decodeAndStoreToken:", err); return { ok: false, code: "UNKNOWN_ERROR", message: "Unexpected error during token verification", raw: err }; } }); } // src/verify/handleRedirectResult.ts var import_jwt_decode4 = require("jwt-decode"); function resolvePostMessageOrigin(targetOrigin, opener) { if (targetOrigin) return targetOrigin; try { return opener.location.origin; } catch (e) { console.warn( "[UNQVerify] Could not determine opener origin and no targetOrigin was provided. Skipping postMessage to avoid broadcasting to '*'. Pass targetOrigin to handleRedirectResult to fix this." ); return null; } } function handleRedirectResult(_0) { return __async(this, arguments, function* ({ onVerified, onFailure, onDenied, onError, targetOrigin }) { let errorCode = "UNKNOWN_ERROR"; try { const url = new URL(window.location.href); const token = url.searchParams.get("jwt"); if (!token) { errorCode = "TOKEN_INVALID"; throw new Error("No JWT found in URL"); } const tokenResult = yield decodeAndStoreToken(token); if (!tokenResult.ok) { errorCode = tokenResult.code; throw new Error(tokenResult.message); } url.searchParams.delete("jwt"); window.history.replaceState({}, document.title, url.toString()); const payload = (0, import_jwt_decode4.jwtDecode)(token); if (window.opener) { try { const origin = resolvePostMessageOrigin( targetOrigin, window.opener ); if (origin) { window.opener.postMessage( { type: "UNQVERIFY_RESULT", payload }, origin ); } } catch (postErr) { console.warn("[UNQVerify] Failed to post result to opener:", postErr); } } onVerified(payload); } catch (err) { console.error("[UNQVerify] handleRedirectResult failed:", err); const message = err instanceof Error ? err.message : String(err); if (window.opener) { try { const origin = resolvePostMessageOrigin( targetOrigin, window.opener ); if (origin) { window.opener.postMessage( { type: "UNQVERIFY_FAILURE", error: message, code: errorCode }, origin ); } } catch (postErr) { console.warn("[UNQVerify] Failed to post failure to opener:", postErr); } } const outcome = { code: errorCode, message, raw: err }; if (errorCode === "UNDER_AGE") { onDenied == null ? void 0 : onDenied(outcome); } else { onError == null ? void 0 : onError(outcome); } onFailure == null ? void 0 : onFailure(err); } }); } // src/verify/getVerifiedAge.ts var import_jwt_decode5 = require("jwt-decode"); function getVerifiedAge() { try { const token = getCookie(VERIFICATION_COOKIE_KEY); if (!token) return null; const decoded = (0, import_jwt_decode5.jwtDecode)(token); const now = Math.floor(Date.now() / 1e3); const isTokenValid = decoded.exp >= now; const isVerified2 = decoded.aldersverificeringdk_verification_result; if (isTokenValid && isVerified2) { return decoded.aldersverificeringdk_verification_age; } return null; } catch (err) { console.warn("[UNQVerify] Failed to extract verified age from token:", err); return null; } } // src/verify/resetVerification.ts function resetVerification() { deleteCookie(VERIFICATION_COOKIE_KEY); console.info("[UNQVerify] Verification token cleared"); } // src/index.ts function init(config2) { setConfig(config2); } // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { getVerifiedAge, handleRedirectResult, init, isVerified, resetVerification, startVerificationWithPopup, startVerificationWithRedirect });