Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/kwp/services/kwpOAuthChallenge.server.ts
T

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();
}