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
@@ -3,6 +3,19 @@ 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;
|
||||
@@ -11,12 +24,18 @@ export interface HostSignInCredentials {
|
||||
|
||||
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.",
|
||||
});
|
||||
|
||||
@@ -64,10 +83,33 @@ async function postJson(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
@@ -75,6 +117,9 @@ export async function signInWithEmail(
|
||||
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");
|
||||
}
|
||||
@@ -83,6 +128,90 @@ export async function signInWithEmail(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
@@ -90,3 +219,61 @@ export async function signOutHost(config: EkklesiaHostConfig, signal?: AbortSign
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user