import type { EkklesiaHostConfig } from "./host-protocol.js"; import { resolveApiUrl } from "./host-protocol.js"; const SIGN_IN_PATH = "/api/auth/sign-in/email"; const SIGN_OUT_PATH = "/api/auth/sign-out"; const REQUEST_PASSWORD_RESET_PATH = "/api/auth/request-password-reset"; const RESET_PASSWORD_PATH = "/api/auth/reset-password"; const SEND_VERIFICATION_EMAIL_PATH = "/api/auth/send-verification-email"; /** * The identity system's own password bounds, mirrored here for immediate form feedback only. * * The server re-checks both from the same configuration and remains authoritative; this exists so * someone typing a five-character password is told before a round trip, not so the browser decides * anything. */ export const HOST_PASSWORD_MIN_LENGTH = 8; export const HOST_PASSWORD_MAX_LENGTH = 128; export interface HostSignInCredentials { readonly email: string; readonly password: string; } export type HostAuthErrorCode = | "AUTH_CREDENTIALS_INVALID" | "AUTH_EMAIL_NOT_VERIFIED" | "AUTH_REQUEST_FAILED" | "AUTH_RESET_TOKEN_INVALID" | "AUTH_PASSWORD_INVALID" | "AUTH_SIGN_OUT_FAILED"; const errorMessages: Readonly> = Object.freeze({ AUTH_CREDENTIALS_INVALID: "Email or password is incorrect.", AUTH_EMAIL_NOT_VERIFIED: "Verify your email address before signing in.", AUTH_PASSWORD_INVALID: `Choose a password between ${HOST_PASSWORD_MIN_LENGTH} and ${HOST_PASSWORD_MAX_LENGTH} characters.`, AUTH_REQUEST_FAILED: "Unable to sign in right now.", AUTH_RESET_TOKEN_INVALID: "This password reset link is no longer valid. Request a new one.", AUTH_SIGN_OUT_FAILED: "Unable to sign out right now.", }); /** * A browser-safe authentication failure. It deliberately retains neither a response body nor the * submitted credentials, a request URL, a Better Auth error, or a caught transport exception. */ export class HostAuthError extends Error { readonly code: HostAuthErrorCode; constructor(code: HostAuthErrorCode) { super(errorMessages[code]); this.name = "HostAuthError"; this.code = code; this.stack = `${this.name}: ${this.message}`; Object.freeze(this); } } async function postJson( config: EkklesiaHostConfig, path: string, body: unknown, failureCode: Exclude, signal?: AbortSignal, ): Promise { const performRequest = config.fetch ?? fetch; try { return await performRequest(resolveApiUrl(config, path), { body: JSON.stringify(body), cache: "no-store", credentials: "include", headers: { accept: "application/json", "content-type": "application/json", }, method: "POST", redirect: "error", ...(signal ? { signal } : {}), }); } catch { // The thrown value may contain a URL or implementation detail. More importantly, this helper // must never attach the request body (and therefore the password) as an error cause. throw new HostAuthError(failureCode); } } /** * Reads the identity system's own error code out of a failed response. * * Only the `code` field is ever taken, and only to choose between this module's own fixed messages — * the response body itself is never surfaced, so a future change to the identity system's prose can * never leak into the interface. A body that is missing, malformed, or not JSON simply yields * `undefined` and the caller falls back to its generic failure. */ async function failureCode(response: Response): Promise { try { const body = (await response.json()) as { readonly code?: unknown } | null; return typeof body?.code === "string" ? body.code : undefined; } catch { return undefined; } } /** * Uses Ekklesia's existing Better Auth transport without exposing Better Auth to browser packages. * Success means only that the mutation succeeded; the shell must resolve `/api/v1/principal` before * it renders authenticated content rather than trusting a response token or user object. * * A correct password on an unverified address is **not** a success and mints no session — the * identity system refuses it before creating one (ADR 0038). It is reported as its own error so the * shell can offer verification rather than claiming the password was wrong, which would be both * untrue and unactionable. Reaching that state already required the correct password, so it reveals * nothing an attacker did not supply. */ export async function signInWithEmail( config: EkklesiaHostConfig, credentials: HostSignInCredentials, signal?: AbortSignal, ): Promise { const response = await postJson(config, SIGN_IN_PATH, credentials, "AUTH_REQUEST_FAILED", signal); if (response.status === 403 && (await failureCode(response)) === "EMAIL_NOT_VERIFIED") { throw new HostAuthError("AUTH_EMAIL_NOT_VERIFIED"); } if (response.status === 400 || response.status === 401) { throw new HostAuthError("AUTH_CREDENTIALS_INVALID"); } if (!response.ok) { throw new HostAuthError("AUTH_REQUEST_FAILED"); } } /** * Asks for a password-reset message. * * Resolves for **every** address the identity system accepts as syntactically valid, whether or not * an account exists, whether or not that account is verified, and whether or not the message could * actually be delivered. That is the whole point: the caller has no way to tell the cases apart, so * neither does anyone using the form. The identity system answers the same way in each case, and a * delivery failure is reported to the operator out of band instead (ADR 0038). * * A transport failure or a rate-limit refusal is still surfaced — those describe this request, not * the existence of an account. */ export async function requestPasswordReset( config: EkklesiaHostConfig, email: string, signal?: AbortSignal, ): Promise { const response = await postJson( config, REQUEST_PASSWORD_RESET_PATH, { email }, "AUTH_REQUEST_FAILED", signal, ); if (!response.ok) { throw new HostAuthError("AUTH_REQUEST_FAILED"); } } /** * Completes a password reset with the token from an emailed link. * * The token travels in the request body, never in a URL this function builds, and is not retained * afterwards. The new password is likewise only ever a body field. */ export async function resetPassword( config: EkklesiaHostConfig, input: { readonly token: string; readonly newPassword: string }, signal?: AbortSignal, ): Promise { const response = await postJson( config, RESET_PASSWORD_PATH, { newPassword: input.newPassword, token: input.token }, "AUTH_REQUEST_FAILED", signal, ); if (response.ok) { return; } const code = await failureCode(response); if (code === "PASSWORD_TOO_SHORT" || code === "PASSWORD_TOO_LONG") { throw new HostAuthError("AUTH_PASSWORD_INVALID"); } if (code === "INVALID_TOKEN") { throw new HostAuthError("AUTH_RESET_TOKEN_INVALID"); } throw new HostAuthError("AUTH_REQUEST_FAILED"); } /** * Asks for a fresh verification message. * * Enumeration-safe in exactly the same way as {@link requestPasswordReset}: the identity system * answers identically for an unknown address, an already-verified one, and an unverified one, and * this function does not distinguish them either. */ export async function resendVerificationEmail( config: EkklesiaHostConfig, email: string, signal?: AbortSignal, ): Promise { const response = await postJson( config, SEND_VERIFICATION_EMAIL_PATH, { email }, "AUTH_REQUEST_FAILED", signal, ); if (!response.ok) { throw new HostAuthError("AUTH_REQUEST_FAILED"); } } /** Invalidates the server session. Hiding the shell without this successful mutation is not logout. */ export async function signOutHost(config: EkklesiaHostConfig, signal?: AbortSignal): Promise { const response = await postJson(config, SIGN_OUT_PATH, {}, "AUTH_SIGN_OUT_FAILED", signal); if (!response.ok) { throw new HostAuthError("AUTH_SIGN_OUT_FAILED"); } } /** * The two query values Ekklesia's own emailed links carry back to the Dashboard. * * Kept in step with `core/src/identity/notifications.ts`, which builds the links. They are query * parameters on the Dashboard root rather than distinct paths because the shell is a static bundle: * a deep path would oblige every deployment's static host to rewrite unknown paths to `index.html` * before an emailed link resolved at all. */ const AUTH_LANDING_PARAMETER = "ekklesia_auth"; const VERIFY_EMAIL_LANDING = "verify-email"; const RESET_PASSWORD_LANDING = "reset-password"; /** What an emailed link asked this browser to show. */ export type HostAuthLanding = | { readonly kind: "verified" } | { readonly kind: "verification-failed"; readonly expired: boolean } | { readonly kind: "reset-password"; readonly token: string } | { readonly kind: "reset-link-invalid" }; /** A reset token is opaque to this shell, but it still has to end up in a URL and a request body, so * anything that is not a plain bounded token is treated as no token at all. */ const RESET_TOKEN_PATTERN = /^[\w-]{1,256}$/u; /** * Classifies the current location's query into a host-owned auth screen. * * A pure function of a query string: it reads no global, so the shell's one browser-global boundary * stays in `browser/main.tsx`. It recognizes **only** Ekklesia's own two landings and ignores * everything else, so an arbitrary query cannot steer the shell into an auth state — and it produces * no URL and no destination of any kind, so there is nothing here an open redirect could ride on. */ export function readAuthLanding(search: string): HostAuthLanding | undefined { let parameters: URLSearchParams; try { parameters = new URLSearchParams(search); } catch { return undefined; } const landing = parameters.get(AUTH_LANDING_PARAMETER); const error = parameters.get("error"); if (landing === VERIFY_EMAIL_LANDING) { if (error !== null) { // Better Auth distinguishes an expired token from a malformed or unknown one, and so does // this: "your link expired, here is how to get another" is actionable where "invalid" is not. return { expired: error === "TOKEN_EXPIRED", kind: "verification-failed" }; } return { kind: "verified" }; } if (landing === RESET_PASSWORD_LANDING) { const token = parameters.get("token"); if (error !== null || token === null || !RESET_TOKEN_PATTERN.test(token)) { return { kind: "reset-link-invalid" }; } return { kind: "reset-password", token }; } return undefined; }