// Resolution for short-lived exchange and optional signed connect links in // the Electron main process. The renderer only hands us the raw deep-link URL; // every security decision happens on this side of the trust boundary. // // Signed-token verification is a dependency-free mirror of // packages/connect-link/src/node.ts. Compact JWS, EdDSA/Ed25519 via node:crypto. import { Buffer } from "node:buffer"; import { createPublicKey, verify } from "node:crypto"; import { readFile, mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; const CONNECT_LINK_ALGORITHM = "EdDSA"; const CONNECT_LINK_AUDIENCE = "openwork-desktop-connect"; const CONNECT_LINK_VERSION = 1; const CONNECT_LINK_ROUTE = "connect"; const DEFAULT_CLOCK_SKEW_SECONDS = 60; const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/; const CONNECT_EXCHANGE_CODE_PATTERN = /^[A-Za-z0-9_-]{24,128}$/; const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]); const REPLAY_GUARD_MAX_ENTRIES = 512; /** * @param {string} value * @returns {string | null} */ function base64UrlDecode(value) { if (!BASE64URL_PATTERN.test(value)) return null; try { return Buffer.from(value, "base64url").toString("utf8"); } catch { return null; } } /** * @param {string} rawUrl * @returns {boolean} */ function isLoopbackUrl(rawUrl) { try { return LOOPBACK_HOSTS.has(new URL(rawUrl).hostname); } catch { return false; } } /** * @param {import("@openwork/types/connect-link").ConnectLinkClaims} claims * @returns {string | null} */ function findRefusedClaimUrl(claims, allowInsecureLoopback) { const candidates = [ claims.den.baseUrl, claims.den.apiBaseUrl ?? null, claims.brand.logoUrl, claims.brand.iconUrl, ]; for (const candidate of candidates) { if (!candidate) continue; let parsed; try { parsed = new URL(candidate); } catch { return candidate; } if (parsed.protocol !== "https:" && !(allowInsecureLoopback && isLoopbackUrl(candidate))) { return candidate; } } return null; } /** * @param {string} value * @returns {boolean} */ function isHttpUrl(value) { try { const parsed = new URL(value); return parsed.protocol === "https:" || parsed.protocol === "http:"; } catch { return false; } } /** * Structural claim validation, mirroring connectLinkClaimsSchema in * packages/connect-link. Hand-rolled because the Electron main process keeps * zero runtime workspace dependencies. * * @param {unknown} payload * @returns {import("@openwork/types/connect-link").ConnectLinkClaims | null} */ function normalizeClaims(payload) { if (typeof payload !== "object" || payload === null) return null; const record = /** @type {Record} */ (payload); const org = record.org; const brand = record.brand; const den = record.den; if (typeof org !== "object" || org === null) return null; if (typeof brand !== "object" || brand === null) return null; if (typeof den !== "object" || den === null) return null; const orgRecord = /** @type {Record} */ (org); const brandRecord = /** @type {Record} */ (brand); const denRecord = /** @type {Record} */ (den); const iss = typeof record.iss === "string" ? record.iss.trim() : ""; const jti = typeof record.jti === "string" ? record.jti.trim() : ""; const orgName = typeof orgRecord.name === "string" ? orgRecord.name.trim() : ""; const brandAppName = typeof brandRecord.appName === "string" ? brandRecord.appName.trim() : ""; const brandLogoUrl = typeof brandRecord.logoUrl === "string" ? brandRecord.logoUrl.trim() : null; const brandIconUrl = typeof brandRecord.iconUrl === "string" ? brandRecord.iconUrl.trim() : null; const denBaseUrl = typeof denRecord.baseUrl === "string" ? denRecord.baseUrl.trim() : ""; const denApiBaseUrlRaw = denRecord.apiBaseUrl; const denApiBaseUrl = typeof denApiBaseUrlRaw === "string" ? denApiBaseUrlRaw.trim() : null; if (!iss || !isHttpUrl(iss)) return null; if (record.aud !== CONNECT_LINK_AUDIENCE) return null; if (typeof record.iat !== "number" || !Number.isInteger(record.iat) || record.iat < 0) return null; if (typeof record.exp !== "number" || !Number.isInteger(record.exp) || record.exp < 0) return null; if (jti.length < 8) return null; if (record.v !== CONNECT_LINK_VERSION) return null; if (!orgName || orgName.length > 128) return null; if (!brandAppName || brandAppName.length > 64) return null; if (brandLogoUrl !== null && !isHttpUrl(brandLogoUrl)) return null; if (brandIconUrl !== null && !isHttpUrl(brandIconUrl)) return null; if (!denBaseUrl || !isHttpUrl(denBaseUrl)) return null; if (denApiBaseUrl !== null && !isHttpUrl(denApiBaseUrl)) return null; if (typeof record.requireSignin !== "boolean") return null; return { iss, aud: CONNECT_LINK_AUDIENCE, iat: record.iat, exp: record.exp, jti, v: CONNECT_LINK_VERSION, org: { name: orgName }, brand: { appName: brandAppName, logoUrl: brandLogoUrl, iconUrl: brandIconUrl }, den: { baseUrl: denBaseUrl, ...(denApiBaseUrl !== null ? { apiBaseUrl: denApiBaseUrl } : {}) }, requireSignin: record.requireSignin, }; } /** * Extracts the signed token from a connect deep link. Accepts the openwork * and openwork-dev schemes and both authority forms (openwork://connect and * openwork:///connect). * * @param {string} rawUrl * @returns {string | null} */ export function extractConnectLinkToken(rawUrl) { if (typeof rawUrl !== "string" || !rawUrl.trim()) return null; let parsed; try { parsed = new URL(rawUrl.trim()); } catch { return null; } if (parsed.protocol !== "openwork:" && parsed.protocol !== "openwork-dev:") return null; const route = (parsed.hostname || parsed.pathname.replace(/^\/+|\/+$/g, "")).toLowerCase(); if (route !== CONNECT_LINK_ROUTE) return null; if (parsed.searchParams.has("code") || parsed.searchParams.has("apiBaseUrl")) return null; const token = parsed.searchParams.get("token")?.trim(); return token || null; } /** * @param {string} rawUrl * @returns {{ code: string, apiBaseUrl: string } | null} */ export function extractConnectExchange(rawUrl) { if (typeof rawUrl !== "string" || !rawUrl.trim()) return null; let parsed; try { parsed = new URL(rawUrl.trim()); } catch { return null; } if (parsed.protocol !== "openwork:" && parsed.protocol !== "openwork-dev:") return null; const route = (parsed.hostname || parsed.pathname.replace(/^\/+|\/+$/g, "")).toLowerCase(); if (route !== CONNECT_LINK_ROUTE || parsed.searchParams.has("token")) return null; const code = parsed.searchParams.get("code")?.trim() ?? ""; const apiBaseUrl = parsed.searchParams.get("apiBaseUrl")?.trim() ?? ""; if (!CONNECT_EXCHANGE_CODE_PATTERN.test(code) || !apiBaseUrl) return null; return { code, apiBaseUrl }; } /** * @param {{ * token: string, * publicKeys: Record, * nowEpochSeconds?: number, * clockSkewSeconds?: number, * allowInsecureLoopback?: boolean, * }} input * @returns {import("@openwork/types/connect-link").ConnectLinkVerifyResult} */ export function verifyConnectLinkToken(input) { const now = input.nowEpochSeconds ?? Math.floor(Date.now() / 1000); const skew = input.clockSkewSeconds ?? DEFAULT_CLOCK_SKEW_SECONDS; const parts = input.token.split("."); if (parts.length !== 3 || parts.some((part) => part.length === 0)) { return { ok: false, code: "invalid_token", message: "Token is not a three-part compact JWS." }; } const [headerPart, payloadPart, signaturePart] = parts; const headerJson = base64UrlDecode(headerPart); if (headerJson === null) { return { ok: false, code: "invalid_token", message: "Token header is not valid base64url." }; } let header; try { header = JSON.parse(headerJson); } catch { return { ok: false, code: "invalid_token", message: "Token header is not valid JSON." }; } if (typeof header !== "object" || header === null) { return { ok: false, code: "invalid_token", message: "Token header is not an object." }; } if (header.alg !== CONNECT_LINK_ALGORITHM) { return { ok: false, code: "invalid_token", message: `Token alg must be ${CONNECT_LINK_ALGORITHM}.` }; } if ("crit" in header) { return { ok: false, code: "invalid_token", message: "Token crit header is not supported." }; } if (header.typ !== undefined && header.typ !== "JWT") { return { ok: false, code: "invalid_token", message: "Token typ must be JWT when present." }; } const kid = header.kid; if (typeof kid !== "string" || kid.length === 0) { return { ok: false, code: "invalid_token", message: "Token kid header is required." }; } const publicKeyPem = input.publicKeys[kid]; if (!publicKeyPem) { return { ok: false, code: "unknown_kid", message: `No trusted key for kid ${kid}.` }; } if (!BASE64URL_PATTERN.test(signaturePart)) { return { ok: false, code: "invalid_token", message: "Token signature is not valid base64url." }; } const signingInput = Buffer.from(`${headerPart}.${payloadPart}`, "utf8"); const signature = Buffer.from(signaturePart, "base64url"); let signatureValid = false; try { signatureValid = verify(null, signingInput, createPublicKey(publicKeyPem), signature); } catch { signatureValid = false; } if (!signatureValid) { return { ok: false, code: "bad_signature", message: "Token signature does not verify." }; } const payloadJson = base64UrlDecode(payloadPart); if (payloadJson === null) { return { ok: false, code: "invalid_token", message: "Token payload is not valid base64url." }; } let payload; try { payload = JSON.parse(payloadJson); } catch { return { ok: false, code: "invalid_token", message: "Token payload is not valid JSON." }; } if (typeof payload !== "object" || payload === null) { return { ok: false, code: "invalid_token", message: "Token payload is not an object." }; } if (payload.aud !== CONNECT_LINK_AUDIENCE) { return { ok: false, code: "wrong_audience", message: "Token audience is not the desktop connect audience." }; } if (payload.v !== CONNECT_LINK_VERSION) { return { ok: false, code: "wrong_version", message: "Token payload version is not supported." }; } const claims = normalizeClaims(payload); if (!claims) { return { ok: false, code: "malformed_claims", message: "Token claims failed validation." }; } if (claims.iat > now + skew) { return { ok: false, code: "not_yet_valid", message: "Token is not valid yet." }; } if (claims.exp <= now - skew) { return { ok: false, code: "expired", message: "Token has expired." }; } const refusedUrl = findRefusedClaimUrl(claims, input.allowInsecureLoopback === true); if (refusedUrl) { return { ok: false, code: "insecure_url", message: `Token target is not https: ${refusedUrl}` }; } return { ok: true, claims, transport: "signed", kid }; } /** * Verifies a raw connect deep-link URL end to end. * * @param {string} rawUrl * @param {{ * publicKeys: Record, * nowEpochSeconds?: number, * allowInsecureLoopback?: boolean, * }} options * @returns {import("@openwork/types/connect-link").ConnectLinkVerifyResult} */ export function verifyConnectLinkUrl(rawUrl, options) { const token = extractConnectLinkToken(rawUrl); if (!token) { return { ok: false, code: "invalid_token", message: "Not a connect deep link." }; } return verifyConnectLinkToken({ token, publicKeys: options.publicKeys, nowEpochSeconds: options.nowEpochSeconds, allowInsecureLoopback: options.allowInsecureLoopback, }); } /** * @param {string} rawUrl * @param {{ * mode: "preview" | "exchange", * fetcher: (url: string, init: object) => Promise, * nowEpochSeconds?: number, * allowInsecureLoopback?: boolean, * }} options * @returns {Promise} */ export async function resolveConnectExchangeUrl(rawUrl, options) { const exchange = extractConnectExchange(rawUrl); if (!exchange) { return { ok: false, code: "invalid_token", message: "Not a keyless connect deep link." }; } const apiBaseUrl = normalizeExchangeApiBaseUrl(exchange.apiBaseUrl, options.allowInsecureLoopback === true); if (!apiBaseUrl) { return { ok: false, code: "insecure_url", message: "Connection server must use HTTPS." }; } const endpoint = new URL(apiBaseUrl); endpoint.pathname = `${endpoint.pathname.replace(/\/+$/, "")}/v1/install-connect/${options.mode}`.replace(/\/{2,}/g, "/"); endpoint.search = ""; endpoint.hash = ""; let response; try { response = await options.fetcher(endpoint.toString(), { method: "POST", headers: { "content-type": "application/json", accept: "application/json" }, body: JSON.stringify({ code: exchange.code }), redirect: "error", signal: AbortSignal.timeout(10_000), }); } catch { return { ok: false, code: "unavailable", message: "The organization server could not be reached." }; } if (response.status === 409) { return { ok: false, code: "replayed", message: "This connect link was already used." }; } if (response.status === 410) { return { ok: false, code: "expired", message: "This connect link expired." }; } if (!response.ok) { return response.status === 404 ? { ok: false, code: "invalid_token", message: "This connect link is not valid." } : { ok: false, code: "unavailable", message: "The organization server refused the connection." }; } let payload; try { payload = await response.json(); } catch { return { ok: false, code: "malformed_claims", message: "The organization server returned invalid data." }; } if (typeof payload !== "object" || payload === null || !("claims" in payload)) { return { ok: false, code: "malformed_claims", message: "The organization server returned invalid data." }; } const claims = normalizeClaims(payload.claims); if (!claims) { return { ok: false, code: "malformed_claims", message: "Connection claims failed validation." }; } const claimsApiBaseUrl = claims.den.apiBaseUrl ? normalizeExchangeApiBaseUrl(claims.den.apiBaseUrl, options.allowInsecureLoopback === true) : null; const claimsIssuer = normalizeExchangeApiBaseUrl(claims.iss, options.allowInsecureLoopback === true); if (claimsApiBaseUrl !== apiBaseUrl || claimsIssuer !== apiBaseUrl) { return { ok: false, code: "malformed_claims", message: "Connection server identity did not match the link." }; } const now = options.nowEpochSeconds ?? Math.floor(Date.now() / 1000); if (claims.iat > now + DEFAULT_CLOCK_SKEW_SECONDS) { return { ok: false, code: "not_yet_valid", message: "Connection link is not valid yet." }; } if (claims.exp <= now - DEFAULT_CLOCK_SKEW_SECONDS) { return { ok: false, code: "expired", message: "Connection link expired." }; } const refusedUrl = findRefusedClaimUrl(claims, options.allowInsecureLoopback === true); if (refusedUrl) { return { ok: false, code: "insecure_url", message: `Connection target is not https: ${refusedUrl}` }; } return { ok: true, claims, transport: "exchange", kid: null }; } /** * Maps verified claims to the only local fields the connection handoff may * change. Kept here so both transports share an independently tested boundary. * * @param {import("@openwork/types/connect-link").ConnectLinkClaims} claims */ export function desktopBootstrapFromConnectClaims(claims) { return { baseUrl: claims.den.baseUrl, ...(claims.den.apiBaseUrl ? { apiBaseUrl: claims.den.apiBaseUrl } : {}), requireSignin: claims.requireSignin, brandAppName: claims.brand.appName, ...(claims.brand.logoUrl ? { brandLogoUrl: claims.brand.logoUrl } : {}), ...(claims.brand.iconUrl ? { brandIconUrl: claims.brand.iconUrl } : {}), }; } /** * @param {string} rawUrl * @param {boolean} allowInsecureLoopback */ function normalizeExchangeApiBaseUrl(rawUrl, allowInsecureLoopback) { let parsed; try { parsed = new URL(rawUrl); } catch { return null; } if (parsed.username || parsed.password || parsed.search || parsed.hash) return null; if (parsed.protocol !== "https:" && !(allowInsecureLoopback && parsed.protocol === "http:" && isLoopbackUrl(rawUrl))) { return null; } parsed.pathname = parsed.pathname.replace(/\/+$/, ""); return parsed.toString().replace(/\/$/, ""); } /** * Bounded persisted set of accepted token ids: a connect link reconfigures * the app at most once, so replaying an emailed link later is refused even * inside its validity window. * * @param {{ filePath: string }} options */ export function createConnectLinkReplayGuard(options) { /** @type {string[] | null} */ let cache = null; let writeQueue = Promise.resolve(); async function load() { if (cache) return cache; try { const raw = await readFile(options.filePath, "utf8"); const parsed = JSON.parse(raw); cache = Array.isArray(parsed) ? parsed.filter((entry) => typeof entry === "string") : []; } catch { cache = []; } return cache; } return { /** @param {string} jti */ async has(jti) { const entries = await load(); return entries.includes(jti); }, /** * Atomically consumes a token id within this app process. Returns false * when it was already consumed. Persistence errors reject so callers can * fail closed before mutating bootstrap configuration. * @param {string} jti * @returns {Promise} */ async remember(jti) { const operation = writeQueue.then(async () => { const entries = await load(); if (entries.includes(jti)) return false; entries.push(jti); while (entries.length > REPLAY_GUARD_MAX_ENTRIES) entries.shift(); await mkdir(path.dirname(options.filePath), { recursive: true }); await writeFile(options.filePath, `${JSON.stringify(entries, null, 2)}\n`, "utf8"); return true; }); writeQueue = operation.then(() => undefined, () => undefined); return operation; }, }; }