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
@@ -0,0 +1,182 @@
import { type ReactElement, useState } from "react";
import { AuthCard, authStyles, useSingleSubmission } from "./AuthCard.js";
export interface VerificationNoticeProps {
/**
* The address the person just tried to sign in with, when this notice followed a sign-in attempt.
*
* Present only because they typed it moments ago: it is never learned from the server, and the
* resend it enables reveals nothing, because the server answers identically for an address with no
* account (ADR 0038).
*/
readonly email?: string;
readonly onResend: (email: string, signal: AbortSignal) => Promise<void>;
readonly onReturnToSignIn: () => void;
}
/** Shown after a correct password on an address that has not been verified. No session exists at
* this point — the identity system refused to create one — so nothing here is authenticated. */
export function VerificationNotice(props: VerificationNoticeProps): ReactElement {
const [email, setEmail] = useState(props.email ?? "");
const [sent, setSent] = useState(false);
const { errorMessage, pending, submit } = useSingleSubmission(
"Unable to send a verification email right now.",
);
return (
<AuthCard
heading="Verify your email"
headingId="ekklesia-verify-heading"
testId="host-verification-required"
>
<p style={authStyles.introduction}>
Your password was accepted, but this address has not been confirmed yet. Open the
verification link we sent you, then sign in again.
</p>
{sent ? (
<p data-testid="verification-resent" style={authStyles.introduction}>
If an unverified Ekklesia account exists for that address, a new verification link is on
its way. It expires in one hour.
</p>
) : (
<form
aria-busy={pending}
onSubmit={(event) =>
void submit(event, async (signal) => {
await props.onResend(email, signal);
setSent(true);
})
}
style={authStyles.form}
>
<label style={authStyles.field}>
<span style={authStyles.label}>Email</span>
<input
aria-describedby={errorMessage ? "ekklesia-verify-error" : undefined}
autoComplete="username"
data-testid="verification-email"
disabled={pending}
inputMode="email"
name="email"
onChange={(event) => setEmail(event.currentTarget.value)}
required
style={authStyles.input}
type="email"
value={email}
/>
</label>
{errorMessage ? (
<p id="ekklesia-verify-error" role="alert" style={authStyles.error}>
{errorMessage}
</p>
) : undefined}
<button
data-testid="verification-resend"
disabled={pending}
style={authStyles.button}
type="submit"
>
{pending ? "Sending…" : "Send a new verification link"}
</button>
</form>
)}
<button
data-testid="verification-return-to-sign-in"
onClick={props.onReturnToSignIn}
style={authStyles.secondaryAction}
type="button"
>
Back to sign in
</button>
</AuthCard>
);
}
export interface VerificationResultProps {
readonly outcome:
| { readonly kind: "verified" }
| { readonly kind: "failed"; readonly expired: boolean };
readonly onReturnToSignIn: () => void;
}
/**
* Where an emailed verification link lands.
*
* The identity system has already acted by the time this renders — it verified the address and
* redirected here, or refused and redirected here with a reason. Nothing on this screen is
* authenticated: following a link deliberately does not mint a session (ADR 0038), so the only
* offer is to go and sign in.
*/
export function VerificationResult(props: VerificationResultProps): ReactElement {
if (props.outcome.kind === "verified") {
return (
<AuthCard
heading="Email verified"
headingId="ekklesia-verified-heading"
testId="host-verification-succeeded"
>
<p style={authStyles.introduction}>
This address is confirmed. Sign in to Ekklesia to continue.
</p>
<button
data-testid="verified-continue"
onClick={props.onReturnToSignIn}
style={authStyles.button}
type="button"
>
Go to sign in
</button>
</AuthCard>
);
}
return (
<AuthCard
heading={props.outcome.expired ? "This link has expired" : "This link is not valid"}
headingId="ekklesia-verify-failed-heading"
testId="host-verification-failed"
>
<p style={authStyles.introduction}>
{props.outcome.expired
? "Verification links last one hour. Sign in with your password and Ekklesia will send a new one."
: "This verification link could not be used. Sign in with your password and Ekklesia will send a new one."}
</p>
<button
data-testid="verification-failed-continue"
onClick={props.onReturnToSignIn}
style={authStyles.button}
type="button"
>
Go to sign in
</button>
</AuthCard>
);
}
export interface ResetLinkInvalidProps {
readonly onReturnToSignIn: () => void;
}
/** Where an expired or malformed password-reset link lands. It offers the recovery form again
* rather than a dead end, and says nothing about whether an account exists. */
export function ResetLinkInvalid(props: ResetLinkInvalidProps): ReactElement {
return (
<AuthCard
heading="This reset link is not valid"
headingId="ekklesia-reset-invalid-heading"
testId="host-reset-link-invalid"
>
<p style={authStyles.introduction}>
Password reset links last one hour and can be used once. Request a new one from the sign-in
page.
</p>
<button
data-testid="reset-invalid-continue"
onClick={props.onReturnToSignIn}
style={authStyles.button}
type="button"
>
Go to sign in
</button>
</AuthCard>
);
}