Serve surfaces/shell's built Dashboard from apps/api on the same origin as /api (ADR 0044), exactly as the display host is served: a closed set of files read once at startup, never a static directory, never a path built from request input. - The shell exports @ekklesia/shell/dashboard-assets (the exact built index.html and script URLs), emits the script at assets/ekklesia-dashboard/app.js, and index.html names that absolute path so dev and production serve the same URLs. - GET / and GET /assets/ekklesia-dashboard/app.js answer from a frozen snapshot: no-cache with a strong ETag and 304 on match. Source maps are never served; every other path is the ordinary sanitized 404. - The page sets nosniff, X-Frame-Options: DENY, Content-Security-Policy: frame-ancestors 'none' (that directive only), and Referrer-Policy: no-referrer, on 200 and 304 alike. - A missing or empty shell build fails startup with a fixed DashboardHostAssetError before any module code is imported. - The If-None-Match comparison moves to conditional-request.ts, shared with the display host routes. - The emailed-link token stripping moves from browser/main.tsx into consumeAuthLanding (location and history injected) so it is tested; behavior is unchanged. Co-Authored-By: Claude Opus 5.5 <[email protected]>
313 lines
12 KiB
TypeScript
313 lines
12 KiB
TypeScript
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<Record<HostAuthErrorCode, string>> = 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<HostAuthErrorCode, "AUTH_CREDENTIALS_INVALID">,
|
|
signal?: AbortSignal,
|
|
): Promise<Response> {
|
|
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<string | undefined> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Captures what an emailed verification or reset link asked for, then removes it from the address
|
|
* bar.
|
|
*
|
|
* A password-reset token is a bearer credential for one account. Leaving it in `location.search`
|
|
* would put it in the browser's history, in the tab title's shareable URL, and in the `Referer` of
|
|
* anything the page subsequently loaded. `history.replaceState` rewrites the entry in place rather
|
|
* than pushing a new one, so the Back button cannot return to the URL that held it. It is
|
|
* deliberately *not* stored anywhere afterwards — it lives only in React state for the lifetime of
|
|
* the reset form, and never in `localStorage` or `sessionStorage`.
|
|
*
|
|
* The location and history are passed in rather than read here, so this module still reads no
|
|
* global and `browser/main.tsx` remains the shell's one browser-global boundary. Only a recognized
|
|
* Ekklesia landing rewrites the URL; any other query is left exactly as it arrived.
|
|
*/
|
|
export function consumeAuthLanding(
|
|
location: Pick<Location, "pathname" | "search">,
|
|
history: Pick<History, "replaceState">,
|
|
): HostAuthLanding | undefined {
|
|
const landing = readAuthLanding(location.search);
|
|
if (!landing) {
|
|
return undefined;
|
|
}
|
|
try {
|
|
history.replaceState(null, "", location.pathname);
|
|
} catch {
|
|
// A browser that refuses the rewrite (an exotic sandbox, a file: URL) must still be able to
|
|
// finish the flow: the token is already captured, and failing here would strand the person on a
|
|
// page that cannot do anything.
|
|
}
|
|
return landing;
|
|
}
|