321 lines
12 KiB
TypeScript
321 lines
12 KiB
TypeScript
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
|
|
import { loadRequiredWebConfig, resolveHostOnlyCookiePolicy } from "@hivegrid/config";
|
|
import { isValidKwpContextCode } from "@hivegrid/integrations";
|
|
import type { SharedStore } from "@hivegrid/shared-store";
|
|
|
|
import { getSharedStore } from "~/shared-store.server";
|
|
|
|
export const KWP_OAUTH_CHALLENGE_COOKIE = "__Host-hivegrid_kwp_oauth_challenge";
|
|
export const LOCAL_KWP_OAUTH_CHALLENGE_COOKIE = "hivegrid_kwp_oauth_challenge";
|
|
export const KWP_OAUTH_CHALLENGE_TTL_MS = 10 * 60 * 1_000;
|
|
|
|
const KWP_OAUTH_CHALLENGE_TTL_SECONDS = Math.ceil(KWP_OAUTH_CHALLENGE_TTL_MS / 1_000);
|
|
const KWP_OAUTH_CHALLENGE_FUTURE_SKEW_MS = 60 * 1_000;
|
|
const MAX_COOKIE_VALUE_LENGTH = 4_096;
|
|
const MAX_CONTEXT_ID_LENGTH = 256;
|
|
const MAX_TENANT_SLUG_LENGTH = 128;
|
|
const PAYLOAD_VERSION = "v2";
|
|
const STATE_PATTERN = /^[0-9a-f]{64}$/u;
|
|
const NONCE_PATTERN = /^[0-9a-f]{64}$/u;
|
|
const PKCE_VERIFIER_PATTERN = /^[A-Za-z0-9._~-]{43,128}$/u;
|
|
const PKCE_CHALLENGE_PATTERN = /^[A-Za-z0-9_-]{43}$/u;
|
|
const TENANT_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/u;
|
|
const CONNECTION_VERSION_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u;
|
|
const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/u;
|
|
|
|
const PAYLOAD_KEYS = [
|
|
"tenantId",
|
|
"tenantSlug",
|
|
"userId",
|
|
"connectionId",
|
|
"connectionVersion",
|
|
"customerCode",
|
|
"state",
|
|
"nonce",
|
|
"codeVerifier",
|
|
"codeChallenge",
|
|
"createdAt",
|
|
] as const;
|
|
|
|
export interface KwpOAuthChallengePayload {
|
|
readonly tenantId: string;
|
|
readonly tenantSlug: string;
|
|
readonly userId: string;
|
|
readonly connectionId: string;
|
|
/** Exact DataConnection.updatedAt value used as an optimistic callback fence. */
|
|
readonly connectionVersion: string;
|
|
readonly customerCode: string;
|
|
readonly state: string;
|
|
readonly nonce: string;
|
|
readonly codeVerifier: string;
|
|
readonly codeChallenge: string;
|
|
/** Epoch milliseconds. */
|
|
readonly createdAt: number;
|
|
}
|
|
|
|
export type ConsumeKwpOAuthChallengeResult =
|
|
| { readonly ok: true; readonly payload: KwpOAuthChallengePayload; readonly clearCookie: string }
|
|
| {
|
|
readonly ok: false;
|
|
readonly reason: "missing" | "malformed" | "bad-signature" | "expired" | "state-mismatch" | "replayed" | "store-unavailable";
|
|
readonly clearCookie: string;
|
|
};
|
|
|
|
type CreateKwpOAuthChallengeOptions = {
|
|
readonly now?: () => number;
|
|
readonly secret?: string;
|
|
};
|
|
|
|
type ConsumeKwpOAuthChallengeOptions = {
|
|
readonly now?: () => number;
|
|
readonly secret?: string;
|
|
};
|
|
|
|
type KwpOAuthChallengeStore = Pick<SharedStore, "set">;
|
|
|
|
let storeFactory: () => KwpOAuthChallengeStore = () => getSharedStore();
|
|
|
|
/** Create a fresh state, nonce, and RFC 7636 PKCE-S256 tuple. */
|
|
export function createKwpOAuthChallenge(
|
|
input: Pick<KwpOAuthChallengePayload, "tenantId" | "tenantSlug" | "userId" | "connectionId" | "connectionVersion" | "customerCode">,
|
|
options: CreateKwpOAuthChallengeOptions = {},
|
|
): { readonly payload: KwpOAuthChallengePayload; readonly cookie: string } {
|
|
const codeVerifier = randomBytes(32).toString("base64url");
|
|
const payload: KwpOAuthChallengePayload = {
|
|
...input,
|
|
state: randomBytes(32).toString("hex"),
|
|
nonce: randomBytes(32).toString("hex"),
|
|
codeVerifier,
|
|
codeChallenge: derivePkceS256Challenge(codeVerifier),
|
|
createdAt: options.now ? options.now() : Date.now(),
|
|
};
|
|
|
|
if (!isKwpOAuthChallengePayload(payload)) {
|
|
throw new TypeError("KWP OAuth challenge input is invalid");
|
|
}
|
|
|
|
return {
|
|
payload,
|
|
cookie: serializeKwpOAuthChallengeCookie(payload, options.secret),
|
|
};
|
|
}
|
|
|
|
/** Serialize a signed, host-only challenge cookie. The payload is not encrypted. */
|
|
export function serializeKwpOAuthChallengeCookie(payload: KwpOAuthChallengePayload, secretOverride?: string): string {
|
|
if (!isKwpOAuthChallengePayload(payload)) {
|
|
throw new TypeError("KWP OAuth challenge payload is invalid");
|
|
}
|
|
const body = Buffer.from(JSON.stringify({ v: PAYLOAD_VERSION, p: payload }), "utf8").toString("base64url");
|
|
const value = `${body}.${sign(body, signingSecret(secretOverride))}`;
|
|
return serializeCookie(value, KWP_OAUTH_CHALLENGE_TTL_SECONDS);
|
|
}
|
|
|
|
/** Clear whichever host-only cookie name is valid for the active environment. */
|
|
export function clearKwpOAuthChallengeCookie(): string {
|
|
return serializeCookie("", 0);
|
|
}
|
|
|
|
/**
|
|
* Verify the signed cookie and returned state, then claim its nonce exactly
|
|
* once across replicas. Every result includes a cookie deletion header.
|
|
*/
|
|
export async function consumeKwpOAuthChallenge(
|
|
request: Request,
|
|
returnedState: string | null | undefined,
|
|
options: ConsumeKwpOAuthChallengeOptions = {},
|
|
): Promise<ConsumeKwpOAuthChallengeResult> {
|
|
const clearCookie = clearKwpOAuthChallengeCookie();
|
|
const failed = (reason: Exclude<ConsumeKwpOAuthChallengeResult, { ok: true }>["reason"]): ConsumeKwpOAuthChallengeResult => ({
|
|
ok: false,
|
|
reason,
|
|
clearCookie,
|
|
});
|
|
|
|
const cookie = readCookieValue(request, cookiePolicy().name);
|
|
if (cookie.status !== "found") return failed(cookie.status);
|
|
if (cookie.value.length > MAX_COOKIE_VALUE_LENGTH) return failed("malformed");
|
|
|
|
const separator = cookie.value.lastIndexOf(".");
|
|
if (separator <= 0 || separator === cookie.value.length - 1) return failed("malformed");
|
|
const body = cookie.value.slice(0, separator);
|
|
const receivedSignature = cookie.value.slice(separator + 1);
|
|
if (!/^[A-Za-z0-9_-]+$/u.test(body) || !PKCE_CHALLENGE_PATTERN.test(receivedSignature)) return failed("malformed");
|
|
|
|
let expectedSignature: string;
|
|
try {
|
|
expectedSignature = sign(body, signingSecret(options.secret));
|
|
} catch {
|
|
return failed("malformed");
|
|
}
|
|
if (!safeStringEqual(receivedSignature, expectedSignature)) return failed("bad-signature");
|
|
|
|
let decoded: unknown;
|
|
try {
|
|
decoded = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
|
|
} catch {
|
|
return failed("malformed");
|
|
}
|
|
if (!isChallengeEnvelope(decoded)) return failed("malformed");
|
|
const payload = decoded.p;
|
|
|
|
const now = options.now ? options.now() : Date.now();
|
|
const age = now - payload.createdAt;
|
|
if (age < -KWP_OAUTH_CHALLENGE_FUTURE_SKEW_MS) return failed("malformed");
|
|
if (age >= KWP_OAUTH_CHALLENGE_TTL_MS) return failed("expired");
|
|
|
|
if (!STATE_PATTERN.test(returnedState ?? "") || !safeStringEqual(returnedState ?? "", payload.state)) {
|
|
return failed("state-mismatch");
|
|
}
|
|
|
|
let claimed: boolean;
|
|
try {
|
|
claimed = await storeFactory().set(nonceClaimKey(payload.nonce), stateFingerprint(payload.state), {
|
|
nx: true,
|
|
ttlSeconds: KWP_OAUTH_CHALLENGE_TTL_SECONDS,
|
|
});
|
|
} catch {
|
|
return failed("store-unavailable");
|
|
}
|
|
if (!claimed) return failed("replayed");
|
|
|
|
return { ok: true, payload, clearCookie };
|
|
}
|
|
|
|
function derivePkceS256Challenge(codeVerifier: string): string {
|
|
return createHash("sha256").update(codeVerifier, "ascii").digest("base64url");
|
|
}
|
|
|
|
function signingSecret(override?: string): string {
|
|
if (override) return override;
|
|
const { cryptoSecret, sessionSecret } = loadRequiredWebConfig();
|
|
const secret = cryptoSecret || sessionSecret;
|
|
if (!secret) throw new Error("CRYPTO_SECRET or SESSION_SECRET is required for KWP OAuth challenges");
|
|
return secret;
|
|
}
|
|
|
|
function sign(body: string, secret: string): string {
|
|
return createHmac("sha256", secret).update(body).digest("base64url");
|
|
}
|
|
|
|
function stateFingerprint(state: string): string {
|
|
return createHash("sha256").update(state, "ascii").digest("hex");
|
|
}
|
|
|
|
function nonceClaimKey(nonce: string): string {
|
|
const fingerprint = createHash("sha256").update(nonce, "ascii").digest("hex");
|
|
return `oauth:kwp:nonce:${fingerprint}`;
|
|
}
|
|
|
|
function safeStringEqual(actual: string, expected: string): boolean {
|
|
const actualBytes = Buffer.from(actual, "utf8");
|
|
const expectedBytes = Buffer.from(expected, "utf8");
|
|
return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes);
|
|
}
|
|
|
|
function cookiePolicy() {
|
|
const webConfig = loadRequiredWebConfig();
|
|
return resolveHostOnlyCookiePolicy({
|
|
nodeEnv: webConfig.nodeEnv,
|
|
routing: webConfig.routing,
|
|
secureName: KWP_OAUTH_CHALLENGE_COOKIE,
|
|
localName: LOCAL_KWP_OAUTH_CHALLENGE_COOKIE,
|
|
maxAge: KWP_OAUTH_CHALLENGE_TTL_SECONDS,
|
|
});
|
|
}
|
|
|
|
function serializeCookie(value: string, maxAge: number): string {
|
|
const policy = cookiePolicy();
|
|
const attributes = [`${policy.name}=${value}`, `Path=${policy.path}`, "HttpOnly", `SameSite=${capitalize(policy.sameSite)}`, `Max-Age=${maxAge}`];
|
|
if (policy.secure) attributes.push("Secure");
|
|
return attributes.join("; ");
|
|
}
|
|
|
|
function capitalize(value: string): string {
|
|
return `${value.slice(0, 1).toUpperCase()}${value.slice(1)}`;
|
|
}
|
|
|
|
function readCookieValue(request: Request, name: string):
|
|
| { readonly status: "found"; readonly value: string }
|
|
| { readonly status: "missing" | "malformed" } {
|
|
const header = request.headers.get("cookie");
|
|
if (!header) return { status: "missing" };
|
|
const matches: string[] = [];
|
|
for (const part of header.split(/;\s*/u)) {
|
|
const separator = part.indexOf("=");
|
|
if (separator < 0) continue;
|
|
if (part.slice(0, separator).trim() === name) matches.push(part.slice(separator + 1).trim());
|
|
}
|
|
if (matches.length === 0) return { status: "missing" };
|
|
if (matches.length !== 1 || !matches[0]) return { status: "malformed" };
|
|
return { status: "found", value: matches[0] };
|
|
}
|
|
|
|
function isChallengeEnvelope(value: unknown): value is { readonly v: typeof PAYLOAD_VERSION; readonly p: KwpOAuthChallengePayload } {
|
|
if (!isRecord(value) || !hasExactKeys(value, ["v", "p"])) return false;
|
|
return value.v === PAYLOAD_VERSION && isKwpOAuthChallengePayload(value.p);
|
|
}
|
|
|
|
function isKwpOAuthChallengePayload(value: unknown): value is KwpOAuthChallengePayload {
|
|
if (!isRecord(value) || !hasExactKeys(value, PAYLOAD_KEYS)) return false;
|
|
const codeVerifier = value.codeVerifier;
|
|
const codeChallenge = value.codeChallenge;
|
|
if (
|
|
!boundedContextString(value.tenantId, MAX_CONTEXT_ID_LENGTH) ||
|
|
!boundedPatternString(value.tenantSlug, MAX_TENANT_SLUG_LENGTH, TENANT_SLUG_PATTERN) ||
|
|
!boundedContextString(value.userId, MAX_CONTEXT_ID_LENGTH) ||
|
|
!boundedContextString(value.connectionId, MAX_CONTEXT_ID_LENGTH) ||
|
|
!isCanonicalConnectionVersion(value.connectionVersion) ||
|
|
!isValidKwpContextCode(value.customerCode) ||
|
|
!STATE_PATTERN.test(typeof value.state === "string" ? value.state : "") ||
|
|
!NONCE_PATTERN.test(typeof value.nonce === "string" ? value.nonce : "") ||
|
|
typeof codeVerifier !== "string" ||
|
|
!PKCE_VERIFIER_PATTERN.test(codeVerifier) ||
|
|
typeof codeChallenge !== "string" ||
|
|
!PKCE_CHALLENGE_PATTERN.test(codeChallenge) ||
|
|
typeof value.createdAt !== "number" ||
|
|
!Number.isSafeInteger(value.createdAt) ||
|
|
value.createdAt < 0
|
|
) {
|
|
return false;
|
|
}
|
|
return safeStringEqual(derivePkceS256Challenge(codeVerifier), codeChallenge);
|
|
}
|
|
|
|
function isCanonicalConnectionVersion(value: unknown): value is string {
|
|
if (typeof value !== "string" || !CONNECTION_VERSION_PATTERN.test(value)) return false;
|
|
const timestamp = Date.parse(value);
|
|
return Number.isFinite(timestamp) && new Date(timestamp).toISOString() === value;
|
|
}
|
|
|
|
function boundedContextString(value: unknown, maxLength: number): value is string {
|
|
return (
|
|
typeof value === "string" &&
|
|
value.length > 0 &&
|
|
value.length <= maxLength &&
|
|
value.trim() === value &&
|
|
!CONTROL_CHARACTER_PATTERN.test(value)
|
|
);
|
|
}
|
|
|
|
function boundedPatternString(value: unknown, maxLength: number, pattern: RegExp): value is string {
|
|
return boundedContextString(value, maxLength) && pattern.test(value);
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function hasExactKeys(value: Record<string, unknown>, expected: readonly string[]): boolean {
|
|
const keys = Object.keys(value);
|
|
return keys.length === expected.length && expected.every((key) => Object.hasOwn(value, key));
|
|
}
|
|
|
|
export function __setKwpOAuthChallengeStoreForTests(store: KwpOAuthChallengeStore): void {
|
|
storeFactory = () => store;
|
|
}
|
|
|
|
export function __resetKwpOAuthChallengeStoreForTests(): void {
|
|
storeFactory = () => getSharedStore();
|
|
}
|