import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import type { InstallationConfig } from "../configuration/customer.ts"; import type { Secret } from "../configuration/secrets.ts"; export const SESSION_COOKIE = "__Host-flarebot-session"; const MAX_SESSION_SECONDS = 8 * 60 * 60; const encoder = new TextEncoder(); interface OwnerSession { version: 1; subject: string; installationId: string; audience: string; issuedAt: number; expiresAt: number; } function encode(bytes: Uint8Array): string { return btoa(String.fromCharCode(...bytes)) .replaceAll("+", "-") .replaceAll("/", "_") .replace(/=+$/, ""); } function decode(value: string): Uint8Array { if (!/^[A-Za-z0-9_-]+$/.test(value)) throw new Error("Invalid encoding"); return Uint8Array.from( atob(value.replaceAll("-", "+").replaceAll("_", "/")), (c) => c.charCodeAt(0), ); } class InvalidSession extends Data.TaggedError("InvalidSession") {} function key(secret: Secret) { return Effect.tryPromise({ try: () => crypto.subtle.importKey( "raw", encoder.encode(secret.reveal()), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"], ), catch: () => new InvalidSession(), }); } // Server-only seam for the future verified OAuth bridge. The caller must first // establish ownership; this function is never exposed as an HTTP/RPC login. export function createOwnerSession( secret: Secret, installation: InstallationConfig, ) { return Effect.gen(function* () { const issuedAt = Math.floor(Date.now() / 1000); const payload: OwnerSession = { version: 1, subject: installation.ownerSubject, installationId: installation.installationId, audience: installation.runtimeOrigin, issuedAt, expiresAt: issuedAt + MAX_SESSION_SECONDS, }; const body = encode(encoder.encode(JSON.stringify(payload))); const signingKey = yield* key(secret); const signature = yield* Effect.promise(() => crypto.subtle.sign("HMAC", signingKey, encoder.encode(body)), ); return `${SESSION_COOKIE}=${body}.${encode(new Uint8Array(signature))}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${MAX_SESSION_SECONDS}`; }); } export function verifyOwnerSession( request: Request, secret: Secret, installation: InstallationConfig, ): Effect.Effect { return Effect.gen(function* () { const cookies = (request.headers.get("Cookie") ?? "") .split(";") .map((cookie) => cookie.trim()) .filter((cookie) => cookie.startsWith(`${SESSION_COOKIE}=`)); if (cookies.length !== 1) return null; const token = cookies[0].slice(SESSION_COOKIE.length + 1); if (token.length > 4096) return null; const parts = token.split("."); if (parts.length !== 2) return null; const [body, signature] = parts; const verificationKey = yield* key(secret); const signatureBytes = yield* Effect.try({ try: () => decode(signature), catch: () => new InvalidSession(), }); const verified = yield* Effect.tryPromise({ try: () => crypto.subtle.verify( "HMAC", verificationKey, signatureBytes, encoder.encode(body), ), catch: () => new InvalidSession(), }); if (!verified) return null; const session: OwnerSession = yield* Effect.try({ try: () => JSON.parse(new TextDecoder().decode(decode(body))), catch: () => new InvalidSession(), }); const now = Math.floor(Date.now() / 1000); if ( !session || session.version !== 1 || session.subject !== installation.ownerSubject || session.installationId !== installation.installationId || session.audience !== new URL(request.url).origin || !Number.isSafeInteger(session.issuedAt) || !Number.isSafeInteger(session.expiresAt) || session.issuedAt > now || session.expiresAt <= now || session.expiresAt <= session.issuedAt || session.expiresAt - session.issuedAt > MAX_SESSION_SECONDS ) return null; return session; }).pipe(Effect.catchTag("InvalidSession", () => Effect.succeed(null))); } export function privateResponse(message: string, status: number): Response { return new Response(message, { status, headers: { "Cache-Control": "no-store" }, }); } export function authorizeRuntimeRequest( request: Request, secret: Secret, installation: InstallationConfig, ) { return Effect.gen(function* () { if (new URL(request.url).origin !== installation.runtimeOrigin) return privateResponse("Forbidden", 403); const origin = request.headers.get("Origin"); const needsOrigin = request.headers.get("Upgrade")?.toLowerCase() === "websocket" || !["GET", "HEAD"].includes(request.method); if ( (needsOrigin || origin !== null) && origin !== installation.runtimeOrigin ) return privateResponse("Forbidden", 403); return (yield* verifyOwnerSession(request, secret, installation)) ? null : privateResponse("Authentication required", 401); }); }