@unqtech/age-verification-mitid
Version:
Frontend SDK for age verification via UNQVerify using MitID
559 lines (546 loc) • 18.9 kB
JavaScript
var __defProp = Object.defineProperty;
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 __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/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
import { jwtDecode } from "jwt-decode";
function isVerified() {
try {
const token = getCookie(VERIFICATION_COOKIE_KEY);
if (!token)
return false;
const decoded = 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
import { jwtDecode as jwtDecode2 } from "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 = jwtDecode2(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
import { jwtVerify, createRemoteJWKSet } from "jose";
import { jwtDecode as jwtDecode3 } from "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 = jwtDecode3(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 = createRemoteJWKSet(new URL(jwksUrl));
const { payload } = yield 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
import { jwtDecode as jwtDecode4 } from "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 = jwtDecode4(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
import { jwtDecode as jwtDecode5 } from "jwt-decode";
function getVerifiedAge() {
try {
const token = getCookie(VERIFICATION_COOKIE_KEY);
if (!token)
return null;
const decoded = jwtDecode5(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);
}
export {
getVerifiedAge,
handleRedirectResult,
init,
isVerified,
resetVerification,
startVerificationWithPopup,
startVerificationWithRedirect
};