feat: verify email addresses and recover forgotten passwords
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:
co-authored by
Claude Opus 5
parent
3581ad7c8e
commit
ea28f8aa2e
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user