feat: verify email addresses and recover forgotten passwords
CI / verify (pull_request) Successful in 42s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m42s

Ekklesia could authenticate a person and, since Pass 1, send a
transactional email — but it had no way to establish that an address
belonged to whoever claimed it, and no way back in for someone who
forgot their password.

A verified address is now a precondition for holding a session. Better
Auth enforces that after the password check and before any session
exists, so an unverified sign-in returns 403 with no cookie. That is why
no "unverified principal" was needed: a correct password on an
unverified address carries exactly the authority of no password, and
/api/v1/principal is unchanged.

Better Auth keeps every token. Ekklesia adds no token table, no hashing,
and no expiry clock. It supplies a recipient, a subject, and two bodies,
and the mail provider supplies everything about SMTP.

Verification and reset links are rebuilt from a validated Dashboard
origin, taking only the token — never the URL Better Auth prebuilds from
a caller-supplied callbackURL — so no request can steer where a
recipient lands. Following a verification link mints no session, because
mail infrastructure dereferences links. Reset revokes that person's
other sessions and leaves managed devices alone.

Recovery answers identically for an address with an account, without
one, already verified, or undeliverable — including when SMTP is broken,
which is why the delivery adapter never throws.

The initial platform owner is the one exception: created already
verified by an operator command with no mail prerequisite, so a fresh
server can be signed in to before a relay exists. Migration 0007 extends
that to owners of installations that already exist. Public signup stays
disabled; verification is not a licence to self-register.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
ekklesia
2026-08-25 07:21:17 -05:00
co-authored by Claude Opus 5
parent 3581ad7c8e
commit ea28f8aa2e
52 changed files with 5675 additions and 197 deletions
+106 -1
View File
@@ -2,7 +2,11 @@ import type { ModuleUiImporter } from "@ekklesia/module-ui-host";
import { type ReactElement, useCallback, useEffect, useRef, useState } from "react";
import {
HostAuthError,
type HostAuthLanding,
type HostSignInCredentials,
requestPasswordReset,
resendVerificationEmail,
resetPassword,
signInWithEmail,
signOutHost,
} from "../auth-protocol.js";
@@ -15,8 +19,11 @@ import {
loadHostPrincipal,
} from "../host-protocol.js";
import { DevicePanel } from "./DevicePanel.js";
import { ForgotPasswordForm } from "./ForgotPasswordForm.js";
import { LoginForm } from "./LoginForm.js";
import { ModuleUiFrame } from "./ModuleUiFrame.js";
import { ResetPasswordForm } from "./ResetPasswordForm.js";
import { ResetLinkInvalid, VerificationNotice, VerificationResult } from "./VerificationScreens.js";
type AuthenticatedPrincipal = Extract<
HostBootstrap["principal"],
@@ -37,11 +44,61 @@ type ResolvedBootstrapState = Extract<
{ readonly status: "ready" | "anonymous" | "access-denied" | "failed" }
>;
/**
* Which host-owned screen an unauthenticated visitor sees.
*
* A small explicit union rather than a client-side router: these four screens are the entirety of
* the platform's anonymous surface, and a routing library would be a dependency and an extra URL
* authority for a state machine that fits on a page. Only `reset-password` carries anything, and
* what it carries came from an emailed link the shell has already stripped from the address bar.
*
* None of these states is authenticated, and none can become authenticated: the shell renders the
* Dashboard only after `/api/v1/principal` says so, exactly as before.
*/
type AuthView =
| { readonly kind: "sign-in" }
| { readonly kind: "forgot-password" }
| { readonly kind: "verification-required"; readonly email: string }
| { readonly kind: "verification-result"; readonly outcome: VerificationOutcome }
| { readonly kind: "reset-password"; readonly token: string }
| { readonly kind: "reset-link-invalid" };
type VerificationOutcome =
| { readonly kind: "verified" }
| { readonly kind: "failed"; readonly expired: boolean };
function authViewFromLanding(landing: HostAuthLanding | undefined): AuthView {
if (!landing) {
return { kind: "sign-in" };
}
if (landing.kind === "verified") {
return { kind: "verification-result", outcome: { kind: "verified" } };
}
if (landing.kind === "verification-failed") {
return {
kind: "verification-result",
outcome: { expired: landing.expired, kind: "failed" },
};
}
if (landing.kind === "reset-password") {
return { kind: "reset-password", token: landing.token };
}
return { kind: "reset-link-invalid" };
}
export interface HostAppProps {
/** Must be a stable reference: bootstrap re-runs whenever it changes, so a fresh object literal
* built during render would re-fetch forever. The browser entry point builds it once at module
* scope. */
readonly config: EkklesiaHostConfig;
/**
* What an emailed verification or reset link asked this browser to show, already parsed.
*
* Supplied by the entry point rather than read here, so this component still touches no browser
* global. It only ever selects among the host's own anonymous screens: it can name no destination,
* so there is nothing an open redirect could use.
*/
readonly authLanding?: HostAuthLanding;
/** Test-only seam, forwarded to {@link ModuleUiFrame}. Production composition leaves it unset. */
readonly importModule?: ModuleUiImporter;
}
@@ -201,6 +258,10 @@ export function HostApp(props: HostAppProps): ReactElement {
const generationRef = useRef(0);
const mountedRef = useRef(true);
const { config, importModule } = props;
// Initialized once from the landing the entry point parsed. A later sign-out or a "back to sign
// in" action moves it back to the form; nothing re-reads the URL, so a stale emailed parameter
// cannot resurrect a screen the person has left.
const [authView, setAuthView] = useState<AuthView>(() => authViewFromLanding(props.authLanding));
const beginTransition = useCallback(() => {
activeTransitionRef.current?.abort();
@@ -306,6 +367,10 @@ export function HostApp(props: HostAppProps): ReactElement {
if (!isCurrent(controller, generation)) {
return;
}
// Returning to the anonymous shell always returns to the sign-in form. Without this, signing
// out while a verification or reset screen was the last anonymous view would show that screen
// again, describing an emailed link the person has long since finished with.
setAuthView({ kind: "sign-in" });
setState(
principal.kind === "anonymous" ? { status: "anonymous" } : { status: "sign-out-failed" },
);
@@ -349,6 +414,7 @@ export function HostApp(props: HostAppProps): ReactElement {
transition.controller.abort();
activeTransitionRef.current = undefined;
setSelectedKey(undefined);
setAuthView({ kind: "sign-in" });
setState({ status: "anonymous" });
}
})
@@ -382,7 +448,46 @@ export function HostApp(props: HostAppProps): ReactElement {
return <HostMessage testId="host-loading">Loading Ekklesia…</HostMessage>;
}
if (state.status === "anonymous") {
return <LoginForm onAuthenticate={authenticate} />;
const returnToSignIn = (): void => setAuthView({ kind: "sign-in" });
if (authView.kind === "forgot-password") {
return (
<ForgotPasswordForm
onRequestReset={(email, signal) => requestPasswordReset(config, email, signal)}
onReturnToSignIn={returnToSignIn}
/>
);
}
if (authView.kind === "verification-required") {
return (
<VerificationNotice
email={authView.email}
onResend={(email, signal) => resendVerificationEmail(config, email, signal)}
onReturnToSignIn={returnToSignIn}
/>
);
}
if (authView.kind === "verification-result") {
return <VerificationResult onReturnToSignIn={returnToSignIn} outcome={authView.outcome} />;
}
if (authView.kind === "reset-password") {
return (
<ResetPasswordForm
onResetPassword={(input, signal) => resetPassword(config, input, signal)}
onReturnToSignIn={returnToSignIn}
token={authView.token}
/>
);
}
if (authView.kind === "reset-link-invalid") {
return <ResetLinkInvalid onReturnToSignIn={returnToSignIn} />;
}
return (
<LoginForm
onAuthenticate={authenticate}
onEmailNotVerified={(email) => setAuthView({ email, kind: "verification-required" })}
onForgotPassword={() => setAuthView({ kind: "forgot-password" })}
/>
);
}
if (state.status === "failed") {
return (