Files
openwork/apps/desktop/electron/connect-link.mjs
T

498 lines
18 KiB
JavaScript

// 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<string, unknown>} */ (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<string, unknown>} */ (org);
const brandRecord = /** @type {Record<string, unknown>} */ (brand);
const denRecord = /** @type {Record<string, unknown>} */ (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<string, string>,
* 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<string, string>,
* 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<Response>,
* nowEpochSeconds?: number,
* allowInsecureLoopback?: boolean,
* }} options
* @returns {Promise<import("@openwork/types/connect-link").ConnectLinkVerifyResult>}
*/
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<boolean>}
*/
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;
},
};
}