feat: verify email addresses and recover forgotten passwords #38

Merged
Zayin_Admin merged 1 commits from phase-7c/email-verification-recovery into main 2026-08-25 23:45:25 +00:00
52 changed files with 5675 additions and 197 deletions
Showing only changes of commit ea28f8aa2e - Show all commits
+15
View File
@@ -81,3 +81,18 @@ S3_BUCKET=ekklesia-development
# EKKLESIA_SMTP_PASSWORD= # EKKLESIA_SMTP_PASSWORD=
# [email protected] # [email protected]
# EKKLESIA_SMTP_FROM_NAME=Ekklesia # EKKLESIA_SMTP_FROM_NAME=Ekklesia
# Where an emailed verification or password-reset link sends a browser (ADR 0038).
#
# Absent means the Dashboard is served from EKKLESIA_IDENTITY_BASE_URL, which is the ordinary
# same-origin production shape. Set it only when a deployment separates the two, for example an API
# at https://api.example.org and the Dashboard at https://dash.example.org. It must also appear in
# EKKLESIA_IDENTITY_TRUSTED_ORIGINS, or the identity system refuses the emailed link — Ekklesia
# checks that at startup rather than letting every recipient discover it.
#
# This is the authenticated Dashboard host, never the public church website.
# EKKLESIA_DASHBOARD_ORIGIN=https://dash.example.org
#
# Verification and password recovery need a working relay above. The initial platform-owner
# bootstrap (pnpm identity:create-initial-owner) deliberately does not: it creates that one account
# already verified, so a fresh server can be signed in to before any mail exists.
+28 -8
View File
@@ -52,7 +52,8 @@ order never grants elevation — but is now **disabled by default, and cannot be
production at all** until durable abuse controls exist (Phase 5D pass 1, below). See production at all** until durable abuse controls exist (Phase 5D pass 1, below). See
[API](docs/development/api.md) and [API](docs/development/api.md) and
[ADR 0011](docs/architecture/decisions/0011-identity-http-transport-and-public-enrollment.md). [ADR 0011](docs/architecture/decisions/0011-identity-http-transport-and-public-enrollment.md).
Email verification, password recovery, and invitation/operator enrollment still do not exist. Email verification and password recovery now exist (Phase 7C pass 2, below); invitation and operator
enrollment still do not.
Public registration remains disabled by default and prohibited in production — and a fresh Public registration remains disabled by default and prohibited in production — and a fresh
production installation now has a supported way to get its first administrator anyway: Phase 7B production installation now has a supported way to get its first administrator anyway: Phase 7B
pass 1B's `pnpm identity:create-initial-owner` (below). The Dashboard now has a minimal authenticated shell and login/logout flow, pass 1B's `pnpm identity:create-initial-owner` (below). The Dashboard now has a minimal authenticated shell and login/logout flow,
@@ -719,12 +720,31 @@ ambiguous, and retrying it would send someone several copies of the same reset e
single bounded attempt and deliberately not an exactly-once promise. There is **no single bounded attempt and deliberately not an exactly-once promise. There is **no
`POST /api/v1/send-email`** and no route that touches the provider. `POST /api/v1/send-email`** and no route that touches the provider.
Email remains **optional**: an installation with no SMTP configured is a complete deployment, since Email remains **optional**: an installation with no SMTP configured is a complete deployment.
nothing sends mail yet. `pnpm doctor` reports the configuration and can probe connectivity **without `pnpm doctor` reports the configuration and can probe connectivity **without sending anything**,
sending anything**, while stating plainly that a successful connection does not establish while stating plainly that a successful connection does not establish deliverability. See
deliverability. **Email verification, password reset, and invitations are Pass 2** — Better Auth's [Outbound email](docs/development/email.md).
`sendResetPassword` and `sendVerificationEmail` hooks are still unimplemented, and this transport is
the seam they will use. See [Outbound email](docs/development/email.md). Phase 7C pass 2 completes the human identity lifecycle over that transport
([ADR 0038](docs/architecture/decisions/0038-email-verification-and-password-recovery.md)). **A
verified email address is a precondition for holding a session**: the identity system refuses an
unverified sign-in after checking the password and before creating one, so a correct password on an
unverified address carries exactly the authority of no password at all — which is why no
"unverified principal" exists and `/api/v1/principal` is unchanged. Better Auth owns every token;
Ekklesia adds no token table, no token hashing, and no expiry clock. Verification and reset links are
rebuilt from a validated Dashboard origin rather than from anything a request supplied, so no caller
can steer where a recipient lands, and following a verification link deliberately mints **no
session** — mail scanners dereference links. Password reset revokes that person's other sessions and
leaves managed devices untouched. Password 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**: `pnpm identity:create-initial-owner` creates
that account already verified and has no mail prerequisite at all, so a freshly provisioned server
can be signed in to before any relay exists. Normal production use of verification and recovery does
require configured outbound email. **Public signup remains disabled** — verification is not a licence
to self-register — and invitations and user administration are still not built. See
[Identity](docs/development/identity.md).
Ekklesia publishes the display bundle at Ekklesia publishes the display bundle at
`GET /api/v1/module-ui/<module-id>/<version>/display`, serves a stable permission-gated page at `GET /api/v1/module-ui/<module-id>/<version>/display`, serves a stable permission-gated page at
@@ -869,7 +889,7 @@ Object storage
Outbound email Outbound email
○ Outbound email not configured ○ Outbound email not configured
No EKKLESIA_SMTP_HOST is set. This is a complete deployment: nothing in the platform sends email yet. No EKKLESIA_SMTP_HOST is set. The platform runs and the bootstrap owner can sign in, but email verification and account recovery cannot deliver.
Result: READY WITH WARNINGS Result: READY WITH WARNINGS
``` ```
+2
View File
@@ -31,8 +31,10 @@
"@ekklesia/typescript-config": "workspace:*", "@ekklesia/typescript-config": "workspace:*",
"@types/node": "24.13.3", "@types/node": "24.13.3",
"@types/pg": "8.20.0", "@types/pg": "8.20.0",
"@types/smtp-server": "3.5.13",
"better-auth": "1.6.23", "better-auth": "1.6.23",
"pg": "8.22.0", "pg": "8.22.0",
"smtp-server": "3.19.3",
"typescript": "7.0.2", "typescript": "7.0.2",
"vitest": "4.1.10" "vitest": "4.1.10"
}, },
+4 -2
View File
@@ -79,8 +79,10 @@ export interface ApiRuntimeConfig {
* The validated host outbound-email provider, or `undefined` when this installation has none * The validated host outbound-email provider, or `undefined` when this installation has none
* (ADR 0037). * (ADR 0037).
* *
* `undefined` is a complete, valid deployment: nothing in the platform sends email yet, and a * `undefined` is a valid deployment: the platform starts, every existing feature works, and the
* future feature that needs to discovers a structured `MAIL_NOT_CONFIGURED` rather than crashing. * initial platform owner — created already verified by an operator command — can still sign in
* (ADR 0038). What it cannot do is verify a new account or deliver a password reset; each attempt
* reports a sanitized `MAIL_NOT_CONFIGURED` out of band rather than changing any HTTP response.
* These values reach Core's provider and nowhere else — no module, browser bundle, diagnostic, or * These values reach Core's provider and nowhere else — no module, browser bundle, diagnostic, or
* API response ever sees the host or the password. * API response ever sees the host or the password.
*/ */
+34 -16
View File
@@ -13,7 +13,12 @@ import {
import { createDashboardStore } from "@ekklesia/core/dashboard"; import { createDashboardStore } from "@ekklesia/core/dashboard";
import { createDeviceStore } from "@ekklesia/core/devices"; import { createDeviceStore } from "@ekklesia/core/devices";
import { type HostEnvironment, loadHostConfiguration } from "@ekklesia/core/host"; import { type HostEnvironment, loadHostConfiguration } from "@ekklesia/core/host";
import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity"; import {
createIdentityNotifications,
createIdentityRuntime,
type IdentityNotificationReporter,
type IdentityRateLimitOverride,
} from "@ekklesia/core/identity";
import { createSmtpMailProvider, type MailProvider } from "@ekklesia/core/mail"; import { createSmtpMailProvider, type MailProvider } from "@ekklesia/core/mail";
import { import {
createModuleConfigurationStore, createModuleConfigurationStore,
@@ -138,6 +143,15 @@ export interface StartApiOptions {
/** Test-only escape hatch: never set by the executable entry point. See /** Test-only escape hatch: never set by the executable entry point. See
* `IdentityRateLimitOverride` for why production must leave this unset. */ * `IdentityRateLimitOverride` for why production must leave this unset. */
readonly rateLimitOverride?: IdentityRateLimitOverride; readonly rateLimitOverride?: IdentityRateLimitOverride;
/**
* Where a verification or reset message that could not be delivered is reported.
*
* Production composition leaves this unset and one fixed sanitized line is written. It exists as a
* seam because the *only* externally observable difference between "the relay is down" and "there
* is no such account" must be this report — every HTTP response is deliberately identical — so a
* test needs somewhere to observe the operational failure the user is never shown.
*/
readonly reportNotificationFailure?: IdentityNotificationReporter;
/** Test-only escape hatch: overrides the directory module discovery scans. Never set by the /** Test-only escape hatch: overrides the directory module discovery scans. Never set by the
* executable entry point. See `prepareStartupModuleInventory`. */ * executable entry point. See `prepareStartupModuleInventory`. */
readonly modulesDirectoryOverride?: string; readonly modulesDirectoryOverride?: string;
@@ -430,6 +444,14 @@ export async function startApi(
transport: config.databaseTransport, transport: config.databaseTransport,
}); });
// Both host providers are constructed from validated configuration **before** anything that uses
// them, and neither touches the network while being built: an S3 client opens no connection and a
// Nodemailer transport opens no socket, so an unreachable relay or storage service can never stop
// the platform from booting (ADR 0030, ADR 0037). An installation with neither configured gets
// `undefined` for both, which is a complete, valid deployment.
const storageProvider = config.storage ? createS3StorageProvider(config.storage) : undefined;
const mailProvider = config.mail ? createSmtpMailProvider(config.mail) : undefined;
let services: ModuleRuntimeServices; let services: ModuleRuntimeServices;
let runtime: ReturnType<typeof createIdentityRuntime>; let runtime: ReturnType<typeof createIdentityRuntime>;
let authorizationStore: ReturnType<typeof createAuthorizationStore>; let authorizationStore: ReturnType<typeof createAuthorizationStore>;
@@ -439,11 +461,22 @@ export async function startApi(
runtime = createIdentityRuntime({ runtime = createIdentityRuntime({
config: config.identity, config: config.identity,
db: databaseHandle.identity, db: databaseHandle.identity,
// Identity's two outbound messages, composed here — the one place that knows both the
// validated identity configuration and the host's mail provider. Core's identity code learns
// a recipient, a subject, and two bodies and nothing whatsoever about SMTP (ADR 0038).
notifications: createIdentityNotifications({
authBaseUrl: config.identity.baseUrl,
dashboardOrigin: config.identity.dashboardOrigin,
mail: mailProvider,
...(options.reportNotificationFailure ? { report: options.reportNotificationFailure } : {}),
}),
...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}), ...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}),
}); });
authorizationStore = createAuthorizationStore(databaseHandle.authorization); authorizationStore = createAuthorizationStore(databaseHandle.authorization);
deviceStore = createDeviceStore(databaseHandle.devices); deviceStore = createDeviceStore(databaseHandle.devices);
} catch (error) { } catch (error) {
await safeCloseStorage(storageProvider);
await safeCloseMail(mailProvider);
await safeCloseDatabase(databaseHandle); await safeCloseDatabase(databaseHandle);
throw error; throw error;
} }
@@ -460,21 +493,6 @@ export async function startApi(
// start, before a single module entrypoint is imported. A failure here fails startup rather than // start, before a single module entrypoint is imported. A failure here fails startup rather than
// falling back to "start everything": the platform cannot know what it was told, and starting a // falling back to "start everything": the platform cannot know what it was told, and starting a
// module an administrator disabled is worse than not starting at all. // module an administrator disabled is worse than not starting at all.
// The host's object-storage provider, constructed from validated configuration before any module
// runs — and **without touching the network**. Building the client opens no connection, so a
// storage outage can never stop the platform from booting; a bucket is ensured lazily on first
// use instead (ADR 0030). An installation with no storage configured gets `undefined` here, which
// is a complete, valid deployment.
const storageProvider = config.storage ? createS3StorageProvider(config.storage) : undefined;
// The host's outbound mail provider, constructed from validated configuration and — like the
// storage client above — **without touching the network**. Building a Nodemailer transport opens
// no socket and sends nothing, so an unreachable relay can never stop the platform from booting;
// the first connection happens when something actually sends (ADR 0037). Nothing sends yet: this
// exists so Pass 2 can hand it to Better Auth's verification and reset hooks. An installation with
// no SMTP configured gets `undefined`, which is a complete, valid deployment.
const mailProvider = config.mail ? createSmtpMailProvider(config.mail) : undefined;
const moduleLifecycleStore = createModuleLifecycleStore(databaseHandle.moduleLifecycle); const moduleLifecycleStore = createModuleLifecycleStore(databaseHandle.moduleLifecycle);
const moduleConfigurationStore = createModuleConfigurationStore( const moduleConfigurationStore = createModuleConfigurationStore(
databaseHandle.moduleConfiguration, databaseHandle.moduleConfiguration,
+46 -1
View File
@@ -224,6 +224,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0004_module_lifecycle_state", "core:0004_module_lifecycle_state",
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
]); ]);
expect(aggregate.migrations[0]?.checksum).toBe( expect(aggregate.migrations[0]?.checksum).toBe(
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f", "f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
@@ -522,6 +523,32 @@ function apiOrigin(running: RunningApi): string {
return `http://127.0.0.1:${address.port}`; return `http://127.0.0.1:${address.port}`;
} }
/**
* Marks an address verified out of band, the same trusted step the initial-owner bootstrap performs
* (ADR 0038).
*
* Deliberately not a weakening of the policy: it is the test-harness equivalent of an operator with
* database credentials, used only so scenarios that are about something else are not obliged to run
* an SMTP server. That the policy itself is enforced — that an unverified account genuinely cannot
* sign in — is asserted separately, both in this file and in `email-verification.integration.ts`.
*/
async function markEmailVerified(origin: string, email: string): Promise<void> {
const url = databaseUrlByOrigin.get(origin);
if (!url) {
throw harnessError("No database is registered for that API origin.");
}
const client = new Client({ connectionString: url });
await client.connect();
try {
await client.query(
'update "ekklesia_core_user" set "email_verified" = true where lower("email") = $1',
[email.trim().toLowerCase()],
);
} finally {
await client.end().catch(() => undefined);
}
}
interface StartConfiguredApiOptions { interface StartConfiguredApiOptions {
readonly clientAddress?: ClientAddressConfigInput; readonly clientAddress?: ClientAddressConfigInput;
/** Test-only narrowing of the platform's 30-second graceful HTTP drain deadline. */ /** Test-only narrowing of the platform's 30-second graceful HTTP drain deadline. */
@@ -537,6 +564,16 @@ interface StartConfiguredApiOptions {
readonly rateLimitOverride?: { readonly max: number; readonly window: number } | undefined; readonly rateLimitOverride?: { readonly max: number; readonly window: number } | undefined;
} }
/**
* The database each running API in this suite is attached to, keyed by its bound origin.
*
* Needed because Phase 7C Pass 2 made a verified address a precondition for holding a session
* (ADR 0038), so `signUp` below has to mark the account verified before these scenarios — which are
* about cookies, CSRF, principals, sessions, and authorization, not about verification — can sign
* in. Verification itself has its own dedicated end-to-end suite.
*/
const databaseUrlByOrigin = new Map<string, string>();
async function startConfiguredApi( async function startConfiguredApi(
url: string, url: string,
options: StartConfiguredApiOptions = {}, options: StartConfiguredApiOptions = {},
@@ -571,7 +608,7 @@ async function startConfiguredApi(
}, },
{ environment }, { environment },
); );
return startApi(config, { const running = await startApi(config, {
...("rateLimitOverride" in options ...("rateLimitOverride" in options
? { ...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}) } ? { ...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}) }
: { rateLimitOverride: { max: 1000, window: 1 } }), : { rateLimitOverride: { max: 1000, window: 1 } }),
@@ -585,6 +622,8 @@ async function startConfiguredApi(
? {} ? {}
: { drainTimeoutMsOverride: options.drainTimeoutMsOverride }), : { drainTimeoutMsOverride: options.drainTimeoutMsOverride }),
}); });
databaseUrlByOrigin.set(apiOrigin(running), url);
return running;
} }
async function startMigratedApi( async function startMigratedApi(
@@ -763,6 +802,12 @@ async function signUp(
headers: { "content-type": "application/json", origin: dashboardOrigin, ...headers }, headers: { "content-type": "application/json", origin: dashboardOrigin, ...headers },
method: "POST", method: "POST",
}); });
if (response.ok) {
// A verified address is a precondition for holding a session (ADR 0038). Sign-up itself always
// writes `emailVerified: false`, so these scenarios — none of which are about verification —
// complete that step out of band rather than standing up an SMTP server apiece.
await markEmailVerified(origin, email);
}
return { body: (await response.clone().json()) as SignUpResult["body"], response }; return { body: (await response.clone().json()) as SignUpResult["body"], response };
} }
+3
View File
@@ -28,6 +28,9 @@ describe("API runtime configuration", () => {
host: "127.0.0.1", host: "127.0.0.1",
identity: { identity: {
baseUrl: "https://identity.example.test", baseUrl: "https://identity.example.test",
// Defaults to the identity base URL: the Dashboard and the API share one origin unless a
// deployment separates them (ADR 0038).
dashboardOrigin: "https://identity.example.test",
publicSignupEnabled: false, publicSignupEnabled: false,
secret: validSecret, secret: validSecret,
trustedOrigins: ["https://identity.example.test"], trustedOrigins: ["https://identity.example.test"],
@@ -0,0 +1,816 @@
import { createHmac, randomBytes } from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
createBootstrapIdentityCreator,
createIdentityDrizzleDatabase,
type IdentityNotificationFailure,
} from "@ekklesia/core/identity";
import {
applyModuleMigrations,
createPostgresMigrationDatabase,
discoverAllMigrations,
} from "@ekklesia/core/migrations";
import { Client, type QueryResultRow } from "pg";
import { SMTPServer } from "smtp-server";
import { afterAll, afterEach, describe, expect, it } from "vitest";
import { createApiRuntimeConfig } from "../src/config.js";
import { type RunningApi, startApi } from "../src/server.js";
/**
* Email verification and password recovery, end to end (ADR 0038).
*
* Everything here is real: a disposable PostgreSQL, the actual HTTP transport, the actual Better
* Auth endpoints, the actual SMTP provider, and an actual SMTP server accepting the message. The
* verification and reset links are **extracted from the delivered message** and then followed, which
* is the only way to prove the whole path rather than the pieces — a fake mail function would prove
* that a callback was invoked and nothing about whether the resulting link works.
*
* `smtp-server` runs in-process and is never given a live credential, so nothing here reaches the
* internet and no real email is ever sent.
*
* Public signup is disabled in every scenario, matching production. Accounts that are not the
* bootstrap owner are created through the same trusted server-side creator the bootstrap CLI uses,
* which is deliberately not reachable over HTTP.
*/
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
const repositoryRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
const coreMigrationsDirectory = path.join(repositoryRoot, "core", "drizzle");
const trackedDatabases = new Set<string>();
let databaseCounter = 0;
const dashboardOrigin = "http://dashboard.localhost:5173";
const integrationSecret = "integration-test-secret-0123456789abcdefgh";
const initialPassword = "correct horse battery staple";
const replacementPassword = "an entirely different passphrase";
class IntegrationHarnessError extends Error {
constructor(message: string) {
super(message);
this.name = "IntegrationHarnessError";
this.stack = `${this.name}: ${this.message}`;
}
}
function baseDatabaseUrl(): string {
const url = process.env[integrationDatabaseUrlEnvironment];
if (typeof url !== "string" || url.trim() === "") {
throw new IntegrationHarnessError("The integration database URL is not configured.");
}
return url;
}
function databaseUrl(name: string): string {
const url = new URL(baseDatabaseUrl());
url.pathname = `/${name}`;
return url.toString();
}
async function openAdministrativeClient(): Promise<Client> {
const client = new Client({ connectionString: baseDatabaseUrl() });
await client.connect();
return client;
}
async function safeQuery<T extends QueryResultRow>(
client: Client,
text: string,
values: readonly unknown[] = [],
): Promise<T[]> {
try {
const result = await client.query<T>(text, [...values]);
return result.rows;
} catch {
// Never rethrows the driver error: it can carry SQL, a connection string, and a host.
throw new IntegrationHarnessError("An integration database statement failed.");
}
}
async function applyCoreMigrations(url: string): Promise<void> {
const emptyModules = await mkdtemp(path.join(tmpdir(), "ekklesia-verify-no-modules-"));
try {
const aggregate = await discoverAllMigrations({
coreMigrationsDirectory,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: emptyModules,
platformVersion: "0.1.0",
},
});
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally {
await rm(emptyModules, { force: true, recursive: true });
}
}
async function freshInstallation(label: string): Promise<string> {
databaseCounter += 1;
const name =
`ekk_verify_${label}_${databaseCounter}_${randomBytes(4).toString("hex")}`.toLowerCase();
const admin = await openAdministrativeClient();
try {
await safeQuery(admin, `create database "${name}"`);
} finally {
await admin.end().catch(() => undefined);
}
trackedDatabases.add(name);
const url = databaseUrl(name);
await applyCoreMigrations(url);
return url;
}
/** A delivered message, as the SMTP server actually received it. */
interface DeliveredMessage {
readonly to: readonly string[];
readonly data: string;
}
interface MailHarness {
readonly port: number;
readonly delivered: DeliveredMessage[];
readonly close: () => Promise<void>;
}
const openResources: Array<() => Promise<void>> = [];
afterEach(async () => {
for (const close of openResources.splice(0).reverse()) {
await close().catch(() => undefined);
}
});
/**
* Picks a free port before the API is configured.
*
* The identity base URL is what the emailed link is built from, so it has to be known *before*
* `startApi` binds. Without this the test could only assert the link's shape and would have to
* rewrite its host before following it — which is exactly the part worth proving. Reserving and
* releasing a port leaves a theoretical race, but nothing else on the loopback interface is
* competing for ephemeral ports inside this suite.
*/
async function reservePort(): Promise<number> {
const { createServer } = await import("node:net");
const server = createServer();
const port = await new Promise<number>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address() as AddressInfo;
resolve(address.port);
});
});
await new Promise<void>((resolve) => server.close(() => resolve()));
return port;
}
async function startMailServer(): Promise<MailHarness> {
const delivered: DeliveredMessage[] = [];
const server = new SMTPServer({
authOptional: true,
disabledCommands: ["STARTTLS"],
onData(stream, session, callback) {
let data = "";
stream.on("data", (chunk: Buffer) => {
data += chunk.toString("utf8");
});
stream.on("end", () => {
delivered.push({
data,
to: session.envelope.rcptTo.map((recipient) => recipient.address),
});
callback();
});
},
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => resolve());
});
const address = server.server.address() as AddressInfo;
const harness: MailHarness = {
close: async () =>
await new Promise<void>((resolve) => {
server.close(() => resolve());
}),
delivered,
port: address.port,
};
openResources.push(harness.close);
return harness;
}
interface Installation {
readonly url: string;
readonly api: RunningApi;
readonly origin: string;
readonly mail: MailHarness | undefined;
readonly notificationFailures: IdentityNotificationFailure[];
readonly createIdentity: (input: {
readonly email: string;
readonly password: string;
readonly name: string;
}) => Promise<void>;
}
/**
* A migrated installation with the API running, optionally with a working SMTP relay.
*
* `plaintext` SMTP and an `http` Dashboard origin are only acceptable because this is explicitly the
* development environment; production configuration refuses both, which `identity-config` and
* `mail-contract` assert separately.
*/
async function installation(
label: string,
options: { readonly mail?: boolean } = {},
): Promise<Installation> {
const url = await freshInstallation(label);
const mail = options.mail === false ? undefined : await startMailServer();
const notificationFailures: IdentityNotificationFailure[] = [];
const identity = createIdentityDrizzleDatabase(url, {});
openResources.push(async () => {
await identity.close().catch(() => undefined);
});
const apiPort = await reservePort();
const identityBaseUrl = `http://127.0.0.1:${apiPort}`;
const config = createApiRuntimeConfig(
{
databaseUrl: url,
host: "127.0.0.1",
identity: {
baseUrl: identityBaseUrl,
dashboardOrigin,
publicSignupEnabled: false,
secret: integrationSecret,
trustedOrigins: [dashboardOrigin],
},
...(mail
? {
mail: {
fromAddress: "[email protected]",
fromName: "Ekklesia",
host: "127.0.0.1",
port: String(mail.port),
security: "plaintext",
},
}
: {}),
port: apiPort,
},
{ environment: "development" },
);
const api = await startApi(config, {
rateLimitOverride: { max: 1000, window: 1 },
reportNotificationFailure: (failure) => notificationFailures.push(failure),
});
openResources.push(async () => {
await api.shutdown().catch(() => undefined);
});
const address = api.server.address();
if (!address || typeof address !== "object" || address.port !== apiPort) {
throw new IntegrationHarnessError("The API integration server did not bind the reserved port.");
}
return {
api,
createIdentity: createBootstrapIdentityCreator(identity.db),
mail,
notificationFailures,
origin: identityBaseUrl,
url,
};
}
async function post(
origin: string,
path: string,
body: unknown,
headers: Readonly<Record<string, string>> = {},
): Promise<Response> {
return await fetch(`${origin}${path}`, {
body: JSON.stringify(body),
headers: { "content-type": "application/json", origin: dashboardOrigin, ...headers },
method: "POST",
redirect: "manual",
});
}
function firstCookiePair(response: Response): string | undefined {
const setCookie = response.headers.get("set-cookie");
return setCookie === null ? undefined : setCookie.split(",")[0]?.split(";")[0]?.trim();
}
async function signIn(
origin: string,
email: string,
password: string,
): Promise<{ readonly response: Response; readonly cookiePair: string | undefined }> {
const response = await post(origin, "/api/auth/sign-in/email", { email, password });
return { cookiePair: firstCookiePair(response), response };
}
async function principalKind(origin: string, cookiePair: string | undefined): Promise<string> {
const response = await fetch(`${origin}/api/v1/principal`, {
headers: cookiePair ? { cookie: cookiePair } : {},
});
const body = (await response.json()) as { readonly kind?: unknown };
return String(body.kind);
}
/**
* The one place a test reads a token, and only ever out of a message this suite's own SMTP server
* received. Nothing in the platform exposes one.
*
* Quoted-printable soft line breaks are undone first: a URL longer than 76 characters is split by
* the encoder, and reassembling it here is what makes the extracted link the one a mail client would
* actually resolve.
*/
function extractLink(message: DeliveredMessage, contains: string): URL {
const unwrapped = message.data.replaceAll("=\r\n", "").replaceAll("=\n", "");
const decoded = unwrapped.replaceAll(/=([0-9A-F]{2})/gu, (_match, hex: string) =>
String.fromCharCode(Number.parseInt(hex, 16)),
);
const match = decoded.match(/https?:\/\/[^\s"<>]+/gu)?.find((url) => url.includes(contains));
if (!match) {
throw new IntegrationHarnessError(`No ${contains} link was delivered.`);
}
return new URL(match);
}
function lastMessage(mail: MailHarness): DeliveredMessage {
const message = mail.delivered.at(-1);
if (!message) {
throw new IntegrationHarnessError("No message was delivered.");
}
return message;
}
async function emailVerifiedFlag(url: string, email: string): Promise<boolean | undefined> {
const client = new Client({ connectionString: url });
await client.connect();
try {
const rows = await safeQuery<{ email_verified: boolean }>(
client,
'select "email_verified" from "ekklesia_core_user" where lower("email") = $1',
[email.toLowerCase()],
);
return rows[0]?.email_verified;
} finally {
await client.end().catch(() => undefined);
}
}
/**
* An account that has completed the real verification round trip.
*
* Recovery is only meaningful for a verified account, and building one through the actual flow —
* rather than by writing `email_verified` directly — keeps every recovery test standing on a
* verification path that genuinely works.
*/
async function verifiedAccountFor(install: Installation, email: string): Promise<void> {
await install.createIdentity({ email, name: "Member", password: initialPassword });
await signIn(install.origin, email, initialPassword);
const link = extractLink(lastMessage(install.mail as MailHarness), "/verify-email");
await fetch(link, { redirect: "manual" });
(install.mail as MailHarness).delivered.length = 0;
}
afterAll(async () => {
const admin = await openAdministrativeClient().catch(() => undefined);
if (!admin) {
return;
}
try {
for (const name of trackedDatabases) {
await safeQuery(admin, `drop database if exists "${name}" with (force)`).catch(
() => undefined,
);
}
} finally {
await admin.end().catch(() => undefined);
}
});
/**
* Forges a verification token that is structurally valid but already expired.
*
* Better Auth's verification token is a plain HS256 JWS over the auth secret (`signJWT`, pinned
* release) — not a database row — so an expired one can be produced here from the same secret this
* suite configured, with no library internals and no waiting an hour. This is the only place any
* test constructs a token rather than reading one out of a delivered message.
*/
function forgeExpiredVerificationToken(email: string): string {
const encode = (value: object): string =>
Buffer.from(JSON.stringify(value)).toString("base64url");
const issued = Math.floor(Date.now() / 1000) - 7200;
const signingInput = `${encode({ alg: "HS256", typ: "JWT" })}.${encode({
email: email.toLowerCase(),
exp: issued + 3600,
iat: issued,
})}`;
const signature = createHmac("sha256", integrationSecret)
.update(signingInput)
.digest("base64url");
return `${signingInput}.${signature}`;
}
describe("email verification, end to end", () => {
it("takes a new human account from unverified to signed in through a real emailed link", async () => {
const install = await installation("flow");
const email = "[email protected]";
await install.createIdentity({ email, name: "Member", password: initialPassword });
// 1. Unverified. A correct password does not produce a session — the identity system refuses
// before creating one, so there is no half-authenticated state anywhere (ADR 0038).
expect(await emailVerifiedFlag(install.url, email)).toBe(false);
const refused = await signIn(install.origin, email, initialPassword);
expect(refused.response.status).toBe(403);
expect(await refused.response.clone().json()).toMatchObject({ code: "EMAIL_NOT_VERIFIED" });
expect(refused.cookiePair).toBeUndefined();
expect(await principalKind(install.origin, refused.cookiePair)).toBe("anonymous");
// 2. That attempt delivered a verification message through the real SMTP provider.
const mail = install.mail as MailHarness;
expect(mail.delivered).toHaveLength(1);
expect(lastMessage(mail).to).toEqual([email]);
expect(lastMessage(mail).data).toContain("Verify your Ekklesia email address");
// 3. Follow the link exactly as it was delivered. Nothing rewrites it.
const link = extractLink(lastMessage(mail), "/verify-email");
expect(link.origin).toBe(install.origin);
const verified = await fetch(link, { redirect: "manual" });
// 4. Better Auth redirects to the Dashboard landing this deployment configured — never to the
// API origin, and never to anything a request supplied.
expect(verified.status).toBe(302);
const landing = new URL(verified.headers.get("location") ?? "");
expect(landing.origin).toBe(dashboardOrigin);
expect(landing.searchParams.get("ekklesia_auth")).toBe("verify-email");
expect(landing.searchParams.get("error")).toBeNull();
// No session was minted by following an emailed link.
expect(verified.headers.get("set-cookie")).toBeNull();
expect(await emailVerifiedFlag(install.url, email)).toBe(true);
// 5. The same credentials now work, and the principal is a fully authenticated human.
const accepted = await signIn(install.origin, email, initialPassword);
expect(accepted.response.status).toBe(200);
expect(accepted.cookiePair).toBeDefined();
expect(await principalKind(install.origin, accepted.cookiePair)).toBe("authenticated");
});
it("refuses a malformed token and an expired token, each with its own reason", async () => {
const install = await installation("bad-token");
const email = "[email protected]";
await install.createIdentity({ email, name: "Expired", password: initialPassword });
const callback = encodeURIComponent(`${dashboardOrigin}/?ekklesia_auth=verify-email`);
for (const [token, reason] of [
["not-a-jwt", "INVALID_TOKEN"],
[forgeExpiredVerificationToken(email), "TOKEN_EXPIRED"],
] as const) {
const response = await fetch(
`${install.origin}/api/auth/verify-email?token=${encodeURIComponent(token)}&callbackURL=${callback}`,
{ redirect: "manual" },
);
expect(response.status).toBe(302);
const landing = new URL(response.headers.get("location") ?? "");
// The failure still lands on the host's own screen rather than a raw JSON error page.
expect(landing.origin).toBe(dashboardOrigin);
expect(landing.searchParams.get("error")).toBe(reason);
expect(await emailVerifiedFlag(install.url, email)).toBe(false);
}
});
it("is idempotent when a link is followed twice, because the token is a signed claim", async () => {
const install = await installation("replay");
const email = "[email protected]";
await install.createIdentity({ email, name: "Replay", password: initialPassword });
await signIn(install.origin, email, initialPassword);
const link = extractLink(lastMessage(install.mail as MailHarness), "/verify-email");
const first = await fetch(link, { redirect: "manual" });
const second = await fetch(link, { redirect: "manual" });
// Documented honestly (ADR 0038): the verification token is a signed JWT, not a consumed
// database row, so it is **not** single-use — it is valid until it expires. Replaying it is a
// no-op on an already-verified address and mints no session, which is what makes that safe.
// Mail scanners and prefetchers dereference links, so idempotence is a requirement here.
expect(first.status).toBe(302);
expect(second.status).toBe(302);
expect(second.headers.get("set-cookie")).toBeNull();
expect(await emailVerifiedFlag(install.url, email)).toBe(true);
});
it("resends on request without revealing whether the address has an account", async () => {
const install = await installation("resend");
const email = "[email protected]";
await install.createIdentity({ email, name: "Resend", password: initialPassword });
const mail = install.mail as MailHarness;
const known = await post(install.origin, "/api/auth/send-verification-email", { email });
const unknown = await post(install.origin, "/api/auth/send-verification-email", {
email: "[email protected]",
});
const verifiedAlready = await post(install.origin, "/api/auth/send-verification-email", {
email: "[email protected]",
});
// Identical answers. The only difference anywhere is that a message was delivered, which the
// requester cannot observe.
expect(known.status).toBe(200);
expect(unknown.status).toBe(200);
expect(verifiedAlready.status).toBe(200);
expect(await known.clone().json()).toEqual(await unknown.clone().json());
expect(mail.delivered.map((message) => message.to)).toEqual([[email]]);
});
});
describe("password recovery, end to end", () => {
it("replaces the password through a real emailed link and invalidates the old one", async () => {
const install = await installation("reset");
const email = "[email protected]";
await verifiedAccountFor(install, email);
const mail = install.mail as MailHarness;
const requested = await post(install.origin, "/api/auth/request-password-reset", { email });
expect(requested.status).toBe(200);
expect(mail.delivered).toHaveLength(1);
expect(lastMessage(mail).to).toEqual([email]);
expect(lastMessage(mail).data).toContain("Reset your Ekklesia password");
// Follow the emailed link. Better Auth validates the token and hands the browser on to the
// Dashboard landing with it attached — which is where the host's reset form picks it up.
const link = extractLink(lastMessage(mail), "/reset-password/");
expect(link.origin).toBe(install.origin);
const handoff = await fetch(link, { redirect: "manual" });
expect(handoff.status).toBe(302);
const landing = new URL(handoff.headers.get("location") ?? "");
expect(landing.origin).toBe(dashboardOrigin);
expect(landing.searchParams.get("ekklesia_auth")).toBe("reset-password");
const token = landing.searchParams.get("token") ?? "";
expect(token).not.toBe("");
const completed = await post(install.origin, "/api/auth/reset-password", {
newPassword: replacementPassword,
token,
});
expect(completed.status).toBe(200);
const oldCredential = await signIn(install.origin, email, initialPassword);
expect(oldCredential.response.status).toBe(401);
const newCredential = await signIn(install.origin, email, replacementPassword);
expect(newCredential.response.status).toBe(200);
expect(await principalKind(install.origin, newCredential.cookiePair)).toBe("authenticated");
});
it("consumes the reset token, so a captured link cannot be replayed", async () => {
const install = await installation("reset-replay");
const email = "[email protected]";
await verifiedAccountFor(install, email);
await post(install.origin, "/api/auth/request-password-reset", { email });
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
const handoff = await fetch(link, { redirect: "manual" });
const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? "";
const first = await post(install.origin, "/api/auth/reset-password", {
newPassword: replacementPassword,
token,
});
const second = await post(install.origin, "/api/auth/reset-password", {
newPassword: "yet another different passphrase",
token,
});
// Unlike the verification token, this one is a database row Better Auth consumes.
expect(first.status).toBe(200);
expect(second.status).toBe(400);
expect(await second.json()).toMatchObject({ code: "INVALID_TOKEN" });
expect((await signIn(install.origin, email, replacementPassword)).response.status).toBe(200);
});
it("signs out every existing session for that account", async () => {
const install = await installation("reset-sessions");
const email = "[email protected]";
await verifiedAccountFor(install, email);
const before = await signIn(install.origin, email, initialPassword);
expect(await principalKind(install.origin, before.cookiePair)).toBe("authenticated");
await post(install.origin, "/api/auth/request-password-reset", { email });
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
const handoff = await fetch(link, { redirect: "manual" });
const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? "";
await post(install.origin, "/api/auth/reset-password", {
newPassword: replacementPassword,
token,
});
// The whole point of recovery is that it works when the credential is already compromised, so
// an attacker's existing session must not survive it (`revokeSessionsOnPasswordReset`).
expect(await principalKind(install.origin, before.cookiePair)).toBe("anonymous");
});
it("enforces the identity system's own password policy, with no reset-specific rule", async () => {
const install = await installation("reset-policy");
const email = "[email protected]";
await verifiedAccountFor(install, email);
await post(install.origin, "/api/auth/request-password-reset", { email });
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
const handoff = await fetch(link, { redirect: "manual" });
const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? "";
const tooShort = await post(install.origin, "/api/auth/reset-password", {
newPassword: "short",
token,
});
expect(tooShort.status).toBe(400);
expect(await tooShort.json()).toMatchObject({ code: "PASSWORD_TOO_SHORT" });
// The old password still works: a rejected reset changes nothing.
expect((await signIn(install.origin, email, initialPassword)).response.status).toBe(200);
});
it("answers a request for an address with no account exactly as it answers a real one", async () => {
const install = await installation("enumeration");
const email = "[email protected]";
await verifiedAccountFor(install, email);
const mail = install.mail as MailHarness;
const known = await post(install.origin, "/api/auth/request-password-reset", { email });
const unknown = await post(install.origin, "/api/auth/request-password-reset", {
email: "[email protected]",
});
expect(known.status).toBe(unknown.status);
expect(await known.clone().json()).toEqual(await unknown.clone().json());
expect(JSON.stringify(await unknown.json())).not.toContain("stranger");
// Only the real account received anything.
expect(mail.delivered.map((message) => message.to)).toEqual([[email]]);
});
});
describe("recovery when the installation has no outbound email", () => {
it("answers identically and records the operational failure out of band", async () => {
const install = await installation("no-mail", { mail: false });
const email = "[email protected]";
await install.createIdentity({ email, name: "No Mail", password: initialPassword });
const reset = await post(install.origin, "/api/auth/request-password-reset", { email });
const resend = await post(install.origin, "/api/auth/send-verification-email", { email });
// The user-facing answer must not change: "we could not send it" for a real address and a
// generic success for an unknown one is an enumeration oracle that appears when SMTP breaks.
expect(reset.status).toBe(200);
expect(resend.status).toBe(200);
// The operator still learns about it, with a sanitized code and no address or token.
expect(install.notificationFailures).toEqual([
{ kind: "password-reset", reason: "MAIL_NOT_CONFIGURED" },
{ kind: "email-verification", reason: "MAIL_NOT_CONFIGURED" },
]);
expect(JSON.stringify(install.notificationFailures)).not.toContain("[email protected]");
});
it("still starts, serves the API, and refuses an unverified sign-in", async () => {
const install = await installation("no-mail-runs", { mail: false });
const email = "[email protected]";
await install.createIdentity({ email, name: "No Mail", password: initialPassword });
// Absent mail is a complete deployment (ADR 0037) and does **not** relax the verification
// policy — which is exactly why the initial owner is created already verified.
expect((await fetch(`${install.origin}/api/v1/principal`)).status).toBe(200);
expect((await signIn(install.origin, email, initialPassword)).response.status).toBe(403);
});
});
describe("recovery cannot be turned into a redirect or an account factory", () => {
it("refuses an attacker-supplied reset destination", async () => {
const install = await installation("redirect");
const email = "[email protected]";
await install.createIdentity({ email, name: "Redirect", password: initialPassword });
const external = await post(install.origin, "/api/auth/request-password-reset", {
email,
redirectTo: "https://evil.example.test/steal",
});
expect(external.status).toBe(403);
expect(await external.json()).toMatchObject({ code: "INVALID_REDIRECT_URL" });
expect((install.mail as MailHarness).delivered).toHaveLength(0);
});
it("refuses an attacker-supplied verification destination", async () => {
const install = await installation("redirect-verify");
for (const callbackURL of [
"https://evil.example.test",
"//evil.example.test",
"/\\evil.example.test",
"javascript:alert(1)",
]) {
const response = await fetch(
`${install.origin}/api/auth/verify-email?token=x&callbackURL=${encodeURIComponent(callbackURL)}`,
{ redirect: "manual" },
);
expect(response.status).toBe(403);
expect(response.headers.get("location")).toBeNull();
}
});
it("ignores a supplied destination even where one is accepted, because the link is rebuilt", async () => {
const install = await installation("redirect-ignored");
const email = "[email protected]";
await verifiedAccountFor(install, email);
// A *trusted* origin passes Better Auth's own check, so this is the case where the emailed
// link's destination would otherwise be caller-chosen. It is not: the message is built from
// configuration and the supplied value never reaches it.
await post(install.origin, "/api/auth/request-password-reset", {
email,
redirectTo: `${dashboardOrigin}/somewhere-else`,
});
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
const callback = new URL(link.searchParams.get("callbackURL") ?? "");
expect(callback.origin).toBe(dashboardOrigin);
expect(callback.pathname).toBe("/");
expect(callback.searchParams.get("ekklesia_auth")).toBe("reset-password");
expect(link.href).not.toContain("somewhere-else");
});
it("keeps public signup disabled now that verification exists", async () => {
const install = await installation("signup");
const response = await post(install.origin, "/api/auth/sign-up/email", {
email: "[email protected]",
name: "Intruder",
password: initialPassword,
});
// Email verification is not a licence to self-register. Future accounts arrive through
// deliberate administration, which this pass does not build.
expect(response.status).toBe(400);
expect(await response.json()).toMatchObject({ code: "EMAIL_PASSWORD_SIGN_UP_DISABLED" });
expect(await emailVerifiedFlag(install.url, "[email protected]")).toBeUndefined();
});
});
describe("managed devices are untouched by human email identity", () => {
it("leaves a paired device intact across a human password reset", async () => {
const install = await installation("devices");
const email = "[email protected]";
await verifiedAccountFor(install, email);
// A device is a row with a credential digest and no email, no password, and no verification
// state (ADR 0036). It is inserted directly here because pairing it through HTTP would prove
// something this test is not about.
const client = new Client({ connectionString: install.url });
await client.connect();
try {
await safeQuery(
client,
'insert into "ekklesia_core_device" ("name", "credential_hash") values ($1, $2)',
["Sanctuary display", "a".repeat(64)],
);
await post(install.origin, "/api/auth/request-password-reset", { email });
const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/");
const handoff = await fetch(link, { redirect: "manual" });
const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? "";
await post(install.origin, "/api/auth/reset-password", {
newPassword: replacementPassword,
token,
});
const devices = await safeQuery<{ credential_hash: string; revoked_at: Date | null }>(
client,
'select "credential_hash", "revoked_at" from "ekklesia_core_device"',
);
expect(devices).toEqual([{ credential_hash: "a".repeat(64), revoked_at: null }]);
// The device table has no email or verification concept for a human flow to reach.
const columns = await safeQuery<{ column_name: string }>(
client,
"select column_name from information_schema.columns where table_name = 'ekklesia_core_device'",
);
const names = columns.map((column) => column.column_name).join(" ");
expect(names).not.toContain("email");
expect(names).not.toContain("verified");
expect(names).not.toContain("password");
} finally {
await client.end().catch(() => undefined);
}
});
it("sends a device no mail, because a device has no address to send to", async () => {
const install = await installation("devices-mail");
await post(install.origin, "/api/auth/request-password-reset", {
email: "[email protected]",
});
// Nothing about device identity participates in either flow.
expect((install.mail as MailHarness).delivered).toHaveLength(0);
expect(install.notificationFailures).toEqual([]);
});
});
+165 -4
View File
@@ -170,9 +170,13 @@ function bootstrapAgainst(url: string): BootstrapHandles {
}; };
} }
/** The production posture: public signup disabled, exactly as a real deployment must run. */ /**
async function startApiWithoutSignup(url: string): Promise<RunningApi> { * The production posture: public signup disabled, exactly as a real deployment must run — and, just
const config = createApiRuntimeConfig( * as deliberately, **no outbound email configured at all**. A fresh installation has no relay yet,
* which is the whole reason the initial owner is created already verified (ADR 0038).
*/
function ownerApiConfig(url: string): ReturnType<typeof createApiRuntimeConfig> {
return createApiRuntimeConfig(
{ {
databaseUrl: url, databaseUrl: url,
host: "127.0.0.1", host: "127.0.0.1",
@@ -186,7 +190,10 @@ async function startApiWithoutSignup(url: string): Promise<RunningApi> {
}, },
{ environment: "development" }, { environment: "development" },
); );
return startApi(config, { rateLimitOverride: { max: 1000, window: 1 } }); }
async function startApiWithoutSignup(url: string): Promise<RunningApi> {
return startApi(ownerApiConfig(url), { rateLimitOverride: { max: 1000, window: 1 } });
} }
function apiOrigin(running: RunningApi): string { function apiOrigin(running: RunningApi): string {
@@ -270,6 +277,22 @@ async function countRows(url: string, table: string): Promise<number> {
} }
} }
/** Better Auth's own `emailVerified` column — the same value its `/verify-email` endpoint writes. */
async function emailVerifiedFlag(url: string, email: string): Promise<boolean | undefined> {
const client = new Client({ connectionString: url });
await client.connect();
try {
const rows = await safeQuery<{ email_verified: boolean }>(
client,
'select "email_verified" from "ekklesia_core_user" where lower("email") = $1',
[email.trim().toLowerCase()],
);
return rows[0]?.email_verified;
} finally {
await client.end().catch(() => undefined);
}
}
afterAll(async () => { afterAll(async () => {
const admin = await openAdministrativeClient().catch(() => undefined); const admin = await openAdministrativeClient().catch(() => undefined);
if (!admin) { if (!admin) {
@@ -685,3 +708,141 @@ describe.sequential("initial platform-owner bootstrap", () => {
}); });
}); });
}); });
describe.sequential("initial platform-owner bootstrap and email verification", () => {
/**
* The trusted-bootstrap verification exception (ADR 0038).
*
* A verified address became a precondition for holding a session in Phase 7C Pass 2. The initial
* owner is created on a freshly provisioned server, by an operator holding the database
* credentials, at a moment when SMTP may not exist — so requiring an email round trip here would
* produce a working installation nobody can ever sign in to. These scenarios exist to prove that
* the exception is real, that it is narrow, and that mail remains genuinely absent throughout.
*/
it("creates the owner already verified, proven in the database and in the browser", async () => {
const url = await freshInstallation("verifiedowner");
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
const bootstrap = bootstrapAgainst(url);
try {
await bootstrap.run(email);
} finally {
await bootstrap.close();
}
// Database state: the exact column Better Auth's own verification endpoint writes, so the row
// is indistinguishable from one that verified normally.
expect(await emailVerifiedFlag(url, email)).toBe(true);
// ...and the browser-facing consequence, which is the only thing that actually matters.
const api = await startApiWithoutSignup(url);
try {
const origin = apiOrigin(api);
const session = await signIn(origin, email, ownerPassword);
expect(session.response.status).toBe(200);
expect(session.cookiePair).toBeDefined();
} finally {
await api.shutdown();
}
});
it("needs no outbound email configured, at bootstrap or at sign-in", async () => {
const url = await freshInstallation("nomailowner");
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
const bootstrap = bootstrapAgainst(url);
try {
// No SMTP host, no relay, no network: `createBootstrapIdentityCreator` constructs its own
// Better Auth instance with no `emailVerification` configuration at all.
await bootstrap.run(email);
} finally {
await bootstrap.close();
}
const api = await startApiWithoutSignup(url);
try {
// The API this suite starts configures no mail either, so nothing in this scenario could have
// sent or received a message.
expect(ownerApiConfig(url).mail).toBeUndefined();
expect((await signIn(apiOrigin(api), email, ownerPassword)).response.status).toBe(200);
} finally {
await api.shutdown();
}
});
it("marks an adopted identity verified too, so bootstrap never yields an owner who cannot sign in", async () => {
const url = await freshInstallation("adoptverified");
const email = `owner-${randomBytes(4).toString("hex")}@example.test`;
// An identity that exists before bootstrap runs — created here through the same trusted creator,
// which always writes `emailVerified: false`.
const first = bootstrapAgainst(url);
try {
await first.run(email);
} finally {
await first.close();
}
const client = new Client({ connectionString: url });
await client.connect();
try {
await safeQuery(client, 'update "ekklesia_core_user" set "email_verified" = false');
await safeQuery(client, 'delete from "ekklesia_core_platform_owner_bootstrap"');
await safeQuery(client, 'delete from "ekklesia_core_platform_owner"');
} finally {
await client.end().catch(() => undefined);
}
expect(await emailVerifiedFlag(url, email)).toBe(false);
const second = bootstrapAgainst(url);
let adopted: Awaited<ReturnType<typeof createInitialPlatformOwner>>;
try {
adopted = await second.run(email);
} finally {
await second.close();
}
expect(adopted.identity).toBe("adopted");
expect(await emailVerifiedFlag(url, email)).toBe(true);
});
it("verifies only the owner, leaving every other account subject to the normal policy", async () => {
const url = await freshInstallation("narrowexception");
const ownerEmail = `owner-${randomBytes(4).toString("hex")}@example.test`;
const otherEmail = `member-${randomBytes(4).toString("hex")}@example.test`;
const bootstrap = bootstrapAgainst(url);
try {
await bootstrap.run(ownerEmail);
// A second identity created through the identical trusted creator, but never bootstrapped.
const identity = createIdentityDrizzleDatabase(url, {});
try {
await createBootstrapIdentityCreator(identity.db)({
email: otherEmail,
name: "Ordinary Member",
password: ownerPassword,
});
} finally {
await identity.close().catch(() => undefined);
}
} finally {
await bootstrap.close();
}
expect(await emailVerifiedFlag(url, ownerEmail)).toBe(true);
expect(await emailVerifiedFlag(url, otherEmail)).toBe(false);
const api = await startApiWithoutSignup(url);
try {
const origin = apiOrigin(api);
expect((await signIn(origin, ownerEmail, ownerPassword)).response.status).toBe(200);
// The exception did not widen: an ordinary account still has to verify, and on this
// mail-less installation it simply cannot sign in yet.
const refused = await signIn(origin, otherEmail, ownerPassword);
expect(refused.response.status).toBe(403);
expect(await refused.response.json()).toMatchObject({ code: "EMAIL_NOT_VERIFIED" });
expect(refused.cookiePair).toBeUndefined();
} finally {
await api.shutdown();
}
});
});
@@ -224,6 +224,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0004_module_lifecycle_state", "core:0004_module_lifecycle_state",
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
]); ]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally { } finally {
@@ -253,6 +254,7 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
"core:0004_module_lifecycle_state", "core:0004_module_lifecycle_state",
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
"db-facade:0000_initial", "db-facade:0000_initial",
]); ]);
expect(aggregate.migrations[0]?.checksum).toBe( expect(aggregate.migrations[0]?.checksum).toBe(
@@ -319,6 +321,27 @@ async function signUp(origin: string, email: string, password: string): Promise<
return { userId: body.user.id }; return { userId: body.user.id };
} }
/**
* Marks an address verified out of band, the same trusted step the initial-owner bootstrap performs.
*
* A verified address is a precondition for holding a session (ADR 0038), and this suite is about
* module database access rather than identity, so it completes that step directly rather than
* standing up an SMTP server. That the policy is genuinely enforced is asserted in
* `email-verification.integration.ts`.
*/
async function markEmailVerified(url: string, email: string): Promise<void> {
const client = new Client({ connectionString: url });
await client.connect();
try {
await client.query(
'update "ekklesia_core_user" set "email_verified" = true where lower("email") = $1',
[email.trim().toLowerCase()],
);
} finally {
await client.end().catch(() => undefined);
}
}
async function signIn(origin: string, email: string, password: string): Promise<string> { async function signIn(origin: string, email: string, password: string): Promise<string> {
const response = await fetch(`${origin}/api/auth/sign-in/email`, { const response = await fetch(`${origin}/api/auth/sign-in/email`, {
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password }),
@@ -507,6 +530,7 @@ describe.sequential("live API — module database access through ModuleRuntimeSe
expect(startupRow).toEqual({ id: STARTUP_RECORD_ID, value: "startup-value" }); expect(startupRow).toEqual({ id: STARTUP_RECORD_ID, value: "startup-value" });
const signedUp = await signUp(origin, "[email protected]", "correct horse battery"); const signedUp = await signUp(origin, "[email protected]", "correct horse battery");
await markEmailVerified(database.url, "[email protected]");
ownerCookie = await signIn(origin, "[email protected]", "correct horse battery"); ownerCookie = await signIn(origin, "[email protected]", "correct horse battery");
expect(signedUp.userId).toBeDefined(); expect(signedUp.userId).toBeDefined();
}); });
@@ -0,0 +1,20 @@
-- Email verification becomes a precondition for holding a session (ADR 0038). Any platform owner
-- that already exists was created by the trusted operator-run bootstrap command before that command
-- marked addresses verified, so without this they would be locked out of their own installation by
-- an upgrade.
--
-- Deliberately narrow. It marks exactly the identities the authorization tables already record as
-- currently-granted platform owners: an authority the deployment established through a command that
-- required database credentials, which is the same authority the bootstrap verification exception
-- rests on. Every other account is left untouched and must verify by email like any other.
--
-- Data-only and idempotent. It adds no column, drops nothing, and re-running it changes no row that
-- is not already false.
UPDATE "ekklesia_core_user"
SET "email_verified" = true
WHERE "email_verified" = false
AND "id" IN (
SELECT "user_id"
FROM "ekklesia_core_platform_owner"
WHERE "revoked_at" IS NULL
);
File diff suppressed because it is too large Load Diff
+6
View File
@@ -43,6 +43,12 @@
"destructive": { "destructive": {
"mode": "none" "mode": "none"
} }
},
{
"tag": "0007_verified_bootstrap_owners",
"destructive": {
"mode": "none"
}
} }
] ]
} }
+7
View File
@@ -50,6 +50,13 @@
"when": 1787543773057, "when": 1787543773057,
"tag": "0006_device_identity", "tag": "0006_device_identity",
"breakpoints": true "breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1787543773058,
"tag": "0007_verified_bootstrap_owners",
"breakpoints": true
} }
] ]
} }
+45
View File
@@ -14,6 +14,7 @@ export type InitialPlatformOwnerErrorCode =
| "INITIAL_OWNER_EMAIL_INVALID" | "INITIAL_OWNER_EMAIL_INVALID"
| "INITIAL_OWNER_IDENTITY_NOT_CREATED" | "INITIAL_OWNER_IDENTITY_NOT_CREATED"
| "INITIAL_OWNER_IDENTITY_UNUSABLE" | "INITIAL_OWNER_IDENTITY_UNUSABLE"
| "INITIAL_OWNER_IDENTITY_NOT_VERIFIED"
| "INITIAL_OWNER_OWNERSHIP_GRANT_FAILED" | "INITIAL_OWNER_OWNERSHIP_GRANT_FAILED"
| "INITIAL_OWNER_PASSWORD_TOO_LONG" | "INITIAL_OWNER_PASSWORD_TOO_LONG"
| "INITIAL_OWNER_PASSWORD_TOO_SHORT" | "INITIAL_OWNER_PASSWORD_TOO_SHORT"
@@ -28,6 +29,8 @@ const initialOwnerMessages: Readonly<Record<InitialPlatformOwnerErrorCode, strin
"The identity system reported success but no account exists for that address. Nothing was changed; the bootstrap is still available.", "The identity system reported success but no account exists for that address. Nothing was changed; the bootstrap is still available.",
INITIAL_OWNER_IDENTITY_UNUSABLE: INITIAL_OWNER_IDENTITY_UNUSABLE:
"An account already exists for that address but has no password and could never sign in. Choose a different address, or remove that account before retrying.", "An account already exists for that address but has no password and could never sign in. Choose a different address, or remove that account before retrying.",
INITIAL_OWNER_IDENTITY_NOT_VERIFIED:
"The identity exists but could not be marked as a verified address, so it could not sign in. Nothing was granted; the bootstrap is still available.",
INITIAL_OWNER_OWNERSHIP_GRANT_FAILED: INITIAL_OWNER_OWNERSHIP_GRANT_FAILED:
"The identity exists but platform ownership was not granted. Bootstrap is still available: run this command again with the same email address to finish it.", "The identity exists but platform ownership was not granted. Bootstrap is still available: run this command again with the same email address to finish it.",
INITIAL_OWNER_PASSWORD_TOO_LONG: "The password is longer than the identity system allows.", INITIAL_OWNER_PASSWORD_TOO_LONG: "The password is longer than the identity system allows.",
@@ -131,6 +134,25 @@ async function findIdentityByEmail(
return { hasCredential: credentials.length > 0, storedEmail: found.email, userId: found.id }; return { hasCredential: credentials.length > 0, storedEmail: found.email, userId: found.id };
} }
/**
* Marks one identity's address verified.
*
* A direct column update rather than an identity-system call, because the pinned release exposes no
* supported way to mark an address verified other than consuming a token it emailed — which is the
* one thing a pre-mail installation cannot do. The column is Better Auth's own `emailVerified`, the
* exact value its `/verify-email` endpoint writes, so the resulting row is indistinguishable from
* one that verified normally; nothing here invents a parallel notion of verification.
*/
async function markIdentityEmailVerified(
db: IdentityDrizzleDatabase,
userId: string,
): Promise<void> {
await db
.update(ekklesiaCoreUser)
.set({ emailVerified: true })
.where(eq(ekklesiaCoreUser.id, userId));
}
function translateIdentityCreationError(error: unknown): InitialPlatformOwnerError { function translateIdentityCreationError(error: unknown): InitialPlatformOwnerError {
if (error instanceof BootstrapIdentityError) { if (error instanceof BootstrapIdentityError) {
if (error.code === "BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT") { if (error.code === "BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT") {
@@ -252,6 +274,29 @@ export async function createInitialPlatformOwner(
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_CREATED"); throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_CREATED");
} }
/**
* The trusted-bootstrap verification exception (ADR 0038).
*
* Every other human account must verify its address by email before it may hold a session.
* This one cannot: it is created on a freshly provisioned server by an operator who already
* holds the database credentials, at a moment when SMTP may not be configured, may not be
* reachable, or may not yet have a DNS record to send from. Requiring an email round trip here
* would mean a working installation whose administrator can never get in — the platform's
* outbound mail would have to be configured and correct before anyone could sign in to
* configure anything.
*
* The exception is narrow and its authority is the same authority that already makes this
* command safe: possession of the database, not possession of the mailbox. It marks exactly one
* address, exactly once per installation, and it is deliberately performed **before** ownership
* is granted — if it fails, the sentinel and the grant roll back together and re-running the
* command adopts the identity and finishes, rather than leaving an owner who cannot sign in.
*/
try {
await markIdentityEmailVerified(dependencies.identityDb, parsedUserId.data);
} catch {
throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_VERIFIED");
}
try { try {
const grant = const grant =
dependencies.grantOwnership ?? dependencies.grantOwnership ??
+2 -1
View File
@@ -116,6 +116,7 @@ export interface DoctorReport {
export interface DoctorIdentityConfigurationOptions { export interface DoctorIdentityConfigurationOptions {
readonly baseUrl?: string | undefined; readonly baseUrl?: string | undefined;
readonly dashboardOrigin?: string | undefined;
readonly environment?: CreateIdentityConfigOptions["environment"]; readonly environment?: CreateIdentityConfigOptions["environment"];
readonly publicSignupEnabled?: string | undefined; readonly publicSignupEnabled?: string | undefined;
readonly secret?: string | undefined; readonly secret?: string | undefined;
@@ -1193,7 +1194,7 @@ export async function diagnoseEkklesia(
"skipped", "skipped",
"Outbound email not configured", "Outbound email not configured",
[ [
"No EKKLESIA_SMTP_HOST is set. This is a complete deployment: nothing in the platform sends email yet.", "No EKKLESIA_SMTP_HOST is set. The platform runs and the bootstrap owner can sign in, but email verification and account recovery cannot deliver.",
], ],
); );
} else { } else {
+4
View File
@@ -26,6 +26,9 @@ export type HostEnvironmentName = "development" | "production";
*/ */
export interface HostIdentityEnvironmentInput { export interface HostIdentityEnvironmentInput {
readonly baseUrl: string | undefined; readonly baseUrl: string | undefined;
/** Where an emailed verification or reset link sends a browser. Unset means the Dashboard is
* served from `baseUrl`, which is the ordinary same-origin production shape (ADR 0038). */
readonly dashboardOrigin: string | undefined;
readonly publicSignupEnabled: string | undefined; readonly publicSignupEnabled: string | undefined;
readonly secret: string | undefined; readonly secret: string | undefined;
readonly trustedOrigins: readonly string[] | undefined; readonly trustedOrigins: readonly string[] | undefined;
@@ -118,6 +121,7 @@ export function loadHostConfiguration(environment: HostEnvironment): HostConfigu
environment: environment.NODE_ENV === "development" ? "development" : "production", environment: environment.NODE_ENV === "development" ? "development" : "production",
identity: { identity: {
baseUrl: environment.EKKLESIA_IDENTITY_BASE_URL, baseUrl: environment.EKKLESIA_IDENTITY_BASE_URL,
dashboardOrigin: environment.EKKLESIA_DASHBOARD_ORIGIN,
publicSignupEnabled: environment.EKKLESIA_PUBLIC_SIGNUP_ENABLED, publicSignupEnabled: environment.EKKLESIA_PUBLIC_SIGNUP_ENABLED,
secret: environment.EKKLESIA_IDENTITY_SECRET, secret: environment.EKKLESIA_IDENTITY_SECRET,
trustedOrigins: splitList(environment.EKKLESIA_IDENTITY_TRUSTED_ORIGINS), trustedOrigins: splitList(environment.EKKLESIA_IDENTITY_TRUSTED_ORIGINS),
+80 -1
View File
@@ -5,6 +5,10 @@ export type IdentityConfigErrorCode =
| "BASE_URL_INVALID" | "BASE_URL_INVALID"
| "BASE_URL_LOOPBACK_IN_PRODUCTION" | "BASE_URL_LOOPBACK_IN_PRODUCTION"
| "BASE_URL_MISSING" | "BASE_URL_MISSING"
| "DASHBOARD_ORIGIN_INSECURE_IN_PRODUCTION"
| "DASHBOARD_ORIGIN_INVALID"
| "DASHBOARD_ORIGIN_LOOPBACK_IN_PRODUCTION"
| "DASHBOARD_ORIGIN_UNTRUSTED"
| "PUBLIC_SIGNUP_ENABLED_INVALID" | "PUBLIC_SIGNUP_ENABLED_INVALID"
| "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION" | "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION"
| "SECRET_LOW_ENTROPY" | "SECRET_LOW_ENTROPY"
@@ -127,6 +131,13 @@ const errorMessages: Readonly<Record<IdentityConfigErrorCode, string>> = Object.
BASE_URL_LOOPBACK_IN_PRODUCTION: BASE_URL_LOOPBACK_IN_PRODUCTION:
"The identity base URL must not resolve to a loopback host in production.", "The identity base URL must not resolve to a loopback host in production.",
BASE_URL_MISSING: "The identity base URL is required.", BASE_URL_MISSING: "The identity base URL is required.",
DASHBOARD_ORIGIN_INSECURE_IN_PRODUCTION: "The Dashboard origin must use https in production.",
DASHBOARD_ORIGIN_INVALID:
"The Dashboard origin must be an absolute http or https origin with no path, query, or fragment.",
DASHBOARD_ORIGIN_LOOPBACK_IN_PRODUCTION:
"The Dashboard origin must not resolve to a loopback host in production.",
DASHBOARD_ORIGIN_UNTRUSTED:
"The Dashboard origin must also be listed as a trusted origin, or emailed links will be refused.",
PUBLIC_SIGNUP_ENABLED_INVALID: PUBLIC_SIGNUP_ENABLED_INVALID:
'Public signup enablement must be an explicit boolean or the exact string "true"/"false".', 'Public signup enablement must be an explicit boolean or the exact string "true"/"false".',
PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION: PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION:
@@ -163,6 +174,21 @@ export class IdentityConfigError extends Error {
export interface IdentityConfig { export interface IdentityConfig {
readonly baseUrl: string; readonly baseUrl: string;
/**
* The browser origin an emailed verification or password-reset link sends a person to.
*
* Defaults to `baseUrl`, which is the ordinary production shape: the Dashboard bundle and the API
* are served from one origin. It exists as its own value because they are not the same *concept* —
* `baseUrl` is where the `/api/auth` transport lives, this is where a human's browser lands — and
* a deployment may separate them (an API at `https://api.example.org`, the Dashboard at
* `https://dash.example.org`). It is deliberately **not** the public church website origin: this
* is the authenticated Dashboard host and nothing else.
*
* Validated to be one of `trustedOrigins` (or `baseUrl`, which Better Auth trusts implicitly), so
* a misconfiguration fails at startup rather than producing emailed links every recipient is
* refused at.
*/
readonly dashboardOrigin: string;
/** Whether `POST /api/auth/sign-up/email` accepts new accounts. Defaults to `false` (disabled) — /** Whether `POST /api/auth/sign-up/email` accepts new accounts. Defaults to `false` (disabled) —
* public signup never becomes reachable merely because this input was omitted, misspelled, or * public signup never becomes reachable merely because this input was omitted, misspelled, or
* loosely parsed. Development and test composition may enable it explicitly; production * loosely parsed. Development and test composition may enable it explicitly; production
@@ -176,6 +202,8 @@ export interface IdentityConfig {
export interface IdentityConfigInput { export interface IdentityConfigInput {
readonly baseUrl?: unknown; readonly baseUrl?: unknown;
/** Optional. Absent or empty means the Dashboard is served from `baseUrl`. */
readonly dashboardOrigin?: unknown;
readonly publicSignupEnabled?: unknown; readonly publicSignupEnabled?: unknown;
readonly secret?: unknown; readonly secret?: unknown;
readonly trustedOrigins?: unknown; readonly trustedOrigins?: unknown;
@@ -261,6 +289,45 @@ function validateBaseUrl(baseUrl: unknown, environment: IdentityEnvironment): st
return url.origin; return url.origin;
} }
/**
* Resolves the origin an emailed link points a browser at.
*
* Unset falls back to the identity base URL — the same-origin production shape — rather than
* failing, because a deployment that has not separated its Dashboard from its API has nothing to
* configure. When it *is* set it is held to exactly the base URL's rules and then to one more: it
* must be an origin Better Auth already trusts, because Better Auth validates the emailed callback
* against `trustedOrigins` before redirecting. Checking it here converts a broken link every
* recipient would hit into a startup failure the operator sees once.
*/
function validateDashboardOrigin(
dashboardOrigin: unknown,
baseUrl: string,
trustedOrigins: readonly string[],
environment: IdentityEnvironment,
): string {
if (dashboardOrigin === undefined || dashboardOrigin === null || dashboardOrigin === "") {
return baseUrl;
}
const url = parseCanonicalHttpOrigin(dashboardOrigin);
if (!url) {
throw new IdentityConfigError("DASHBOARD_ORIGIN_INVALID");
}
if (environment === "production") {
if (url.protocol !== "https:") {
throw new IdentityConfigError("DASHBOARD_ORIGIN_INSECURE_IN_PRODUCTION");
}
if (isLoopbackOrUnspecifiedHostname(url.hostname)) {
throw new IdentityConfigError("DASHBOARD_ORIGIN_LOOPBACK_IN_PRODUCTION");
}
}
// `baseUrl` is accepted without appearing in `trustedOrigins` because Better Auth itself adds the
// base URL's origin to its trusted set (`getTrustedOrigins`, pinned release).
if (url.origin !== baseUrl && !trustedOrigins.includes(url.origin)) {
throw new IdentityConfigError("DASHBOARD_ORIGIN_UNTRUSTED");
}
return url.origin;
}
function validateTrustedOrigin(origin: unknown, environment: IdentityEnvironment): string { function validateTrustedOrigin(origin: unknown, environment: IdentityEnvironment): string {
if (typeof origin !== "string" || origin.length === 0) { if (typeof origin !== "string" || origin.length === 0) {
throw new IdentityConfigError("TRUSTED_ORIGIN_INVALID"); throw new IdentityConfigError("TRUSTED_ORIGIN_INVALID");
@@ -355,7 +422,19 @@ export function createIdentityConfig(
const secret = validateSecret(input.secret); const secret = validateSecret(input.secret);
const baseUrl = validateBaseUrl(input.baseUrl, environment); const baseUrl = validateBaseUrl(input.baseUrl, environment);
const trustedOrigins = validateTrustedOrigins(input.trustedOrigins, environment); const trustedOrigins = validateTrustedOrigins(input.trustedOrigins, environment);
const dashboardOrigin = validateDashboardOrigin(
input.dashboardOrigin,
baseUrl,
trustedOrigins,
environment,
);
const publicSignupEnabled = validatePublicSignupEnabled(input.publicSignupEnabled, environment); const publicSignupEnabled = validatePublicSignupEnabled(input.publicSignupEnabled, environment);
return Object.freeze({ baseUrl, publicSignupEnabled, secret, trustedOrigins }); return Object.freeze({
baseUrl,
dashboardOrigin,
publicSignupEnabled,
secret,
trustedOrigins,
});
} }
+1
View File
@@ -2,4 +2,5 @@ export * from "./bootstrap-identity.js";
export * from "./client-address.js"; export * from "./client-address.js";
export * from "./config.js"; export * from "./config.js";
export * from "./database.js"; export * from "./database.js";
export * from "./notifications.js";
export * from "./runtime.js"; export * from "./runtime.js";
+291
View File
@@ -0,0 +1,291 @@
import { MailError, type MailProvider, validateMailMessage } from "../mail/index.js";
/**
* The identity feature's outbound messages, and the seam between Better Auth's callbacks and the
* host's mail transport (ADR 0038).
*
* ## What this owns and what it deliberately does not
*
* It owns the recipient, the subject, and the two bodies. It does **not** know the SMTP host, the
* port, the TLS mode, a credential, or the sender: those are host configuration held by the mail
* provider, and there is no field on the message contract through which a caller could choose any
* of them (ADR 0037). Swapping SMTP for a provider HTTP API would not change a line of this file.
*
* It also does not own tokens. Better Auth mints, persists (for reset), expires, and consumes them;
* this receives one already-minted token and puts it in a link. There is no Ekklesia token table,
* no Ekklesia token hashing, and no Ekklesia expiry clock — a second implementation of any of those
* would be a second thing to get wrong.
*
* ## Why the link is rebuilt rather than used as given
*
* Better Auth hands these callbacks a prebuilt `url`, and that URL embeds whatever `callbackURL` or
* `redirectTo` the *requesting client* supplied. Better Auth does validate those against its trusted
* origins, and that validation is sound — but an emailed link is the one artifact a person is most
* likely to click without looking, so its destination is derived here from validated configuration
* alone and from nothing a request could influence. The token is the only value taken from Better
* Auth. No request parameter can steer where a recipient lands, independently of whether the origin
* check behaves as expected.
*/
/** What could not be delivered, for an operator — never who it was for, and never the link. */
export type IdentityNotificationKind = "password-reset" | "email-verification";
export interface IdentityNotificationFailure {
readonly kind: IdentityNotificationKind;
/** A sanitized `MailError` code, or `MAIL_SEND_FAILED` for anything unrecognized. */
readonly reason: string;
}
/**
* Where a delivery failure is reported.
*
* It exists because these callbacks must **never throw**. Better Auth's own
* `/send-verification-email` rethrows whatever the callback threw, and a thrown error there turns
* "this address has an unverified account" into a distinguishable response — an account-enumeration
* oracle that appears the moment SMTP breaks. So failure is swallowed at this boundary and surfaced
* out of band instead, where no HTTP response shape depends on it.
*
* The default implementation writes one fixed line per failure. It carries the flow and a sanitized
* code and nothing else: no address, no token, no URL, no provider text.
*/
export type IdentityNotificationReporter = (failure: IdentityNotificationFailure) => void;
export interface IdentityNotificationRecipient {
readonly email: string;
readonly name?: string;
}
export interface CreateIdentityNotificationsOptions {
/**
* The host mail provider, or `undefined` when this installation has none.
*
* `undefined` is a complete deployment (ADR 0037): the platform still runs, the initial owner
* still signs in, and only verification and recovery are unavailable — reported as
* `MAIL_NOT_CONFIGURED` through the reporter, never as a different HTTP response.
*/
readonly mail: MailProvider | undefined;
/** Where the `/api/auth` transport lives — the origin Better Auth's own endpoints are on. */
readonly authBaseUrl: string;
/** Where a human's browser lands. Validated to be a trusted origin (`./config.js`). */
readonly dashboardOrigin: string;
/** Defaults to a single fixed sanitized console line. */
readonly report?: IdentityNotificationReporter;
}
export interface IdentityNotifications {
/** Shaped for Better Auth's `emailVerification.sendVerificationEmail`. Never throws. */
readonly sendVerificationEmail: (data: {
readonly user: IdentityNotificationRecipient;
readonly token: string;
}) => Promise<void>;
/** Shaped for Better Auth's `emailAndPassword.sendResetPassword`. Never throws. */
readonly sendResetPassword: (data: {
readonly user: IdentityNotificationRecipient;
readonly token: string;
}) => Promise<void>;
}
/**
* The two query values the Dashboard bundle reads to decide which host-owned auth screen to show.
*
* They are query parameters on the Dashboard's root path rather than distinct paths deliberately:
* the shell is a static single-page bundle, and a deep path would require every deployment's static
* host to rewrite unknown paths to `index.html` before an emailed link worked at all. A query
* parameter works on any static host, including the repository's own development server.
*/
export const DASHBOARD_AUTH_LANDING_PARAMETER = "ekklesia_auth";
export const DASHBOARD_VERIFY_EMAIL_LANDING = "verify-email";
export const DASHBOARD_RESET_PASSWORD_LANDING = "reset-password";
function landingUrl(dashboardOrigin: string, landing: string): string {
const url = new URL(dashboardOrigin);
url.searchParams.set(DASHBOARD_AUTH_LANDING_PARAMETER, landing);
return url.href;
}
/**
* Better Auth's own verification URL, rebuilt from configuration.
*
* The shape is the pinned release's protocol exactly — `GET {authBase}/api/auth/verify-email?
* token=...&callbackURL=...` — so Better Auth remains the only thing that inspects, expires, or acts
* on the token. Only the destination is ours.
*/
function verificationUrl(options: CreateIdentityNotificationsOptions, token: string): string {
const callback = landingUrl(options.dashboardOrigin, DASHBOARD_VERIFY_EMAIL_LANDING);
return `${options.authBaseUrl}/api/auth/verify-email?token=${encodeURIComponent(
token,
)}&callbackURL=${encodeURIComponent(callback)}`;
}
/** Better Auth's reset callback, rebuilt the same way. It validates the token, then redirects the
* browser to the Dashboard landing with the token attached. */
function resetUrl(options: CreateIdentityNotificationsOptions, token: string): string {
const callback = landingUrl(options.dashboardOrigin, DASHBOARD_RESET_PASSWORD_LANDING);
return `${options.authBaseUrl}/api/auth/reset-password/${encodeURIComponent(
token,
)}?callbackURL=${encodeURIComponent(callback)}`;
}
/** Escapes the four characters that could otherwise close an element or attribute in the HTML body.
* Only the link and the greeting name are interpolated, and the name comes from the user record. */
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
/**
* The same rule `validateMailMessage` applies, expressed the same way it expresses it: a code-point
* scan rather than a regular expression, because a regex over a control-character range is itself
* something a linter (rightly) objects to.
*/
function hasControlCharacter(value: string): boolean {
for (const character of value) {
const codePoint = character.codePointAt(0);
if (codePoint === undefined || codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) {
return true;
}
}
return false;
}
/**
* A greeting that is safe to place in a message body.
*
* A stored display name is not trusted to be free of control characters — the message validator
* would reject the whole message for one stray carriage return, which would turn a cosmetic problem
* into an undeliverable password reset. So an unusable name simply falls back to no greeting.
*/
function greeting(name: string | undefined): string {
if (typeof name !== "string") {
return "Hello,";
}
const trimmed = name.trim();
if (trimmed.length === 0 || trimmed.length > 64 || hasControlCharacter(trimmed)) {
return "Hello,";
}
return `Hello ${trimmed},`;
}
const EXPIRY_SENTENCE = "This link expires in one hour.";
const UNEXPECTED_SENTENCE =
"If you did not expect this message, you can ignore it and nothing will change.";
interface MessageBodies {
readonly subject: string;
readonly text: string;
readonly html: string;
}
function verificationBodies(name: string | undefined, url: string): MessageBodies {
const lead = greeting(name);
return {
subject: "Verify your Ekklesia email address",
html: [
`<p>${escapeHtml(lead)}</p>`,
"<p>Confirm this email address so it can be used to sign in to Ekklesia.</p>",
`<p><a href="${escapeHtml(url)}">Verify this email address</a></p>`,
`<p>${EXPIRY_SENTENCE} ${escapeHtml(UNEXPECTED_SENTENCE)}</p>`,
].join("\n"),
text: [
lead,
"",
"Confirm this email address so it can be used to sign in to Ekklesia:",
"",
url,
"",
EXPIRY_SENTENCE,
UNEXPECTED_SENTENCE,
].join("\n"),
};
}
function resetBodies(name: string | undefined, url: string): MessageBodies {
const lead = greeting(name);
const closing = `${UNEXPECTED_SENTENCE} Your current password stays in place until a new one is chosen.`;
return {
subject: "Reset your Ekklesia password",
html: [
`<p>${escapeHtml(lead)}</p>`,
"<p>Someone asked to reset the password for this Ekklesia account.</p>",
`<p><a href="${escapeHtml(url)}">Choose a new password</a></p>`,
`<p>${EXPIRY_SENTENCE} ${escapeHtml(closing)}</p>`,
].join("\n"),
text: [
lead,
"",
"Someone asked to reset the password for this Ekklesia account. Choose a new one here:",
"",
url,
"",
EXPIRY_SENTENCE,
closing,
].join("\n"),
};
}
function defaultReporter(failure: IdentityNotificationFailure): void {
// One fixed line. The flow and a sanitized code are exactly what an operator needs in order to go
// look at `pnpm doctor`; the address, the token, and the link are exactly what must never be here.
console.error(`[identity] ${failure.kind} email was not delivered: ${failure.reason}`);
}
function failureReason(error: unknown): string {
return error instanceof MailError ? error.code : "MAIL_SEND_FAILED";
}
/**
* Builds the two Better Auth callbacks.
*
* Neither rejects. A delivery failure is reported through `report` and the callback resolves, so the
* HTTP response an unauthenticated caller sees is identical whether the address exists, whether it
* was already verified, and whether the relay was reachable.
*/
export function createIdentityNotifications(
options: CreateIdentityNotificationsOptions,
): IdentityNotifications {
const report = options.report ?? defaultReporter;
const deliver = async (
kind: IdentityNotificationKind,
recipient: IdentityNotificationRecipient,
bodies: MessageBodies,
): Promise<void> => {
if (!options.mail) {
report({ kind, reason: "MAIL_NOT_CONFIGURED" });
return;
}
try {
await options.mail.send(
validateMailMessage({
html: bodies.html,
subject: bodies.subject,
text: bodies.text,
to: recipient.email,
}),
);
} catch (error) {
report({ kind, reason: failureReason(error) });
}
};
const notifications: IdentityNotifications = {
sendResetPassword: async (data) => {
await deliver(
"password-reset",
data.user,
resetBodies(data.user.name, resetUrl(options, data.token)),
);
},
sendVerificationEmail: async (data) => {
await deliver(
"email-verification",
data.user,
verificationBodies(data.user.name, verificationUrl(options, data.token)),
);
},
};
return Object.freeze(notifications);
}
+119 -5
View File
@@ -9,6 +9,7 @@ import { getCookies } from "better-auth/cookies";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import type { IdentityConfig } from "./config.js"; import type { IdentityConfig } from "./config.js";
import type { IdentityDrizzleDatabase } from "./database.js"; import type { IdentityDrizzleDatabase } from "./database.js";
import type { IdentityNotifications } from "./notifications.js";
import { ekklesiaCoreSession, identityAdapterSchema } from "./schema.js"; import { ekklesiaCoreSession, identityAdapterSchema } from "./schema.js";
export type IdentityRuntimeErrorCode = export type IdentityRuntimeErrorCode =
@@ -46,6 +47,16 @@ export const TRUSTED_CLIENT_ADDRESS_HEADER = "x-ekklesia-client-address";
*/ */
const sanitizedBetterAuthLogMessage = "[identity] Better Auth reported an error."; const sanitizedBetterAuthLogMessage = "[identity] Better Auth reported an error.";
/**
* One hour for both, matching the pinned release's own defaults but stated rather than inherited.
*
* Short enough that a link sitting in a forwarded or archived mailbox stops working, long enough
* that someone who reads their mail after lunch is not sent back to the start. The message bodies
* state the same hour, so the two can be checked against each other.
*/
const EMAIL_VERIFICATION_TOKEN_TTL_SECONDS = 3600;
const RESET_PASSWORD_TOKEN_TTL_SECONDS = 3600;
/** /**
* Explicitly replaces Better Auth's default logger so its own message text and arguments — which * Explicitly replaces Better Auth's default logger so its own message text and arguments — which
* may include a raw exception, SQL, a connection string, a host/port, a filesystem path, a * may include a raw exception, SQL, a connection string, a host/port, a filesystem path, a
@@ -82,10 +93,29 @@ export interface CreateIdentityRuntimeOptions {
readonly config: IdentityConfig; readonly config: IdentityConfig;
readonly db: IdentityDrizzleDatabase; readonly db: IdentityDrizzleDatabase;
/** /**
* A narrow, explicit override for the sign-in/sign-up rate-limit window used only by test * How verification and password-reset messages are delivered.
* composition that issues many such requests quickly. Rate limiting itself is always enabled; *
* production composition must leave this unset so Better Auth's own strict built-in default * Injected rather than constructed here, for the same reason the database is: this file composes
* (3 requests / 10 seconds on sign-in and sign-up) applies unmodified. * Better Auth and must not decide what a message says or how it travels. Omitting it is a
* complete, valid deployment — email verification and password recovery are then configured
* exactly as they otherwise are, and each attempt reports `MAIL_NOT_CONFIGURED` out of band while
* every HTTP response keeps its usual shape (ADR 0038).
*
* Note what omitting it does **not** do: it does not relax `requireEmailVerification`. An
* installation with no mail cannot verify a new account, which is why the initial platform owner
* is created already verified by a trusted operator command rather than by an email round trip.
*/
readonly notifications?: IdentityNotifications;
/**
* A narrow, explicit override for the rate-limit windows on the four paths Better Auth treats
* more strictly than its global default, used only by test composition that issues many such
* requests quickly. Rate limiting itself is always enabled; production composition must leave this
* unset so Better Auth's own built-in defaults apply unmodified — 3 requests / 10 seconds on
* sign-in and sign-up, and 3 requests / 60 seconds on password-reset requests and verification
* resends, which are the two paths an attacker could otherwise use to send mail repeatedly.
*
* That limiter is per-process and in-memory (ADR 0038): it is a real abuse control for a single
* deployment and is deliberately **not** claimed to be a distributed one.
*/ */
readonly rateLimitOverride?: IdentityRateLimitOverride; readonly rateLimitOverride?: IdentityRateLimitOverride;
} }
@@ -264,9 +294,81 @@ export function createIdentityRuntime(
enabled: true, enabled: true,
// Pinned explicitly rather than left on Better Auth's fallback default (`|| 8` / `|| 128` // Pinned explicitly rather than left on Better Auth's fallback default (`|| 8` / `|| 128`
// in the pinned release): an implicit default can change silently on a future Better Auth // in the pinned release): an implicit default can change silently on a future Better Auth
// upgrade, and Ekklesia's own tests assert this exact 8–128 boundary. // upgrade, and Ekklesia's own tests assert this exact 8–128 boundary. The reset endpoint
// enforces the identical pair from the same configuration, so there is deliberately no
// separate reset-time password policy.
maxPasswordLength: 128, maxPasswordLength: 128,
minPasswordLength: 8, minPasswordLength: 8,
/**
* A person may not hold a session until their address is verified (ADR 0038).
*
* In the pinned release this is enforced *after* the password is checked and *before* any
* session is created: the attempt fails with `403 EMAIL_NOT_VERIFIED` and no cookie is set.
* That is the whole reason no "unverified principal" exists anywhere in this platform — a
* correct password on an unverified account produces no session at all, so there is no
* half-authenticated state for the Dashboard to misread. `/api/v1/principal` continues to
* describe only anonymous, authenticated human, and device.
*/
requireEmailVerification: true,
/** Pinned rather than left on the release's `|| 3600` fallback, for the same reason the
* password bounds are pinned. */
resetPasswordTokenExpiresIn: RESET_PASSWORD_TOKEN_TTL_SECONDS,
/**
* Every existing session for that account is deleted when its password is reset.
*
* Password reset is the recovery path someone uses precisely when they suspect their
* credential is compromised, and leaving the attacker's session alive would make the recovery
* cosmetic. This is Better Auth's own supported option (`deleteUserSessions` in the pinned
* release), not a Core reach into the session table.
*
* It deletes *all* of that human's sessions, including the browser that performed the reset —
* so the completion state returns to the sign-in form rather than pretending to be signed in.
* Managed device sessions are a separate Ekklesia store keyed by device, hold no email and no
* password, and are entirely unaffected (ADR 0036).
*/
revokeSessionsOnPasswordReset: true,
...(options.notifications
? { sendResetPassword: options.notifications.sendResetPassword }
: {}),
},
/**
* Verification is configured identically whether or not mail is deliverable, so the policy an
* installation enforces never depends on the state of its relay.
*/
emailVerification: {
/**
* No session is minted from following an emailed link.
*
* A verification URL travels through mail infrastructure and is routinely fetched by things
* that are not the recipient — link scanners, prefetchers, corporate mail gateways. Turning
* that GET into a logged-in browser session would hand a session to whoever, or whatever,
* dereferenced the link. Verification therefore only flips the flag, and the person signs in
* afterwards through the ordinary form.
*/
autoSignInAfterVerification: false,
/** Pinned rather than left on the release's `|| 3600` fallback. */
expiresIn: EMAIL_VERIFICATION_TOKEN_TTL_SECONDS,
/**
* A sign-in attempt against an unverified account re-sends the verification message.
*
* This is the resend path that costs nothing to secure: reaching it requires the correct
* password, so it reveals nothing an attacker did not already know, and it means the common
* case — the first link expired — is handled by the thing the person was already trying to do.
* The standalone `/send-verification-email` endpoint remains available for the case where they
* would rather not retype a password, and is enumeration-safe on its own.
*/
sendOnSignIn: true,
/**
* Pinned rather than left on the release's "follow `requireEmailVerification`" fallback.
*
* It reaches nothing in production, where signup is refused outright — but stating it means a
* development or test deployment that does enable signup behaves the way this platform
* intends, rather than the way an inherited default happens to.
*/
sendOnSignUp: true,
...(options.notifications
? { sendVerificationEmail: options.notifications.sendVerificationEmail }
: {}),
}, },
logger: { logger: {
disabled: false, disabled: false,
@@ -293,6 +395,18 @@ export function createIdentityRuntime(
...(rateLimitOverride ...(rateLimitOverride
? { ? {
customRules: { customRules: {
// The four paths Better Auth applies a stricter-than-global default to. Production
// composition never sets this override, so those defaults — 3 requests / 10 seconds
// on sign-in and sign-up, 3 requests / 60 seconds on the two paths that trigger an
// email — remain in force there, unmodified.
"/request-password-reset": {
max: rateLimitOverride.max ?? 100,
window: rateLimitOverride.window ?? 10,
},
"/send-verification-email": {
max: rateLimitOverride.max ?? 100,
window: rateLimitOverride.window ?? 10,
},
"/sign-in/email": { "/sign-in/email": {
max: rateLimitOverride.max ?? 100, max: rateLimitOverride.max ?? 100,
window: rateLimitOverride.window ?? 10, window: rateLimitOverride.window ?? 10,
@@ -177,6 +177,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0004_module_lifecycle_state", "core:0004_module_lifecycle_state",
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
]); ]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally { } finally {
@@ -176,6 +176,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0004_module_lifecycle_state", "core:0004_module_lifecycle_state",
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
]); ]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally { } finally {
+1 -1
View File
@@ -126,7 +126,7 @@ Object storage
Outbound email Outbound email
○ Outbound email not configured ○ Outbound email not configured
No EKKLESIA_SMTP_HOST is set. This is a complete deployment: nothing in the platform sends email yet. No EKKLESIA_SMTP_HOST is set. The platform runs and the bootstrap owner can sign in, but email verification and account recovery cannot deliver.
Result: READY WITH WARNINGS`); Result: READY WITH WARNINGS`);
}); });
@@ -62,6 +62,7 @@ const INTERPRETED_VARIABLES = Object.freeze([
"EKKLESIA_CLIENT_ADDRESS_HEADER", "EKKLESIA_CLIENT_ADDRESS_HEADER",
"EKKLESIA_CLIENT_ADDRESS_MODE", "EKKLESIA_CLIENT_ADDRESS_MODE",
"EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES", "EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES",
"EKKLESIA_DASHBOARD_ORIGIN",
"EKKLESIA_DATABASE_CA", "EKKLESIA_DATABASE_CA",
"EKKLESIA_DATABASE_TLS_MODE", "EKKLESIA_DATABASE_TLS_MODE",
"EKKLESIA_IDENTITY_BASE_URL", "EKKLESIA_IDENTITY_BASE_URL",
+108
View File
@@ -26,6 +26,9 @@ describe("Core identity configuration", () => {
expect(config).toEqual({ expect(config).toEqual({
baseUrl: validBaseUrl, baseUrl: validBaseUrl,
// Unset: the Dashboard is served from the identity base URL, which is the ordinary
// same-origin production shape (ADR 0038).
dashboardOrigin: validBaseUrl,
publicSignupEnabled: false, publicSignupEnabled: false,
secret: validSecret, secret: validSecret,
trustedOrigins: validTrustedOrigins, trustedOrigins: validTrustedOrigins,
@@ -419,3 +422,108 @@ describe("Core identity configuration", () => {
} }
}); });
}); });
describe("Core identity Dashboard origin", () => {
/**
* Where an emailed verification or reset link sends a browser (ADR 0038).
*
* The rules exist because a link in an email is the one artifact nobody inspects before clicking:
* it must land on the deployment's own Dashboard, over https in production, at an origin Better
* Auth will actually accept as a redirect target — and a misconfiguration must fail at startup
* rather than produce a link every recipient is refused at.
*/
it("defaults to the identity base URL, the ordinary same-origin production shape", () => {
expect(createIdentityConfig(validInput()).dashboardOrigin).toBe(validBaseUrl);
expect(createIdentityConfig({ ...validInput(), dashboardOrigin: "" }).dashboardOrigin).toBe(
validBaseUrl,
);
});
it("accepts a separate Dashboard origin that is also a trusted origin", () => {
const config = createIdentityConfig({
...validInput(),
dashboardOrigin: "https://dashboard.example.test",
});
expect(config.dashboardOrigin).toBe("https://dashboard.example.test");
// Separate from the API origin, deliberately: the two are different concepts and a deployment
// may split them.
expect(config.dashboardOrigin).not.toBe(config.baseUrl);
});
it("refuses an origin Better Auth would not accept as a redirect target", () => {
// The failure this prevents is silent and total: Better Auth validates the emailed callback
// against its trusted origins, so an untrusted Dashboard origin breaks every link it sends.
try {
createIdentityConfig({ ...validInput(), dashboardOrigin: "https://elsewhere.example.test" });
throw new Error("expected the untrusted Dashboard origin to be refused");
} catch (error) {
expect(error).toMatchObject({ code: "DASHBOARD_ORIGIN_UNTRUSTED" });
}
});
it("refuses anything that is not a canonical origin", () => {
for (const value of [
"dashboard.example.test",
"https://dashboard.example.test/",
"https://dashboard.example.test/dashboard",
"https://user:[email protected]",
"https://dashboard.example.test?a=b",
"javascript:alert(1)",
"//dashboard.example.test",
" https://dashboard.example.test",
42,
]) {
try {
createIdentityConfig({ ...validInput(), dashboardOrigin: value });
throw new Error(`expected ${String(value)} to be refused`);
} catch (error) {
expect(error).toMatchObject({ code: "DASHBOARD_ORIGIN_INVALID" });
}
}
});
it("requires https and a non-loopback host in production, and relaxes both in development", () => {
try {
createIdentityConfig(
// Structural and policy validity are checked before membership, so a plaintext origin is
// reported as plaintext rather than as "untrusted" — which would send an operator looking
// in the wrong place.
{ ...validInput(), dashboardOrigin: "http://dashboard.example.test" },
{ environment: "production" },
);
throw new Error("expected plaintext to be refused in production");
} catch (error) {
expect(error).toMatchObject({ code: "DASHBOARD_ORIGIN_INSECURE_IN_PRODUCTION" });
}
try {
createIdentityConfig(
{ ...validInput(), dashboardOrigin: "https://localhost" },
{ environment: "production" },
);
throw new Error("expected loopback to be refused in production");
} catch (error) {
expect(error).toMatchObject({ code: "DASHBOARD_ORIGIN_LOOPBACK_IN_PRODUCTION" });
}
const development = createIdentityConfig(
{
...developmentInput(),
dashboardOrigin: "http://localhost:5173",
trustedOrigins: ["http://localhost:3000", "http://localhost:5173"],
},
{ environment: "development" },
);
expect(development.dashboardOrigin).toBe("http://localhost:5173");
});
it("never carries the offending origin in the thrown error", () => {
try {
createIdentityConfig({ ...validInput(), dashboardOrigin: "https://evil.example.test" });
throw new Error("expected a refusal");
} catch (error) {
expect(JSON.stringify({ message: (error as Error).message })).not.toContain("evil");
}
});
});
+269
View File
@@ -0,0 +1,269 @@
import { describe, expect, it, vi } from "vitest";
import {
createIdentityNotifications,
type IdentityNotificationFailure,
} from "../src/identity/notifications.js";
import { MailError, type MailProvider, type ValidatedMailMessage } from "../src/mail/index.js";
/**
* The identity feature's two outbound messages (ADR 0038).
*
* The properties that matter here are all negative ones: that a link is built from configuration
* rather than from anything a request supplied, that a token never reaches a log, that a delivery
* failure never becomes an observable difference between "this address has an account" and "it does
* not", and that identity code learns nothing about SMTP.
*/
const authBaseUrl = "https://api.example.test";
const dashboardOrigin = "https://dashboard.example.test";
const token = "verification-token-abc123";
interface Harness {
readonly sent: ValidatedMailMessage[];
readonly failures: IdentityNotificationFailure[];
readonly notifications: ReturnType<typeof createIdentityNotifications>;
}
function harness(
options: { readonly mail?: MailProvider | undefined; readonly configured?: boolean } = {},
): Harness {
const sent: ValidatedMailMessage[] = [];
const failures: IdentityNotificationFailure[] = [];
const provider: MailProvider = options.mail ?? {
close: async () => undefined,
send: async (message) => {
sent.push(message);
return { messageId: "provider-message-id" };
},
verify: async () => undefined,
};
return {
failures,
notifications: createIdentityNotifications({
authBaseUrl,
dashboardOrigin,
mail: options.configured === false ? undefined : provider,
report: (failure) => failures.push(failure),
}),
sent,
};
}
function only(sent: readonly ValidatedMailMessage[]): ValidatedMailMessage {
expect(sent).toHaveLength(1);
return sent[0] as ValidatedMailMessage;
}
/** Both bodies, so an assertion can never pass because it only looked at the plaintext one. */
function bodies(message: ValidatedMailMessage): string {
return `${message.text ?? ""}\n${message.html ?? ""}`;
}
describe("identity verification email", () => {
it("sends one message to the account's own address, with both bodies", async () => {
const test = harness();
await test.notifications.sendVerificationEmail({
token,
user: { email: "[email protected]", name: "Sam" },
});
const message = only(test.sent);
expect(message.to).toEqual(["[email protected]"]);
expect(message.subject).toBe("Verify your Ekklesia email address");
expect(message.text).toContain("Hello Sam,");
expect(message.html).toContain("<p>Hello Sam,</p>");
expect(bodies(message)).toContain("This link expires in one hour.");
expect(bodies(message)).toContain("you can ignore it");
});
it("builds the link from configuration alone, on the Dashboard origin", async () => {
const test = harness();
await test.notifications.sendVerificationEmail({ token, user: { email: "[email protected]" } });
const text = only(test.sent).text ?? "";
const link = text.split("\n").find((line) => line.startsWith("https://")) ?? "";
const url = new URL(link);
// Better Auth's own endpoint, so Better Auth remains the only thing that acts on the token.
expect(url.origin).toBe(authBaseUrl);
expect(url.pathname).toBe("/api/auth/verify-email");
expect(url.searchParams.get("token")).toBe(token);
// ...and the destination it will redirect to is the Dashboard, never the API origin.
const callback = new URL(url.searchParams.get("callbackURL") ?? "");
expect(callback.origin).toBe(dashboardOrigin);
expect(callback.searchParams.get("ekklesia_auth")).toBe("verify-email");
});
it("never names the sender, the relay, or any transport detail", async () => {
const test = harness();
await test.notifications.sendVerificationEmail({ token, user: { email: "[email protected]" } });
// The message contract has no `from` field at all, so identity code cannot choose a sender.
expect(Object.keys(only(test.sent))).not.toContain("from");
const serialized = JSON.stringify(only(test.sent)).toLowerCase();
for (const forbidden of ["smtp", "starttls", "password", "587", "465"]) {
expect(serialized).not.toContain(forbidden);
}
});
});
describe("identity password-reset email", () => {
it("sends one message with a link on Better Auth's reset callback", async () => {
const test = harness();
await test.notifications.sendResetPassword({
token: "reset-token-xyz",
user: { email: "[email protected]", name: "Sam" },
});
const message = only(test.sent);
expect(message.to).toEqual(["[email protected]"]);
expect(message.subject).toBe("Reset your Ekklesia password");
const link = (message.text ?? "").split("\n").find((line) => line.startsWith("https://")) ?? "";
const url = new URL(link);
expect(url.origin).toBe(authBaseUrl);
expect(url.pathname).toBe("/api/auth/reset-password/reset-token-xyz");
const callback = new URL(url.searchParams.get("callbackURL") ?? "");
expect(callback.origin).toBe(dashboardOrigin);
expect(callback.searchParams.get("ekklesia_auth")).toBe("reset-password");
});
it("carries no password, hash, or session material", async () => {
const test = harness();
await test.notifications.sendResetPassword({
token: "reset-token-xyz",
user: { email: "[email protected]" },
});
const serialized = bodies(only(test.sent)).toLowerCase();
expect(serialized).not.toContain("hash");
expect(serialized).not.toContain("session");
// It does say the current password still works, which is the reassurance the flow needs.
expect(serialized).toContain("current password stays in place");
});
});
describe("identity notification failures", () => {
it("resolves rather than throwing when the relay fails, and reports a sanitized code", async () => {
const test = harness({
mail: {
close: async () => undefined,
send: async () => {
throw new MailError("MAIL_PROVIDER_UNAVAILABLE");
},
verify: async () => undefined,
},
});
// Resolving is the security property: Better Auth's own `/send-verification-email` rethrows
// whatever this throws, which would make "account exists" observable the moment SMTP broke.
await expect(
test.notifications.sendVerificationEmail({ token, user: { email: "[email protected]" } }),
).resolves.toBeUndefined();
expect(test.failures).toEqual([
{ kind: "email-verification", reason: "MAIL_PROVIDER_UNAVAILABLE" },
]);
});
it("collapses an unrecognized thrown value rather than forwarding it", async () => {
const test = harness({
mail: {
close: async () => undefined,
send: async () => {
throw new Error("connect ECONNREFUSED 10.40.0.2:587 [email protected] pass=hunter2");
},
verify: async () => undefined,
},
});
await test.notifications.sendResetPassword({ token: "t", user: { email: "[email protected]" } });
expect(test.failures).toEqual([{ kind: "password-reset", reason: "MAIL_SEND_FAILED" }]);
const serialized = JSON.stringify(test.failures);
expect(serialized).not.toContain("hunter2");
expect(serialized).not.toContain("10.40.0.2");
});
it("reports MAIL_NOT_CONFIGURED, and still resolves, when the installation has no mail", async () => {
const test = harness({ configured: false });
await test.notifications.sendVerificationEmail({ token, user: { email: "[email protected]" } });
await test.notifications.sendResetPassword({ token: "t", user: { email: "[email protected]" } });
expect(test.failures).toEqual([
{ kind: "email-verification", reason: "MAIL_NOT_CONFIGURED" },
{ kind: "password-reset", reason: "MAIL_NOT_CONFIGURED" },
]);
expect(test.sent).toHaveLength(0);
});
it("writes one fixed line by default, carrying neither the address nor the token", async () => {
const errors = vi.spyOn(console, "error").mockImplementation(() => undefined);
try {
const notifications = createIdentityNotifications({
authBaseUrl,
dashboardOrigin,
mail: undefined,
});
await notifications.sendResetPassword({
token: "reset-token-xyz",
user: { email: "[email protected]" },
});
expect(errors).toHaveBeenCalledTimes(1);
const line = String(errors.mock.calls[0]?.[0]);
expect(line).toBe("[identity] password-reset email was not delivered: MAIL_NOT_CONFIGURED");
expect(line).not.toContain("secret-person");
expect(line).not.toContain("reset-token-xyz");
} finally {
errors.mockRestore();
}
});
});
describe("identity notification message safety", () => {
it("escapes a display name rather than letting it close an element", async () => {
const test = harness();
await test.notifications.sendVerificationEmail({
token,
user: { email: "[email protected]", name: 'Sam"><script>alert(1)</script>' },
});
const message = only(test.sent);
expect(message.html).not.toContain("<script>");
expect(message.html).toContain("&lt;script&gt;");
});
it("drops an unusable display name instead of failing the whole message", async () => {
const test = harness();
// A stored name carrying a control character would be refused by the message validator, which
// would turn a cosmetic problem into an undeliverable password reset.
await test.notifications.sendResetPassword({
token: "t",
user: { email: "[email protected]", name: "Sam\r\nBcc: [email protected]" },
});
const message = only(test.sent);
expect(message.text).toContain("Hello,");
expect(message.text).not.toContain("[email protected]");
expect(test.failures).toHaveLength(0);
});
it("refuses to send to an address the mail contract rejects, without throwing", async () => {
const test = harness();
await test.notifications.sendVerificationEmail({
token,
user: { email: "[email protected], [email protected]" },
});
expect(test.sent).toHaveLength(0);
expect(test.failures).toEqual([
{ kind: "email-verification", reason: "MAIL_RECIPIENT_INVALID" },
]);
});
});
+57 -2
View File
@@ -20,6 +20,7 @@ import { identityAdapterSchema } from "../src/identity/schema.js";
function validConfig(overrides: Partial<IdentityConfig> = {}): IdentityConfig { function validConfig(overrides: Partial<IdentityConfig> = {}): IdentityConfig {
return Object.freeze({ return Object.freeze({
baseUrl: "https://identity.example.test", baseUrl: "https://identity.example.test",
dashboardOrigin: "https://identity.example.test",
publicSignupEnabled: false, publicSignupEnabled: false,
secret: "correct-horse-battery-staple-0123456789", secret: "correct-horse-battery-staple-0123456789",
trustedOrigins: Object.freeze(["https://identity.example.test"]), trustedOrigins: Object.freeze(["https://identity.example.test"]),
@@ -109,18 +110,21 @@ describe("Core identity runtime construction boundary", () => {
expect(options).toEqual({ provider: "pg", schema: identityAdapterSchema }); expect(options).toEqual({ provider: "pg", schema: identityAdapterSchema });
}); });
it("enables exactly email/password (autoSignIn disabled) and rate limiting, nothing else", () => { it("enables exactly email/password, verification, and rate limiting, nothing else", () => {
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
createIdentityRuntime({ config: validConfig(), db: fakeDb() }); createIdentityRuntime({ config: validConfig(), db: fakeDb() });
expect(betterAuthMock).toHaveBeenCalledTimes(1); expect(betterAuthMock).toHaveBeenCalledTimes(1);
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>]; const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
// Exhaustive on purpose: a new top-level Better Auth feature has to be added here deliberately,
// never by inheriting a default.
expect(Object.keys(options).sort()).toEqual([ expect(Object.keys(options).sort()).toEqual([
"advanced", "advanced",
"baseURL", "baseURL",
"database", "database",
"emailAndPassword", "emailAndPassword",
"emailVerification",
"logger", "logger",
"onAPIError", "onAPIError",
"rateLimit", "rateLimit",
@@ -133,13 +137,60 @@ describe("Core identity runtime construction boundary", () => {
enabled: true, enabled: true,
maxPasswordLength: 128, maxPasswordLength: 128,
minPasswordLength: 8, minPasswordLength: 8,
// A verified address is a precondition for holding a session (ADR 0038).
requireEmailVerification: true,
resetPasswordTokenExpiresIn: 3600,
// Password reset revokes that human's other sessions, so recovering from a compromise is not
// cosmetic.
revokeSessionsOnPasswordReset: true,
}); });
expect(options.emailVerification).toEqual({
// Following an emailed link never mints a session: mail infrastructure dereferences links.
autoSignInAfterVerification: false,
expiresIn: 3600,
sendOnSignIn: true,
sendOnSignUp: true,
});
// No `customRules`: production inherits Better Auth's own stricter per-path defaults.
expect(options.rateLimit).toEqual({ enabled: true, max: 100, window: 10 }); expect(options.rateLimit).toEqual({ enabled: true, max: 100, window: 10 });
expect(options.baseURL).toBe(validConfig().baseUrl); expect(options.baseURL).toBe(validConfig().baseUrl);
expect(options.secret).toBe(validConfig().secret); expect(options.secret).toBe(validConfig().secret);
expect(options.trustedOrigins).toEqual([...validConfig().trustedOrigins]); expect(options.trustedOrigins).toEqual([...validConfig().trustedOrigins]);
}); });
it("wires the injected notifier into both delivery callbacks, and neither without it", () => {
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
const sendResetPassword = vi.fn(async () => undefined);
const sendVerificationEmail = vi.fn(async () => undefined);
createIdentityRuntime({
config: validConfig(),
db: fakeDb(),
notifications: { sendResetPassword, sendVerificationEmail },
});
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
expect((options.emailAndPassword as Record<string, unknown>).sendResetPassword).toBe(
sendResetPassword,
);
expect((options.emailVerification as Record<string, unknown>).sendVerificationEmail).toBe(
sendVerificationEmail,
);
betterAuthMock.mockClear();
createIdentityRuntime({ config: validConfig(), db: fakeDb() });
const [withoutMail] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
// An installation with no mail configures the identical policy and simply cannot deliver.
// Crucially it does **not** relax `requireEmailVerification`.
expect(withoutMail.emailAndPassword).toMatchObject({ requireEmailVerification: true });
expect(
(withoutMail.emailAndPassword as Record<string, unknown>).sendResetPassword,
).toBeUndefined();
expect(
(withoutMail.emailVerification as Record<string, unknown>).sendVerificationEmail,
).toBeUndefined();
});
it("enables sign-up (disableSignUp: false) only when publicSignupEnabled is explicitly true", () => { it("enables sign-up (disableSignUp: false) only when publicSignupEnabled is explicitly true", () => {
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
@@ -254,7 +305,7 @@ describe("Core identity runtime construction boundary", () => {
} }
}); });
it("only widens the sign-in/sign-up rate-limit window when a narrow override is explicitly injected", () => { it("only widens the four strict rate-limit windows when a narrow override is explicitly injected", () => {
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
createIdentityRuntime({ createIdentityRuntime({
@@ -266,6 +317,10 @@ describe("Core identity runtime construction boundary", () => {
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>]; const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
expect(options.rateLimit).toEqual({ expect(options.rateLimit).toEqual({
customRules: { customRules: {
// The two paths that trigger an email are included, so a test suite driving many recovery
// requests is not throttled by Better Auth's 3-per-60-seconds default for them.
"/request-password-reset": { max: 1000, window: 1 },
"/send-verification-email": { max: 1000, window: 1 },
"/sign-in/email": { max: 1000, window: 1 }, "/sign-in/email": { max: 1000, window: 1 },
"/sign-up/email": { max: 1000, window: 1 }, "/sign-up/email": { max: 1000, window: 1 },
}, },
@@ -0,0 +1,192 @@
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
/**
* Structural guarantees about email verification and password recovery that no runtime assertion can
* make (ADR 0038).
*
* These read the repository itself, because what matters here is what code *exists* and what depends
* on what: that identity never learns an SMTP detail, that no token store was reimplemented
* alongside Better Auth's, that a device identity has no route into a human email flow, and that
* nothing in a browser bundle can reach any of it.
*/
const repositoryRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
async function readTypeScriptFiles(
directory: string,
): Promise<ReadonlyArray<{ readonly path: string; readonly text: string }>> {
const results: Array<{ path: string; text: string }> = [];
const walk = async (current: string): Promise<void> => {
for (const entry of await readdir(current, { withFileTypes: true })) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
if (entry.name === "node_modules" || entry.name === "dist") {
continue;
}
await walk(full);
continue;
}
if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
results.push({ path: full, text: await readFile(full, "utf8") });
}
}
};
await walk(path.join(repositoryRoot, directory));
return results;
}
/** Strips block comments and whole-line `//` comments: these assertions are about code, and prose
* explaining a rule inevitably quotes the token the rule forbids. */
function withoutComments(text: string): string {
return text
.replaceAll(/\/\*[\s\S]*?\*\//gu, "")
.split("\n")
.filter((line) => !line.trimStart().startsWith("//"))
.join("\n");
}
describe("identity email flows stay above the mail transport", () => {
it("names no SMTP or provider detail anywhere in the identity feature", async () => {
for (const file of await readTypeScriptFiles(path.join("core", "src", "identity"))) {
const code = withoutComments(file.text).toLowerCase();
for (const forbidden of [
"nodemailer",
"smtpserver",
"starttls",
"createtransport",
"587",
"465",
]) {
expect(code, `${file.path} names ${forbidden}`).not.toContain(forbidden);
}
}
});
it("reaches the transport only through the provider seam", async () => {
const notifications = await readFile(
path.join(repositoryRoot, "core", "src", "identity", "notifications.ts"),
"utf8",
);
// The whole mail surface identity may use: a validated message and a `send`. Not a config, not
// a transport, not a credential.
expect(notifications).toContain('from "../mail/index.js"');
expect(withoutComments(notifications)).not.toContain("MailProviderConfig");
expect(withoutComments(notifications)).not.toContain("createSmtpMailProvider");
});
});
describe("Better Auth owns every token", () => {
it("adds no Ekklesia table, column, or store for a verification or reset token", async () => {
const schemas = [
path.join("core", "src", "identity", "schema.ts"),
path.join("core", "src", "authorization", "schema.ts"),
path.join("core", "src", "devices", "schema.ts"),
];
for (const relative of schemas) {
const code = withoutComments(
await readFile(path.join(repositoryRoot, relative), "utf8"),
).toLowerCase();
// A parallel token table would be a second expiry clock, a second consumption rule, and a
// second thing to get wrong. `ekklesia_core_verification` is Better Auth's own table.
expect(code, relative).not.toContain("reset_token");
expect(code, relative).not.toContain("verification_token");
expect(code, relative).not.toContain("password_reset");
}
});
it("never hashes, stores, or compares a token itself", async () => {
for (const file of await readTypeScriptFiles(path.join("core", "src", "identity"))) {
const code = withoutComments(file.text);
expect(code, file.path).not.toContain("createHash");
expect(code, file.path).not.toContain("timingSafeEqual");
}
});
it("interpolates no token, link, address, or message body into anything it logs", async () => {
for (const file of await readTypeScriptFiles(path.join("core", "src", "identity"))) {
const code = withoutComments(file.text);
for (const call of code.matchAll(/console\.\w+\(([^\n]*)/gu)) {
// Fixed prose in a log line is fine; a **substituted value** is what could carry a secret.
// Only the interpolations are examined, so "email was not delivered" passes while
// `${user.email}` could not.
for (const substitution of (call[1] ?? "").matchAll(/\$\{([^}]*)\}/gu)) {
const expression = (substitution[1] ?? "").toLowerCase();
for (const forbidden of ["token", "url", "html", "text", "email", "recipient", "body"]) {
expect(expression, `${file.path} logs ${forbidden}`).not.toContain(forbidden);
}
}
}
}
});
});
describe("managed devices have no part in human email identity", () => {
it("gives a device no address, password, or verification state", async () => {
for (const file of await readTypeScriptFiles(path.join("core", "src", "devices"))) {
const code = withoutComments(file.text).toLowerCase();
for (const forbidden of ["email", "password", "emailverified", "sendresetpassword"]) {
expect(code, `${file.path} names ${forbidden}`).not.toContain(forbidden);
}
}
});
it("keeps the device store out of the identity notification path entirely", async () => {
const notifications = await readFile(
path.join(repositoryRoot, "core", "src", "identity", "notifications.ts"),
"utf8",
);
expect(notifications).not.toContain("devices");
expect(notifications).not.toContain("Device");
});
});
describe("the browser shell reaches none of it", () => {
it("imports neither Better Auth nor Core's mail from any browser source", async () => {
for (const file of await readTypeScriptFiles(path.join("surfaces", "shell", "src"))) {
expect(file.text, file.path).not.toContain("better-auth");
expect(file.text, file.path).not.toContain("@ekklesia/core/mail");
expect(file.text, file.path).not.toContain("nodemailer");
}
});
it("declares no server identity or mail dependency in the shell package", async () => {
const manifest = JSON.parse(
await readFile(path.join(repositoryRoot, "surfaces", "shell", "package.json"), "utf8"),
) as Record<string, Record<string, string> | undefined>;
const declared = Object.keys({
...(manifest.dependencies ?? {}),
...(manifest.devDependencies ?? {}),
});
expect(declared).not.toContain("better-auth");
expect(declared).not.toContain("nodemailer");
expect(declared).not.toContain("@ekklesia/core");
});
it("never puts a reset token in browser storage", async () => {
for (const file of await readTypeScriptFiles(path.join("surfaces", "shell", "src"))) {
const code = withoutComments(file.text);
// A reset token is a bearer credential for one account; the shell holds it in React state for
// the life of one form and nowhere else.
expect(code, file.path).not.toContain("localStorage");
expect(code, file.path).not.toContain("sessionStorage");
expect(code, file.path).not.toContain("document.cookie");
}
});
});
describe("public signup stays gone", () => {
it("offers no registration path from any browser source", async () => {
for (const file of await readTypeScriptFiles(path.join("surfaces", "shell", "src"))) {
const code = withoutComments(file.text);
// Email verification is not a licence to self-register (ADR 0038).
expect(code, file.path).not.toContain("/sign-up");
expect(code, file.path).not.toContain("signUp");
}
});
});
+23 -11
View File
@@ -42,30 +42,42 @@ async function readTextFiles(directory: string): Promise<Array<{ path: string; t
} }
describe("outbound email stays server-only", () => { describe("outbound email stays server-only", () => {
it("declares the SMTP dependency in Core alone", async () => { it("declares the SMTP transport in Core alone", async () => {
const core = await readJson("core/package.json"); const core = await readJson("core/package.json");
expect(Object.keys((core.dependencies ?? {}) as object)).toContain("nodemailer"); expect(Object.keys((core.dependencies ?? {}) as object)).toContain("nodemailer");
const mailPackages = (names: readonly string[]): readonly string[] =>
names.filter((name) => /nodemailer|smtp|mailgun|sendgrid|postmark|resend/iu.test(name));
// Every package a browser can end up executing, plus the contracts barrel a surface must not // Every package a browser can end up executing, plus the contracts barrel a surface must not
// reach. None may carry a mail transport. // reach. None may carry anything mail-related at all, in either dependency list.
for (const manifest of [ for (const manifest of [
"packages/contracts/package.json", "packages/contracts/package.json",
"packages/module-sdk/package.json", "packages/module-sdk/package.json",
"packages/module-ui-sdk/package.json", "packages/module-ui-sdk/package.json",
"packages/module-ui-host/package.json", "packages/module-ui-host/package.json",
"surfaces/shell/package.json", "surfaces/shell/package.json",
"apps/api/package.json",
]) { ]) {
const parsed = await readJson(manifest); const parsed = await readJson(manifest);
const dependencies = { expect(
...((parsed.dependencies ?? {}) as Record<string, string>), mailPackages([
...((parsed.devDependencies ?? {}) as Record<string, string>), ...Object.keys((parsed.dependencies ?? {}) as object),
}; ...Object.keys((parsed.devDependencies ?? {}) as object),
const mailDependencies = Object.keys(dependencies).filter((name) => ]),
/nodemailer|smtp|mailgun|sendgrid|postmark|resend/iu.test(name), manifest,
); ).toEqual([]);
expect(mailDependencies, manifest).toEqual([]);
} }
// The API executable ships no mail package of any kind: it reaches SMTP only through Core's
// provider. Its *test* dependencies may include `smtp-server`, which is a receiving server and
// has no capability to send anything — that is what lets the verification and recovery
// integration suites capture a real message instead of asserting against a fake (ADR 0038).
const api = await readJson("apps/api/package.json");
expect(mailPackages(Object.keys((api.dependencies ?? {}) as object))).toEqual([]);
expect(mailPackages(Object.keys((api.devDependencies ?? {}) as object))).toEqual([
"@types/smtp-server",
"smtp-server",
]);
}); });
it("never imports the transport outside Core's own mail module", async () => { it("never imports the transport outside Core's own mail module", async () => {
@@ -178,6 +178,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0004_module_lifecycle_state", "core:0004_module_lifecycle_state",
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
]); ]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally { } finally {
@@ -181,6 +181,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"core:0004_module_lifecycle_state", "core:0004_module_lifecycle_state",
"core:0005_module_configuration", "core:0005_module_configuration",
"core:0006_device_identity", "core:0006_device_identity",
"core:0007_verified_bootstrap_owners",
]); ]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally { } finally {
@@ -0,0 +1,181 @@
# 0038: Email verification and password recovery, with a trusted-bootstrap exception
- Status: Accepted
- Date: 2026-08-25
- Extends: [0010](0010-core-identity-and-better-auth-adoption.md),
[0011](0011-identity-http-transport-and-public-enrollment.md),
[0034](0034-host-owned-browser-authentication.md),
[0035](0035-initial-platform-owner-bootstrap.md),
[0036](0036-managed-device-identity-and-pairing.md),
[0037](0037-host-provided-outbound-email.md)
## Context
Ekklesia can authenticate a person, hold a session, pair a device, and — since
[ADR 0037](0037-host-provided-outbound-email.md) — send a transactional email. What it cannot do is
either half of the ordinary human identity lifecycle: it has no way to establish that an address
belongs to the person who claimed it, and no way for someone who has forgotten their password to
get back in. The Better Auth hooks for both have existed and been deliberately unimplemented since
[ADR 0011](0011-identity-http-transport-and-public-enrollment.md); Pass 1 built the transport they
were waiting for.
Adding them is not simply "turn the feature on", because two things about this platform are unusual.
The **initial platform owner** is created by an operator-run command
([ADR 0035](0035-initial-platform-owner-bootstrap.md)) on a freshly provisioned server, at a moment
when SMTP may not be configured, may not be reachable, and may not yet have a domain to send from.
A verification requirement applied naively would produce a correctly installed platform whose only
administrator can never sign in to configure the very mail service the requirement depends on.
And **password recovery is an anonymous, unauthenticated mutation** that sends mail to an address
the caller chose. That is the classic account-enumeration surface, and it is also the classic way to
turn a platform into someone else's mail cannon.
## Decision
### Better Auth owns the mechanics; Ekklesia owns the experience
```text
host-owned browser screens ← Ekklesia
↓
Ekklesia auth boundary ← Ekklesia
↓
Better Auth ← tokens, hashing, expiry, consumption, session policy
↓ "deliver this"
identity notification adapter ← Ekklesia: recipient, subject, bodies
↓
MailProvider seam → SMTP ← ADR 0037
```
Ekklesia implements **no** token table, token hashing, token expiry, or token consumption. Those
exist and are correct in the pinned release, and a second implementation would be a second thing to
get wrong. A structural test asserts that no Ekklesia schema gains a reset or verification token
column and that identity code never hashes or compares one.
### A verified address is a precondition for holding a session
`emailAndPassword.requireEmailVerification` is enabled. In the pinned release this is enforced
*after* the password is validated and *before* any session is created: an unverified sign-in fails
with `403 EMAIL_NOT_VERIFIED` and **no cookie is set**.
That single fact is what keeps the principal model unchanged. There is no "unverified principal",
because there is no session to attach one to — a correct password on an unverified address produces
exactly the same authority as no password at all. `/api/v1/principal` continues to describe only
`anonymous`, an authenticated human, and a managed device, and the Dashboard shell continues to
render authenticated content only after the principal endpoint says so. Nothing was added to the
public principal union, and nothing needed to be.
### The initial platform owner is created already verified
`createInitialPlatformOwner` marks the address verified — writing Better Auth's own `emailVerified`
column, so the resulting row is indistinguishable from one that verified normally — before it grants
ownership. If that write fails, the grant and the one-time sentinel roll back together and the
command remains available.
The exception's authority is the same authority that already makes the command safe: **possession of
the database, not possession of the mailbox.** It is not a weaker policy, it is a different and
stronger proof, available exactly once per installation. `pnpm identity:create-initial-owner` gains
no mail prerequisite of any kind.
An adopted identity is marked verified too. The alternative — bootstrap succeeding and producing an
owner who cannot sign in — is the failure this whole exception exists to prevent.
Migration `0007_verified_bootstrap_owners` marks currently-granted platform owners of *existing*
installations verified, so enabling this policy cannot lock an operator out of a platform they
already run. It is deliberately narrow: it touches only identities the authorization tables already
record as owners, and no other account.
### Links are built from configuration, never from a request
Better Auth hands its callbacks a prebuilt URL embedding whatever `callbackURL` or `redirectTo` the
requesting client supplied, and validates those against its trusted origins. That validation is
sound. It is nonetheless not what Ekklesia relies on: the notification adapter takes **only the
token** and rebuilds the link from validated configuration.
The result is that no request parameter can influence where a recipient lands, independently of
whether any origin check behaves as expected. An emailed link is the one artifact a person clicks
without looking.
The destination is a new validated `IdentityConfig.dashboardOrigin` — the **Dashboard** browser
origin, which defaults to the identity base URL (the ordinary same-origin shape) and may be
configured separately (`EKKLESIA_DASHBOARD_ORIGIN`) for a deployment that splits its API from its
Dashboard. It is validated to be a canonical origin, https and non-loopback in production, and a
Better Auth trusted origin — so a misconfiguration fails at startup instead of producing a link
every recipient is refused at. It is explicitly **not** the public church website origin: this is
the authenticated Dashboard host and nothing else.
### Following a link never mints a session
`emailVerification.autoSignInAfterVerification` is `false`. Verification links travel through mail
infrastructure and are routinely dereferenced by things that are not the recipient — link scanners,
prefetchers, corporate mail gateways. Turning that GET into a logged-in session would hand a session
to whatever fetched the URL. Verification flips the flag and the person signs in afterwards.
This also makes replay harmless, which matters: the verification token is a signed JWT rather than a
stored row, so it is **not single-use** — it is valid until it expires (one hour). Replaying it on an
already-verified address is a no-op that mints nothing. The reset token *is* a stored row and *is*
consumed, so a captured reset link cannot be replayed.
### Password reset revokes that human's sessions
`emailAndPassword.revokeSessionsOnPasswordReset` is enabled. Recovery is what someone uses when they
believe their credential is compromised; leaving the attacker's session alive would make it
cosmetic. This is Better Auth's own supported option, not a Core reach into the session table.
It deletes *all* of that person's sessions, including the browser performing the reset — so the
completion screen returns to sign-in rather than pretending to be authenticated. **Managed device
sessions are unaffected**: a device is a separate credential digest keyed by device
([ADR 0036](0036-managed-device-identity-and-pairing.md)), holds no email and no password, and has
no row in the human session table.
### Recovery is enumeration-safe, including when mail is broken
The user-facing answer is identical for an address with an account, an address without one, an
already-verified address, and an address whose message could not be delivered.
Making that true required one thing beyond the library's own behaviour. Better Auth's
`/send-verification-email` rethrows whatever the delivery callback throws, which would make "this
address has an unverified account" observable the moment SMTP broke. **The notification adapter
therefore never throws.** A failure is reported out of band with a sanitized code and no address,
token, or link, where no HTTP response shape depends on it.
### Sending is still direct, non-retrying, and rate-limited per process
[ADR 0037](0037-host-provided-outbound-email.md)'s discipline is unchanged: one bounded attempt, no
application-level retry. A timeout after SMTP DATA is genuinely ambiguous, and retrying it sends the
same reset link twice.
Better Auth's own limiter applies **3 requests per 60 seconds per IP** to password-reset requests and
verification resends, and 3 per 10 seconds to sign-in. That limiter is in-memory and per process.
It is a real abuse control for a single deployment and is deliberately **not** claimed to be a
distributed one; a multi-process deployment gets that budget per process. Resending is also reachable
by simply attempting to sign in (`emailVerification.sendOnSignIn`), which costs nothing to secure
because it requires the correct password.
### Mail-less installations still run
An installation with no SMTP configured starts, serves, and signs its bootstrap owner in. It
configures the identical verification policy and simply cannot deliver; every attempt reports
`MAIL_NOT_CONFIGURED` through the reporter. Absent mail does **not** relax
`requireEmailVerification` — which is precisely why the bootstrap exception exists.
The operator requirement is therefore: **normal production use of verification and recovery requires
configured outbound email; initial platform-owner bootstrap does not.**
### Public signup remains disabled
Email verification is not a licence to self-register. `POST /api/auth/sign-up/email` stays refused,
production still cannot enable it at all, and the browser shell offers no registration control. A
regression test asserts each. Future human accounts will arrive through deliberate administration or
invitation, which this decision does not design.
## Consequences
- A human account's lifecycle is complete: it exists, verifies, signs in, recovers, and signs out.
- The principal union, the device model, and the module-facing surface are all unchanged.
- Ekklesia Lite is unaffected: it has no human authentication, so it acquires no SMTP requirement.
- Verification and recovery are now the second and third things that will break if outbound email is
misconfigured. `pnpm doctor`'s `outbound-email.configuration` check is the place an operator finds
that out.
- Deferred deliberately: invitations, user administration, email-change verification, multiple
addresses, MFA, OAuth/SSO, a durable mail outbox, and distributed rate limiting.
+1
View File
@@ -46,3 +46,4 @@ ADRs capture durable choices that affect more than one package or phase.
- [0035: The first platform owner is created by an operator command, never by a browser](0035-initial-platform-owner-bootstrap.md) — Accepted - [0035: The first platform owner is created by an operator command, never by a browser](0035-initial-platform-owner-bootstrap.md) — Accepted
- [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted - [0036: A display device is its own principal, paired by an administrator](0036-managed-device-identity-and-pairing.md) — Accepted
- [0037: Outbound email is a host-provided transport, and SMTP is its first provider](0037-host-provided-outbound-email.md) — Accepted - [0037: Outbound email is a host-provided transport, and SMTP is its first provider](0037-host-provided-outbound-email.md) — Accepted
- [0038: Email verification and password recovery, with a trusted-bootstrap exception](0038-email-verification-and-password-recovery.md) — Accepted
+25 -2
View File
@@ -153,8 +153,9 @@ status, `Set-Cookie`, and other headers but discards the native token/user respo
The shell neither parses nor stores a token. It resolves `/api/v1/principal` again, and only an The shell neither parses nor stores a token. It resolves `/api/v1/principal` again, and only an
authenticated result permits the navigation request and module mount. Credential denial and authenticated result permits the navigation request and module mount. Credential denial and
service failure use distinct fixed messages, but no Better Auth response body, exception, email, service failure use distinct fixed messages, but no Better Auth response body, exception, email,
password, URL, or stack reaches browser output or logging. The form deliberately offers no signup password, URL, or stack reaches browser output or logging. The form offers **no signup** control,
or password-reset link because neither is a supported production flow. because public registration is not a supported production flow. It does offer **Forgot password?**,
which is now a real backend flow (ADR 0038).
Sign-out posts `{}` with credentials to `/api/auth/sign-out` and, after that mutation succeeds, Sign-out posts `{}` with credentials to `/api/auth/sign-out` and, after that mutation succeeds,
resolves the principal again. The host does not equate hiding the shell with invalidating a session. resolves the principal again. The host does not equate hiding the shell with invalidating a session.
@@ -182,6 +183,28 @@ redirect target and performs no post-login navigation, so external and protocol-
have no redirect sink in this pass. See have no redirect sink in this pass. See
[ADR 0034](../architecture/decisions/0034-host-owned-browser-authentication.md). [ADR 0034](../architecture/decisions/0034-host-owned-browser-authentication.md).
### The four anonymous screens
The shell's unauthenticated surface is a small explicit union, not a client-side router: sign-in,
forgot-password, verification (notice or result), and choose-a-new-password
([ADR 0038](../architecture/decisions/0038-email-verification-and-password-recovery.md)). None of
them is authenticated, and none can become authenticated — `/api/v1/principal` still decides.
An emailed link returns to the Dashboard root carrying `?ekklesia_auth=verify-email` or
`?ekklesia_auth=reset-password`. Query parameters rather than paths, deliberately: the shell is a
static bundle, and a deep path would oblige every deployment's static host to rewrite unknown paths
to `index.html` before an emailed link worked at all.
The entry point parses that query once, with a pure function that recognizes only those two values
and **names no destination of any kind**, then removes it from the address bar with
`history.replaceState` — so a reset token never enters browser history, a shareable URL, or a
`Referer`. The token lives in React state for the life of one form and is never written to
`localStorage`, `sessionStorage`, or a cookie.
A correct password on an unverified address is reported as its own state (`403 EMAIL_NOT_VERIFIED`,
no cookie) so the shell can offer a resend rather than claim the password was wrong. It is never
confused with an authenticated principal.
## Public registration policy ## Public registration policy
**Public sign-up is disabled by default** (`EKKLESIA_PUBLIC_SIGNUP_ENABLED` unset or anything other **Public sign-up is disabled by default** (`EKKLESIA_PUBLIC_SIGNUP_ENABLED` unset or anything other
+40 -7
View File
@@ -3,9 +3,14 @@
Ekklesia can send transactional email through a host-configured SMTP transport Ekklesia can send transactional email through a host-configured SMTP transport
([ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md)). ([ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md)).
**Nothing in the platform sends email yet.** This pass added the transport; email verification, Two things send email: **email verification** and **password recovery**
password reset, and invitations are Phase 7C Pass 2. An installation with no SMTP configured is a ([ADR 0038](../architecture/decisions/0038-email-verification-and-password-recovery.md)). Invitations
complete, valid deployment — the platform starts and every existing feature works. do not exist.
An installation with no SMTP configured is still a complete, valid deployment — the platform starts,
every existing feature works, and the initial platform owner can sign in. What it cannot do is verify
a new account or recover a password, because both need a message to arrive. See
[When no relay is configured](#when-no-relay-is-configured).
## Configuring a relay ## Configuring a relay
@@ -121,8 +126,36 @@ transport refusing to continue when the upgrade is not offered.
**No test sends real email and no live credential exists in this repository or in CI.** **No test sends real email and no live credential exists in this repository or in CI.**
## Next ## What the platform actually sends
Phase 7C Pass 2 wires Better Auth's `sendResetPassword` and `emailVerification.sendVerificationEmail` | message | when | contents |
hooks — both currently unimplemented — to this transport. See | --- | --- | --- |
[Identity](identity.md#outbound-email-and-the-verification-seam). | Verify your Ekklesia email address | a new account signs in unverified, or asks for a resend | one link, an expiry sentence, and ignore-if-unexpected wording |
| Reset your Ekklesia password | someone asks to recover an account | one link, an expiry sentence, and a note that the current password still works |
Both are plain text and HTML, both are generic Ekklesia templates with no branding and no marketing
content, and both are built in `core/src/identity/notifications.ts` — which knows a recipient, a
subject, and two bodies, and nothing about SMTP.
Links are rebuilt from validated configuration rather than taken from the request that triggered
them, so no caller can steer where a recipient lands. They point at
`EKKLESIA_DASHBOARD_ORIGIN` (defaulting to the identity base URL). See
[Identity](identity.md#email-verification-and-password-recovery).
## When no relay is configured
Nothing crashes and nothing changes shape. A verification or recovery request answers exactly as it
would with a working relay — the response must not reveal whether an account exists, and it must not
start revealing it the moment SMTP breaks — and the operator is told out of band:
```text
[identity] password-reset email was not delivered: MAIL_NOT_CONFIGURED
```
That line carries a flow and a sanitized code. It never carries the address, the token, or the link.
`pnpm doctor` reports the same absence under `outbound-email.configuration`.
The operator requirement is therefore:
- **normal production use of verification and recovery** → requires configured outbound email
- **initial platform-owner bootstrap** (`pnpm identity:create-initial-owner`) → does not
+116 -24
View File
@@ -253,12 +253,12 @@ logs, and error carry no relation name, column name, SQL, driver message, host,
## What is and is not operational ## What is and is not operational
Email/password sign-in, principal resolution, sign-out, and the Dashboard's host-owned browser flow Email/password sign-in, principal resolution, sign-out, the Dashboard's host-owned browser flow,
are operational — see [API](api.md) and ADR 0034. Public registration remains a future product email verification, and password recovery are operational — see [API](api.md), ADR 0034, and
policy, not a production capability: it is disabled by default, may be explicitly enabled only for ADR 0038. Public registration remains a future product policy, not a production capability: it is
development/test, and production rejects it. No email verification, password recovery, invitation disabled by default, may be explicitly enabled only for development/test, and production rejects it.
flow, email delivery, social or OAuth provider, passkey, magic link, two-factor authentication, or No invitation flow, social or OAuth provider, passkey, magic link, two-factor authentication,
roles/groups/teams/tenants model exists. email-change verification, or roles/groups/teams/tenants model exists.
Core's authorization foundation (platform owners, explicit grants, and an immutable permission Core's authorization foundation (platform owners, explicit grants, and an immutable permission
registry) is covered separately in [Authorization](authorization.md). Its bootstrap command grants registry) is covered separately in [Authorization](authorization.md). Its bootstrap command grants
@@ -279,31 +279,93 @@ wins, so a browser holding both remains a person.
`/api/v1/principal` reports a device as `anonymous` on purpose. It is the endpoint the Dashboard uses `/api/v1/principal` reports a device as `anonymous` on purpose. It is the endpoint the Dashboard uses
to decide whether a person is signed in, and a device is not one. to decide whether a person is signed in, and a device is not one.
## Outbound email and the verification seam ## Email verification and password recovery
Ekklesia has a host-provided SMTP transport A verified email address is a **precondition for holding a session**
([ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md), see ([ADR 0038](../architecture/decisions/0038-email-verification-and-password-recovery.md)). Better Auth
[Outbound email](email.md)) — but **no identity flow uses it yet.** enforces this after the password is checked and before any session exists, so an unverified sign-in
returns `403 EMAIL_NOT_VERIFIED` and sets no cookie.
Better Auth exposes exactly two hooks for the flows that need mail, and this repository configures That is why there is no "unverified principal": a correct password on an unverified address carries
neither: exactly the same authority as no password at all, and `/api/v1/principal` still reports only
`anonymous`, an authenticated human, or a device.
| hook | flow | ### Who owns what
| concern | owner |
| --- | --- | | --- | --- |
| `emailAndPassword.sendResetPassword({ user, url, token })` | Password reset | | token minting, expiry, consumption, password hashing, session revocation | Better Auth |
| `emailVerification.sendVerificationEmail({ user, url, token })` | Email verification | | recipient, subject, plaintext and HTML bodies, link construction | `core/src/identity/notifications.ts` |
| SMTP host, port, TLS, credentials, sender identity | the mail provider ([ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md)) |
| the browser screens a person actually sees | the shell (`surfaces/shell`) |
Phase 7C Pass 2 implements them over the mail transport. `startApi` already constructs the provider Ekklesia implements no token table, no token hashing, and no expiry clock. A structural test asserts
and closes it on every shutdown path, so the remaining work is to inject it into that none appears.
`createIdentityRuntime` and write the two message bodies.
**The `url` and `token` those hooks receive are credentials.** The entire security of both flows is ### Token semantics, stated precisely
that only the recipient sees them, so they must never be logged, never appear in an error, and never
reach a diagnostic. The transport already logs nothing and refuses to echo a message body.
Until that pass lands: email verification, password recovery, and invitations do not exist, and | | verification | password reset |
`requireEmailVerification` stays off — turning it on without a working sender would lock every | --- | --- | --- |
account out. | representation | signed JWT (not stored) | random value stored in `ekklesia_core_verification` |
| lifetime | 1 hour | 1 hour |
| single-use | **no** — valid until it expires | **yes** — consumed on use |
| replay | idempotent no-op, mints no session | refused with `INVALID_TOKEN` |
Verification is deliberately idempotent because mail scanners and prefetchers dereference links.
Following one never creates a session (`autoSignInAfterVerification: false`), so a link fetched by
something that is not the recipient hands out nothing.
### Links
Both links are rebuilt from validated configuration, taking **only the token** from Better Auth —
never the `url` it prebuilds from a caller-supplied `callbackURL`/`redirectTo`. No request parameter
can influence where a recipient lands.
They point at `IdentityConfig.dashboardOrigin` (`EKKLESIA_DASHBOARD_ORIGIN`), which defaults to the
identity base URL, must be a canonical origin, must be https and non-loopback in production, and must
be a trusted origin — checked at startup, so a misconfiguration fails once for the operator instead of
for every recipient. It is the authenticated Dashboard host, not the public church website.
**The `url` and `token` are credentials.** Nothing logs them, nothing puts them in an error, nothing
puts them in a diagnostic, and the browser strips the reset token from the address bar with
`history.replaceState` before rendering the form.
### Recovery is enumeration-safe
Every user-facing answer is identical for an address with an account, an address without one, an
already-verified address, and one whose message could not be delivered. The notification adapter
therefore **never throws** — Better Auth's `/send-verification-email` rethrows what its callback
throws, which would turn a broken relay into an account oracle. Failures are reported out of band:
```text
[identity] email-verification email was not delivered: MAIL_NOT_CONFIGURED
```
### Password reset and sessions
`revokeSessionsOnPasswordReset` is enabled: completing a reset deletes **every** session for that
person, including the browser that performed it, so the completion screen returns to sign-in.
Recovery is what someone uses when they think their credential is compromised, and leaving the
attacker signed in would make it cosmetic.
Managed device sessions are untouched — a device holds a separate credential digest, no email, and no
password.
### Resending
A sign-in attempt against an unverified account resends the message (`sendOnSignIn`). That path costs
nothing to secure because it already required the correct password.
`POST /api/auth/send-verification-email` is also available and is enumeration-safe on its own.
Better Auth rate-limits both mail-triggering paths at **3 requests per 60 seconds per IP**, and
sign-in at 3 per 10 seconds. That limiter is **in-memory and per process** — a real control for a
single deployment, and deliberately not claimed to be a distributed one.
### Without a configured relay
The platform starts, serves, and signs its bootstrap owner in. It configures the identical
verification policy and simply cannot deliver. Absent mail does **not** relax
`requireEmailVerification`, which is exactly why the initial owner is created already verified.
## Creating the first platform owner ## Creating the first platform owner
@@ -325,6 +387,36 @@ DATABASE_URL=postgresql://... pnpm identity:create-initial-owner -- --email owne
`--name` is optional and defaults to the email address; the identity model requires a display name, `--name` is optional and defaults to the email address; the identity model requires a display name,
and asking for one that nobody can edit yet would be a question without a purpose. and asking for one that nobody can edit yet would be a question without a purpose.
### This one account is created already verified
Every other human account must confirm its address by email before it may hold a session. This one
cannot: it is created on a freshly provisioned server, by an operator who already holds the database
credentials, at a moment when SMTP may not be configured, may not be reachable, and may not yet have
a domain to send from. Requiring an email round trip here would produce a working installation whose
only administrator can never sign in to configure the mail service the requirement depends on
([ADR 0038](../architecture/decisions/0038-email-verification-and-password-recovery.md)).
**The command has no mail prerequisite of any kind.** It marks Better Auth's own `emailVerified`
column — so the row is indistinguishable from one that verified normally — before granting ownership;
if that write fails, the grant and the one-time sentinel roll back together and the command remains
available. An adopted identity is marked verified too, because the alternative is bootstrap
succeeding and producing an owner who cannot sign in.
The exception is narrow: exactly one address, exactly once per installation. Its authority is
possession of the database, not possession of a mailbox. An account created any other way is still
subject to the ordinary policy.
Existing installations are covered by migration `0007_verified_bootstrap_owners`, which marks
currently-granted platform owners verified so enabling this policy cannot lock an operator out of a
platform they already run. It touches no other account.
### How future human accounts will become verified
There is no user administration and no invitation flow yet, and this documentation does not claim
either exists. When one is built, the path is already in place: an account created by any trusted
server-side means starts unverified, its first sign-in attempt sends a verification message, and it
becomes ordinary once the address is confirmed. The bootstrap owner remains the only exception.
### The password never becomes an argument or a variable ### The password never becomes an argument or a variable
It is prompted for twice, invisibly, when a terminal is attached. `--password` is **refused with an It is prompted for twice, invisibly, when a terminal is attached. `--password` is **refused with an
+1 -1
View File
@@ -166,7 +166,7 @@ Object storage
Outbound email Outbound email
○ Outbound email not configured ○ Outbound email not configured
No EKKLESIA_SMTP_HOST is set. This is a complete deployment: nothing in the platform sends email yet. No EKKLESIA_SMTP_HOST is set. The platform runs and the bootstrap owner can sign in, but email verification and account recovery cannot deliver.
Result: READY WITH WARNINGS Result: READY WITH WARNINGS
``` ```
+45 -3
View File
@@ -189,9 +189,12 @@ while the module retains the original response. Only anonymous unmounts the modu
authenticated preserves the session and mounted module. A stale response from an already-unmounted authenticated preserves the session and mounted module. A stale response from an already-unmounted
frame is ignored, and network/server failures do not fabricate logout. frame is ignored, and network/server failures do not fabricate logout.
The flow remains at the current Dashboard URL and accepts no callback/return target, so this pass The flow remains at the current Dashboard URL and accepts no callback/return target, so it adds no
adds no open-redirect input. It exposes no signup or recovery link: production signup is prohibited open-redirect input. It exposes **no signup** link, because production signup is prohibited. It does
and no email delivery/recovery system exists. The existing cookie stays host-only; exact trusted expose a recovery link, and the screens it leads to still accept no destination: the emailed link's
landing is parsed by a pure function that recognizes exactly two Ekklesia-owned values and names no
URL at all, and the link itself was built by the server from validated configuration rather than
from anything a request supplied (ADR 0038). The existing cookie stays host-only; exact trusted
origins, credentialed CORS, and explicitly enabled Better Auth origin/CSRF checks remain the origins, credentialed CORS, and explicitly enabled Better Auth origin/CSRF checks remain the
boundary. Broadening the cookie to a parent domain or inventing a Dashboard hostname would be a boundary. Broadening the cookie to a parent domain or inventing a Dashboard hostname would be a
separate deployment and threat-model decision. separate deployment and threat-model decision.
@@ -620,6 +623,40 @@ Deliverability — SPF, DKIM, DMARC, and the relay's own authorization policy
responsibility this repository cannot verify, and Doctor says so rather than implying a successful responsibility this repository cannot verify, and Doctor says so rather than implying a successful
connection means mail arrives. connection means mail arrives.
### Email verification and password recovery
Both flows are Better Auth's, delivered over that transport
([ADR 0038](../architecture/decisions/0038-email-verification-and-password-recovery.md)).
- **A verified address is a precondition for a session.** An unverified sign-in fails after the
password check and before any session exists, so a correct password on an unverified address is
worth exactly as much as no password. There is no "partly authenticated" principal to misread.
- **The initial platform owner is the one exception**, created already verified by an operator
command whose authority is possession of the database. It is exactly one address, exactly once per
installation, with no mail prerequisite — the alternative is an installation nobody can ever sign
in to.
- **Ekklesia stores no token.** No token table, no token hashing, no expiry clock, no consumption
logic. Verification tokens are signed JWTs valid for one hour and replayable but idempotent (mail
scanners dereference links); reset tokens are stored rows, valid for one hour, and consumed.
- **Following a link never creates a session.** `autoSignInAfterVerification` is off, so a URL
fetched by a link scanner, a prefetcher, or a mail gateway hands out nothing.
- **Links are built from configuration.** Only the token is taken from the identity system; the
destination comes from a validated Dashboard origin that must be canonical, https and non-loopback
in production, and a trusted origin — checked at startup. No request parameter can steer where a
recipient lands.
- **Tokens never reach a log, an error, or a diagnostic**, and the browser strips the reset token
from the address bar with `history.replaceState` before rendering the form. It is never written to
`localStorage`, `sessionStorage`, or a cookie.
- **Recovery is enumeration-safe, including when mail is broken.** Every user-facing answer is
identical for an address with an account, without one, already verified, or undeliverable. The
delivery adapter never throws, because the identity system rethrows what its callback throws and
that would turn a failed relay into an account oracle. Failures are reported out of band with a
sanitized code and no address, token, or link.
- **Password reset revokes that person's sessions**, including the one performing it. Managed device
sessions are untouched: a device holds a separate credential digest, no email, and no password.
- **Verification does not imply registration.** Public signup remains disabled and the browser offers
no way to ask for an account.
### Rate limiting is per-process today ### Rate limiting is per-process today
Better Auth's authentication rate limiter is **in-memory and per API process**. That is acceptable Better Auth's authentication rate limiter is **in-memory and per API process**. That is acceptable
@@ -628,6 +665,11 @@ only while **both** of the following hold, which is the current state:
- production public signup is disabled (and cannot be enabled — see above); and - production public signup is disabled (and cannot be enabled — see above); and
- exactly one API instance serves traffic. - exactly one API instance serves traffic.
The same limiter covers the two paths that trigger an email — password-reset requests and
verification resends — at **3 requests per 60 seconds per IP**. That is a real control against
turning the platform into someone's mail cannon from one source, and it is deliberately **not**
claimed to be distributed: a multi-process deployment gets that budget per process.
If either changes — public signup is enabled, or a second instance is added — a durable shared or If either changes — public signup is enabled, or a second instance is added — a durable shared or
edge rate limiter becomes mandatory, and it must be proven that its counters are shared across edge rate limiter becomes mandatory, and it must be proven that its counters are shared across
instances, survive process restart, and cannot be re-bucketed by a spoofed client-IP header. instances, survive process restart, and cannot be re-bucketed by a spoofed client-IP header.
+47
View File
@@ -271,6 +271,53 @@ demonstrate what it *cannot* say — its whole world is a `ModuleStorage` and tw
manifest — and it asserts that the module's durable state holds keys while only the URL carries manifest — and it asserts that the module's durable state holds keys while only the URL carries
host detail. It also proves that forty URLs cost no provider round trips. host detail. It also proves that forty URLs cost no provider round trips.
A ninth suite (`apps/api/tests/email-verification.integration.ts`, same harness) proves email
verification and password recovery **end to end**
([ADR 0038](../architecture/decisions/0038-email-verification-and-password-recovery.md)). Everything
in it is real: a disposable PostgreSQL, the actual HTTP transport, the actual Better Auth endpoints,
the actual SMTP provider, and an in-process `smtp-server` accepting the message. The verification and
reset links are **extracted from the delivered message** and then followed verbatim — the API is
bound to a port reserved before it is configured, precisely so the emailed URL is the one the test
resolves rather than one it had to rewrite. A fake mail function would have proved that a callback
ran and nothing about whether the resulting link works.
It pins: an unverified account refused a session with `403 EMAIL_NOT_VERIFIED` and no cookie; the
full unverified → emailed link → verified → signed in path; a malformed and an expired token
reported as separate reasons on the host's own landing; verification replay as an idempotent no-op
that mints nothing; a reset link that changes the password, invalidates the old one, is consumed on
use, and signs every existing session out; the identity system's own 8–128 policy enforced at reset
with no reset-specific rule; an unknown address answered byte-identically to a real one; a
mail-less installation answering identically while recording `MAIL_NOT_CONFIGURED` out of band;
attacker-supplied `redirectTo`/`callbackURL` values refused, and a *trusted* one still ignored
because the link is rebuilt from configuration; public signup still refused; and a paired device row
untouched across a human password reset.
The expired-token case is the one place any test constructs a token rather than reading one from a
delivered message: the verification token is a plain HS256 JWS over the auth secret this suite
configured, so an already-expired one is forged from that secret with `node:crypto` rather than by
waiting an hour.
**Nothing here reaches the internet and no live credential exists anywhere in it.**
`apps/api/tests/initial-owner.integration.ts` additionally proves the trusted-bootstrap exception:
that the owner is created verified (asserted in the database *and* through a real browser sign-in),
that neither bootstrap nor sign-in needs any mail configuration, that an adopted identity is marked
verified too, and that the exception is narrow — an ordinary account created by the identical trusted
creator stays unverified and is refused.
Browser-side, `surfaces/shell/tests/auth-recovery.test.tsx` proves the host-owned screens: that an
unverified sign-in offers verification rather than a credentials error and never renders the
Dashboard, that recovery shows one generic completion state whatever the address, that a repeated
submit sends one email, that a mismatched confirmation is blocked before anything is sent, and that a
reset token never reaches `localStorage`, `sessionStorage`, the DOM, or the address bar. The landing
parser is exercised as the pure function it is, including that it names no destination at all.
`core/tests/identity-verification-boundaries.test.ts` reads the repository itself for the properties
no runtime assertion can make: that the identity feature names no SMTP detail, that no Ekklesia
schema gained a token column and no identity code hashes or compares a token, that nothing
interpolates a token, link, address, or body into a log line, that a device has no email, password,
or verification concept, and that no browser source imports Better Auth or Core's mail.
Unit suites (`core/tests/module-storage.test.ts`, `core/tests/module-storage-lifecycle.test.ts`) use an Unit suites (`core/tests/module-storage.test.ts`, `core/tests/module-storage-lifecycle.test.ts`) use an
injected in-memory provider and never touch the network. They prove the parts the protocol cannot: injected in-memory provider and never touch the network. They prove the parts the protocol cannot:
binding determinism and collision-safety, scoping and cross-module isolation, key validation, lazy binding determinism and collision-safety, scoping and cross-module isolation, key validation, lazy
+6
View File
@@ -67,12 +67,18 @@ importers:
'@types/pg': '@types/pg':
specifier: 8.20.0 specifier: 8.20.0
version: 8.20.0 version: 8.20.0
'@types/smtp-server':
specifier: 3.5.13
version: 3.5.13
better-auth: better-auth:
specifier: 1.6.23 specifier: 1.6.23
version: 1.6.23([email protected])([email protected](@types/[email protected])([email protected])([email protected]))([email protected])([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))) version: 1.6.23([email protected])([email protected](@types/[email protected])([email protected])([email protected]))([email protected])([email protected]([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])))
pg: pg:
specifier: 8.22.0 specifier: 8.22.0
version: 8.22.0 version: 8.22.0
smtp-server:
specifier: 3.19.3
version: 3.19.3
typescript: typescript:
specifier: 7.0.2 specifier: 7.0.2
version: 7.0.2 version: 7.0.2
+187
View File
@@ -3,6 +3,19 @@ import { resolveApiUrl } from "./host-protocol.js";
const SIGN_IN_PATH = "/api/auth/sign-in/email"; const SIGN_IN_PATH = "/api/auth/sign-in/email";
const SIGN_OUT_PATH = "/api/auth/sign-out"; 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 { export interface HostSignInCredentials {
readonly email: string; readonly email: string;
@@ -11,12 +24,18 @@ export interface HostSignInCredentials {
export type HostAuthErrorCode = export type HostAuthErrorCode =
| "AUTH_CREDENTIALS_INVALID" | "AUTH_CREDENTIALS_INVALID"
| "AUTH_EMAIL_NOT_VERIFIED"
| "AUTH_REQUEST_FAILED" | "AUTH_REQUEST_FAILED"
| "AUTH_RESET_TOKEN_INVALID"
| "AUTH_PASSWORD_INVALID"
| "AUTH_SIGN_OUT_FAILED"; | "AUTH_SIGN_OUT_FAILED";
const errorMessages: Readonly<Record<HostAuthErrorCode, string>> = Object.freeze({ const errorMessages: Readonly<Record<HostAuthErrorCode, string>> = Object.freeze({
AUTH_CREDENTIALS_INVALID: "Email or password is incorrect.", 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_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.", 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. * 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 * 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. * 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( export async function signInWithEmail(
config: EkklesiaHostConfig, config: EkklesiaHostConfig,
@@ -75,6 +117,9 @@ export async function signInWithEmail(
signal?: AbortSignal, signal?: AbortSignal,
): Promise<void> { ): Promise<void> {
const response = await postJson(config, SIGN_IN_PATH, credentials, "AUTH_REQUEST_FAILED", signal); 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) { if (response.status === 400 || response.status === 401) {
throw new HostAuthError("AUTH_CREDENTIALS_INVALID"); 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. */ /** Invalidates the server session. Hiding the shell without this successful mutation is not logout. */
export async function signOutHost(config: EkklesiaHostConfig, signal?: AbortSignal): Promise<void> { export async function signOutHost(config: EkklesiaHostConfig, signal?: AbortSignal): Promise<void> {
const response = await postJson(config, SIGN_OUT_PATH, {}, "AUTH_SIGN_OUT_FAILED", signal); 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"); 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;
}
+155
View File
@@ -0,0 +1,155 @@
import { type FormEvent, type ReactElement, useEffect, useRef, useState } from "react";
import { HostAuthError } from "../auth-protocol.js";
/**
* The shared visual language of every host-owned auth screen.
*
* Exported so the login, recovery, reset, and verification screens are literally the same card
* rather than four that drift apart. Modules never render any of them.
*/
export const authStyles = {
button: {
background: "#1c1917",
border: "1px solid #1c1917",
borderRadius: "0.375rem",
color: "#fafaf9",
cursor: "pointer",
font: "inherit",
fontWeight: 600,
padding: "0.625rem 0.875rem",
},
card: {
background: "#ffffff",
border: "1px solid #e7e5e4",
borderRadius: "0.75rem",
boxShadow: "0 12px 32px rgb(28 25 23 / 0.08)",
display: "grid",
gap: "1rem",
maxWidth: "24rem",
padding: "1.5rem",
width: "100%",
},
error: { color: "#9f1239", margin: 0 },
field: { display: "grid", gap: "0.375rem" },
form: { display: "grid", gap: "1rem" },
heading: { fontSize: "1.5rem", margin: 0 },
host: {
alignItems: "center",
background: "#fafaf9",
color: "#1c1917",
display: "flex",
fontFamily: "system-ui, sans-serif",
justifyContent: "center",
minHeight: "100vh",
padding: "1rem",
},
input: {
border: "1px solid #a8a29e",
borderRadius: "0.375rem",
font: "inherit",
padding: "0.625rem 0.75rem",
width: "100%",
},
introduction: { color: "#57534e", margin: 0 },
label: { fontWeight: 600 },
secondaryAction: {
background: "transparent",
border: "none",
color: "#1c1917",
cursor: "pointer",
font: "inherit",
justifySelf: "start",
padding: 0,
textDecoration: "underline",
},
} as const;
export interface AuthCardProps {
readonly headingId: string;
readonly heading: string;
readonly testId: string;
readonly children: React.ReactNode;
}
/** One centred card on the sign-in background, with a labelled region for assistive technology. */
export function AuthCard(props: AuthCardProps): ReactElement {
return (
<main data-testid={props.testId} style={authStyles.host}>
<section aria-labelledby={props.headingId} style={authStyles.card}>
<h1 id={props.headingId} style={authStyles.heading}>
{props.heading}
</h1>
{props.children}
</section>
</main>
);
}
export interface UseSingleSubmissionResult {
readonly pending: boolean;
readonly errorMessage: string | undefined;
readonly setErrorMessage: (message: string | undefined) => void;
/** Runs `operation` at most once at a time. A second submit while one is in flight is dropped. */
readonly submit: (
event: FormEvent<HTMLFormElement>,
operation: (signal: AbortSignal) => Promise<void>,
) => Promise<void>;
}
/**
* The submission discipline every auth form needs, in one place.
*
* A `ref` closes the same-tick gap before React commits `pending`, which is what actually prevents a
* double submission — a disabled attribute alone cannot, because it is not applied until after the
* commit. Every one of these forms triggers an email or changes a credential, so a duplicate is not
* merely untidy.
*/
export function useSingleSubmission(fallbackMessage: string): UseSingleSubmissionResult {
const [pending, setPending] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | undefined>(undefined);
const pendingRef = useRef(false);
const attemptRef = useRef<AbortController | undefined>(undefined);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
attemptRef.current?.abort();
};
}, []);
const submit = async (
event: FormEvent<HTMLFormElement>,
operation: (signal: AbortSignal) => Promise<void>,
): Promise<void> => {
event.preventDefault();
if (pendingRef.current) {
return;
}
pendingRef.current = true;
setPending(true);
setErrorMessage(undefined);
const attempt = new AbortController();
attemptRef.current = attempt;
try {
await operation(attempt.signal);
} catch (error) {
if (!attempt.signal.aborted && mountedRef.current) {
// A `HostAuthError` message is fixed text this package wrote. Anything else is an unknown
// thrown value whose message could contain a URL or a provider detail, so it is replaced.
setErrorMessage(error instanceof HostAuthError ? error.message : fallbackMessage);
}
} finally {
if (attemptRef.current === attempt) {
attemptRef.current = undefined;
pendingRef.current = false;
if (mountedRef.current) {
setPending(false);
}
}
}
};
return { errorMessage, pending, setErrorMessage, submit };
}
@@ -0,0 +1,103 @@
import { type ReactElement, useState } from "react";
import { AuthCard, authStyles, useSingleSubmission } from "./AuthCard.js";
export interface ForgotPasswordFormProps {
/** Resolves for every syntactically valid address. See `requestPasswordReset`. */
readonly onRequestReset: (email: string, signal: AbortSignal) => Promise<void>;
readonly onReturnToSignIn: () => void;
}
/**
* The host-owned "forgot password" screen.
*
* Its completion state is deliberately the same sentence for every address. The form does not know,
* and cannot be made to reveal, whether an account exists — the server answers identically, this
* component renders identically, and a delivery failure reaches the operator out of band rather than
* the person at the keyboard (ADR 0038).
*/
export function ForgotPasswordForm(props: ForgotPasswordFormProps): ReactElement {
const [email, setEmail] = useState("");
const [submitted, setSubmitted] = useState(false);
const { errorMessage, pending, submit } = useSingleSubmission(
"Unable to send a reset email right now.",
);
if (submitted) {
return (
<AuthCard
heading="Check your email"
headingId="ekklesia-forgot-sent-heading"
testId="host-forgot-password-sent"
>
<p style={authStyles.introduction}>
If an Ekklesia account exists for that address, a message with a link to choose a new
password is on its way. The link expires in one hour.
</p>
<button
data-testid="forgot-return-to-sign-in"
onClick={props.onReturnToSignIn}
style={authStyles.button}
type="button"
>
Back to sign in
</button>
</AuthCard>
);
}
return (
<AuthCard
heading="Reset your password"
headingId="ekklesia-forgot-heading"
testId="host-forgot-password"
>
<p style={authStyles.introduction}>
Enter the email address for your Ekklesia account and we will send a link to choose a new
password.
</p>
<form
aria-busy={pending}
onSubmit={(event) =>
void submit(event, async (signal) => {
await props.onRequestReset(email, signal);
setSubmitted(true);
})
}
style={authStyles.form}
>
<label style={authStyles.field}>
<span style={authStyles.label}>Email</span>
<input
aria-describedby={errorMessage ? "ekklesia-forgot-error" : undefined}
autoComplete="username"
data-testid="forgot-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-forgot-error" role="alert" style={authStyles.error}>
{errorMessage}
</p>
) : undefined}
<button disabled={pending} style={authStyles.button} type="submit">
{pending ? "Sending…" : "Send reset link"}
</button>
<button
data-testid="forgot-cancel"
onClick={props.onReturnToSignIn}
style={authStyles.secondaryAction}
type="button"
>
Back to sign in
</button>
</form>
</AuthCard>
);
}
+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 { type ReactElement, useCallback, useEffect, useRef, useState } from "react";
import { import {
HostAuthError, HostAuthError,
type HostAuthLanding,
type HostSignInCredentials, type HostSignInCredentials,
requestPasswordReset,
resendVerificationEmail,
resetPassword,
signInWithEmail, signInWithEmail,
signOutHost, signOutHost,
} from "../auth-protocol.js"; } from "../auth-protocol.js";
@@ -15,8 +19,11 @@ import {
loadHostPrincipal, loadHostPrincipal,
} from "../host-protocol.js"; } from "../host-protocol.js";
import { DevicePanel } from "./DevicePanel.js"; import { DevicePanel } from "./DevicePanel.js";
import { ForgotPasswordForm } from "./ForgotPasswordForm.js";
import { LoginForm } from "./LoginForm.js"; import { LoginForm } from "./LoginForm.js";
import { ModuleUiFrame } from "./ModuleUiFrame.js"; import { ModuleUiFrame } from "./ModuleUiFrame.js";
import { ResetPasswordForm } from "./ResetPasswordForm.js";
import { ResetLinkInvalid, VerificationNotice, VerificationResult } from "./VerificationScreens.js";
type AuthenticatedPrincipal = Extract< type AuthenticatedPrincipal = Extract<
HostBootstrap["principal"], HostBootstrap["principal"],
@@ -37,11 +44,61 @@ type ResolvedBootstrapState = Extract<
{ readonly status: "ready" | "anonymous" | "access-denied" | "failed" } { 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 { export interface HostAppProps {
/** Must be a stable reference: bootstrap re-runs whenever it changes, so a fresh object literal /** 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 * built during render would re-fetch forever. The browser entry point builds it once at module
* scope. */ * scope. */
readonly config: EkklesiaHostConfig; 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. */ /** Test-only seam, forwarded to {@link ModuleUiFrame}. Production composition leaves it unset. */
readonly importModule?: ModuleUiImporter; readonly importModule?: ModuleUiImporter;
} }
@@ -201,6 +258,10 @@ export function HostApp(props: HostAppProps): ReactElement {
const generationRef = useRef(0); const generationRef = useRef(0);
const mountedRef = useRef(true); const mountedRef = useRef(true);
const { config, importModule } = props; 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(() => { const beginTransition = useCallback(() => {
activeTransitionRef.current?.abort(); activeTransitionRef.current?.abort();
@@ -306,6 +367,10 @@ export function HostApp(props: HostAppProps): ReactElement {
if (!isCurrent(controller, generation)) { if (!isCurrent(controller, generation)) {
return; 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( setState(
principal.kind === "anonymous" ? { status: "anonymous" } : { status: "sign-out-failed" }, principal.kind === "anonymous" ? { status: "anonymous" } : { status: "sign-out-failed" },
); );
@@ -349,6 +414,7 @@ export function HostApp(props: HostAppProps): ReactElement {
transition.controller.abort(); transition.controller.abort();
activeTransitionRef.current = undefined; activeTransitionRef.current = undefined;
setSelectedKey(undefined); setSelectedKey(undefined);
setAuthView({ kind: "sign-in" });
setState({ status: "anonymous" }); setState({ status: "anonymous" });
} }
}) })
@@ -382,7 +448,46 @@ export function HostApp(props: HostAppProps): ReactElement {
return <HostMessage testId="host-loading">Loading Ekklesia…</HostMessage>; return <HostMessage testId="host-loading">Loading Ekklesia…</HostMessage>;
} }
if (state.status === "anonymous") { 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") { if (state.status === "failed") {
return ( return (
+39 -71
View File
@@ -7,61 +7,23 @@ import {
useState, useState,
} from "react"; } from "react";
import { HostAuthError, type HostSignInCredentials } from "../auth-protocol.js"; import { HostAuthError, type HostSignInCredentials } from "../auth-protocol.js";
import { AuthCard, authStyles } from "./AuthCard.js";
export interface LoginFormProps { export interface LoginFormProps {
readonly onAuthenticate: ( readonly onAuthenticate: (
credentials: HostSignInCredentials, credentials: HostSignInCredentials,
signal: AbortSignal, signal: AbortSignal,
) => Promise<void>; ) => Promise<void>;
/** Opens the host-owned password-recovery screen. */
readonly onForgotPassword: () => void;
/**
* Called when the credentials were correct but the address is unverified, so the shell can offer
* verification instead of an error. The address is passed through because the person just typed
* it — it is never learned from a response.
*/
readonly onEmailNotVerified: (email: string) => void;
} }
const loginStyles = {
button: {
background: "#1c1917",
border: "1px solid #1c1917",
borderRadius: "0.375rem",
color: "#fafaf9",
cursor: "pointer",
font: "inherit",
fontWeight: 600,
padding: "0.625rem 0.875rem",
},
card: {
background: "#ffffff",
border: "1px solid #e7e5e4",
borderRadius: "0.75rem",
boxShadow: "0 12px 32px rgb(28 25 23 / 0.08)",
display: "grid",
gap: "1rem",
maxWidth: "24rem",
padding: "1.5rem",
width: "100%",
},
error: { color: "#9f1239", margin: 0 },
field: { display: "grid", gap: "0.375rem" },
form: { display: "grid", gap: "1rem" },
heading: { fontSize: "1.5rem", margin: 0 },
host: {
alignItems: "center",
background: "#fafaf9",
color: "#1c1917",
display: "flex",
fontFamily: "system-ui, sans-serif",
justifyContent: "center",
minHeight: "100vh",
padding: "1rem",
},
input: {
border: "1px solid #a8a29e",
borderRadius: "0.375rem",
font: "inherit",
padding: "0.625rem 0.75rem",
width: "100%",
},
introduction: { color: "#57534e", margin: 0 },
label: { fontWeight: 600 },
} as const;
/** Host-owned email/password sign-in. Modules never render or call this component. */ /** Host-owned email/password sign-in. Modules never render or call this component. */
export function LoginForm(props: LoginFormProps): ReactElement { export function LoginForm(props: LoginFormProps): ReactElement {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
@@ -108,11 +70,18 @@ export function LoginForm(props: LoginFormProps): ReactElement {
setPassword(""); setPassword("");
await authentication; await authentication;
} catch (error) { } catch (error) {
if (!attempt.signal.aborted && mountedRef.current) { if (attempt.signal.aborted || !mountedRef.current) {
return;
}
if (error instanceof HostAuthError && error.code === "AUTH_EMAIL_NOT_VERIFIED") {
// Not an error state on this form: the password was right, and the next thing the person
// needs is a verification link rather than a red message under a password box.
props.onEmailNotVerified(email);
return;
}
setErrorMessage( setErrorMessage(
error instanceof HostAuthError ? error.message : "Unable to sign in right now.", error instanceof HostAuthError ? error.message : "Unable to sign in right now.",
); );
}
} finally { } finally {
if (attemptRef.current === attempt) { if (attemptRef.current === attempt) {
attemptRef.current = undefined; attemptRef.current = undefined;
@@ -142,20 +111,11 @@ export function LoginForm(props: LoginFormProps): ReactElement {
}; };
return ( return (
<main data-testid="host-login" style={loginStyles.host}> <AuthCard heading="Sign in to Ekklesia" headingId="ekklesia-login-heading" testId="host-login">
<section aria-labelledby="ekklesia-login-heading" style={loginStyles.card}> <p style={authStyles.introduction}>Use your existing Ekklesia account.</p>
<h1 id="ekklesia-login-heading" style={loginStyles.heading}> <form aria-busy={pending} onKeyDown={submitOnEnter} onSubmit={submit} style={authStyles.form}>
Sign in to Ekklesia <label style={authStyles.field}>
</h1> <span style={authStyles.label}>Email</span>
<p style={loginStyles.introduction}>Use your existing Ekklesia account.</p>
<form
aria-busy={pending}
onKeyDown={submitOnEnter}
onSubmit={submit}
style={loginStyles.form}
>
<label style={loginStyles.field}>
<span style={loginStyles.label}>Email</span>
<input <input
autoComplete="username" autoComplete="username"
data-testid="login-email" data-testid="login-email"
@@ -164,13 +124,13 @@ export function LoginForm(props: LoginFormProps): ReactElement {
name="email" name="email"
onChange={(event) => setEmail(event.currentTarget.value)} onChange={(event) => setEmail(event.currentTarget.value)}
required required
style={loginStyles.input} style={authStyles.input}
type="email" type="email"
value={email} value={email}
/> />
</label> </label>
<label style={loginStyles.field}> <label style={authStyles.field}>
<span style={loginStyles.label}>Password</span> <span style={authStyles.label}>Password</span>
<input <input
aria-describedby={errorMessage ? "ekklesia-login-error" : undefined} aria-describedby={errorMessage ? "ekklesia-login-error" : undefined}
autoComplete="current-password" autoComplete="current-password"
@@ -180,21 +140,29 @@ export function LoginForm(props: LoginFormProps): ReactElement {
onChange={(event) => setPassword(event.currentTarget.value)} onChange={(event) => setPassword(event.currentTarget.value)}
ref={passwordRef} ref={passwordRef}
required required
style={loginStyles.input} style={authStyles.input}
type="password" type="password"
value={password} value={password}
/> />
</label> </label>
{errorMessage ? ( {errorMessage ? (
<p id="ekklesia-login-error" role="alert" style={loginStyles.error}> <p id="ekklesia-login-error" role="alert" style={authStyles.error}>
{errorMessage} {errorMessage}
</p> </p>
) : undefined} ) : undefined}
<button disabled={pending} style={loginStyles.button} type="submit"> <button disabled={pending} style={authStyles.button} type="submit">
{pending ? "Signing in…" : "Sign in"} {pending ? "Signing in…" : "Sign in"}
</button> </button>
<button
data-testid="login-forgot-password"
disabled={pending}
onClick={props.onForgotPassword}
style={authStyles.secondaryAction}
type="button"
>
Forgot password?
</button>
</form> </form>
</section> </AuthCard>
</main>
); );
} }
@@ -0,0 +1,145 @@
import { type ReactElement, useState } from "react";
import { HOST_PASSWORD_MAX_LENGTH, HOST_PASSWORD_MIN_LENGTH } from "../auth-protocol.js";
import { AuthCard, authStyles, useSingleSubmission } from "./AuthCard.js";
const PASSWORD_MISMATCH_MESSAGE = "The two passwords do not match.";
export interface ResetPasswordFormProps {
/** The opaque token captured from the emailed link. Never rendered and never stored. */
readonly token: string;
readonly onResetPassword: (
input: { readonly token: string; readonly newPassword: string },
signal: AbortSignal,
) => Promise<void>;
readonly onReturnToSignIn: () => void;
}
/**
* The host-owned "choose a new password" screen.
*
* The token reaches this component as a prop and goes straight into a request body. It is never
* placed in a URL this component builds, never written to storage, and never rendered — and the
* shell strips it from the address bar before this screen is even mounted (`browser/main.tsx`).
*
* Confirmation is required here even though the server needs one value: mistyping a password you
* cannot see, in the one flow whose whole purpose is that you have lost access, locks you out again.
* The mismatch check is a courtesy in the browser; the length policy is echoed for immediate
* feedback but the server enforces the identical bounds from the same configuration and remains
* authoritative.
*/
export function ResetPasswordForm(props: ResetPasswordFormProps): ReactElement {
const [password, setPassword] = useState("");
const [confirmation, setConfirmation] = useState("");
const [completed, setCompleted] = useState(false);
const { errorMessage, pending, setErrorMessage, submit } = useSingleSubmission(
"Unable to set a new password right now.",
);
if (completed) {
return (
<AuthCard
heading="Password updated"
headingId="ekklesia-reset-done-heading"
testId="host-reset-password-complete"
>
<p style={authStyles.introduction}>
Your password has been changed and every existing Ekklesia session for this account has
been signed out. Sign in with the new password.
</p>
<button
data-testid="reset-return-to-sign-in"
onClick={props.onReturnToSignIn}
style={authStyles.button}
type="button"
>
Go to sign in
</button>
</AuthCard>
);
}
return (
<AuthCard
heading="Choose a new password"
headingId="ekklesia-reset-heading"
testId="host-reset-password"
>
<p style={authStyles.introduction}>
Pick a password between {HOST_PASSWORD_MIN_LENGTH} and {HOST_PASSWORD_MAX_LENGTH}{" "}
characters.
</p>
<form
aria-busy={pending}
onSubmit={(event) =>
void submit(event, async (signal) => {
if (password !== confirmation) {
// Rejected before anything is sent: the server cannot detect a typo it never receives,
// and a mismatched confirmation is the one failure the browser genuinely does know
// about. Reported directly rather than thrown, because it is not a protocol failure.
setErrorMessage(PASSWORD_MISMATCH_MESSAGE);
return;
}
await props.onResetPassword({ newPassword: password, token: props.token }, signal);
setPassword("");
setConfirmation("");
setCompleted(true);
})
}
style={authStyles.form}
>
<label style={authStyles.field}>
<span style={authStyles.label}>New password</span>
<input
aria-describedby={errorMessage ? "ekklesia-reset-error" : undefined}
autoComplete="new-password"
data-testid="reset-password"
disabled={pending}
maxLength={HOST_PASSWORD_MAX_LENGTH}
minLength={HOST_PASSWORD_MIN_LENGTH}
name="newPassword"
onChange={(event) => setPassword(event.currentTarget.value)}
required
style={authStyles.input}
type="password"
value={password}
/>
</label>
<label style={authStyles.field}>
<span style={authStyles.label}>Confirm new password</span>
<input
autoComplete="new-password"
data-testid="reset-password-confirmation"
disabled={pending}
maxLength={HOST_PASSWORD_MAX_LENGTH}
minLength={HOST_PASSWORD_MIN_LENGTH}
name="confirmPassword"
onChange={(event) => {
setConfirmation(event.currentTarget.value);
setErrorMessage(undefined);
}}
required
style={authStyles.input}
type="password"
value={confirmation}
/>
</label>
{errorMessage ? (
<p id="ekklesia-reset-error" role="alert" style={authStyles.error}>
{errorMessage}
</p>
) : undefined}
<button disabled={pending} style={authStyles.button} type="submit">
{pending ? "Saving…" : "Set new password"}
</button>
<button
data-testid="reset-cancel"
onClick={props.onReturnToSignIn}
style={authStyles.secondaryAction}
type="button"
>
Back to sign in
</button>
</form>
</AuthCard>
);
}
@@ -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>
);
}
+35 -1
View File
@@ -1,4 +1,5 @@
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { type HostAuthLanding, readAuthLanding } from "../auth-protocol.js";
import type { EkklesiaHostConfig } from "../host-protocol.js"; import type { EkklesiaHostConfig } from "../host-protocol.js";
import { HostApp } from "./HostApp.js"; import { HostApp } from "./HostApp.js";
@@ -23,9 +24,42 @@ const config: EkklesiaHostConfig = {
apiBaseUrl: __EKKLESIA_API_BASE_URL__ || window.location.origin, apiBaseUrl: __EKKLESIA_API_BASE_URL__ || window.location.origin,
}; };
/**
* 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 parse is a pure function that recognizes only Ekklesia's own two landings, so nothing in the
* query string can name a destination.
*/
function captureAuthLanding(): HostAuthLanding | undefined {
const landing = readAuthLanding(window.location.search);
if (!landing) {
return undefined;
}
try {
window.history.replaceState(null, "", window.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;
}
const authLanding = captureAuthLanding();
const container = document.getElementById("ekklesia-host"); const container = document.getElementById("ekklesia-host");
if (!container) { if (!container) {
throw new Error("The Ekklesia shell requires a #ekklesia-host element."); throw new Error("The Ekklesia shell requires a #ekklesia-host element.");
} }
createRoot(container).render(<HostApp config={config} />); createRoot(container).render(
authLanding ? <HostApp authLanding={authLanding} config={config} /> : <HostApp config={config} />,
);
+481
View File
@@ -0,0 +1,481 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { type HostAuthLanding, readAuthLanding } from "../src/auth-protocol.js";
import { HostApp } from "../src/browser/HostApp.js";
import type { EkklesiaHostConfig } from "../src/host-protocol.js";
/**
* The host-owned recovery and verification screens (ADR 0038).
*
* Everything asserted here is a property of the *host's* browser surface: that an unverified account
* never reads as authenticated, that a reset token is never persisted or rendered, that a query
* string can only ever select among Ekklesia's own screens, and that no failure message carries a
* provider detail. The server-side behaviour these screens sit on top of is proven separately, end
* to end, in `apps/api/tests/email-verification.integration.ts`.
*/
declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
const apiBaseUrl = "https://ekklesia.example.test";
let container: HTMLDivElement;
let root: Root;
let consoleError: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
window.history.replaceState({}, "", "/");
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
});
afterEach(async () => {
await act(async () => {
root.unmount();
});
container.remove();
consoleError.mockRestore();
localStorage.clear();
sessionStorage.clear();
});
interface RecordedCall {
readonly url: string;
readonly body: string;
}
interface AuthStub {
/** Status for `/api/auth/sign-in/email`. 403 with `EMAIL_NOT_VERIFIED` is the unverified case. */
readonly signIn?: () => Response;
readonly requestPasswordReset?: () => Response;
readonly resetPassword?: () => Response;
readonly sendVerificationEmail?: () => Response;
}
interface Platform {
readonly fetch: typeof fetch;
readonly calls: RecordedCall[];
}
function platform(stub: AuthStub = {}): Platform {
const calls: RecordedCall[] = [];
const implementation = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
calls.push({ body: String(init?.body ?? ""), url });
if (url.endsWith("/api/v1/principal")) {
return Response.json({ kind: "anonymous" });
}
if (url.endsWith("/api/auth/sign-in/email")) {
return (
stub.signIn?.() ?? Response.json({ code: "INVALID_EMAIL_OR_PASSWORD" }, { status: 401 })
);
}
if (url.endsWith("/api/auth/request-password-reset")) {
return (
stub.requestPasswordReset?.() ??
Response.json({ message: "If this email exists in our system…", status: true })
);
}
if (url.endsWith("/api/auth/reset-password")) {
return stub.resetPassword?.() ?? Response.json({ status: true });
}
if (url.endsWith("/api/auth/send-verification-email")) {
return stub.sendVerificationEmail?.() ?? Response.json({ status: true });
}
return new Response("{}", { status: 404 });
});
return { calls, fetch: implementation as unknown as typeof fetch };
}
async function render(config: EkklesiaHostConfig, authLanding?: HostAuthLanding): Promise<void> {
await act(async () => {
root.render(<HostApp config={config} {...(authLanding ? { authLanding } : {})} />);
});
}
function text(): string {
return container.textContent ?? "";
}
function query(testId: string): Element | null {
return container.querySelector(`[data-testid="${testId}"]`);
}
async function setInput(testId: string, value: string): Promise<void> {
const input = query(testId) as HTMLInputElement | null;
if (!input) {
throw new Error(`missing input ${testId}`);
}
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}
async function click(testId: string): Promise<void> {
const element = query(testId) as HTMLElement | null;
if (!element) {
throw new Error(`missing element ${testId}`);
}
await act(async () => {
element.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
await Promise.resolve();
});
}
async function submit(hostTestId: string): Promise<void> {
const form = query(hostTestId)?.querySelector("form");
if (!form) {
throw new Error(`missing form in ${hostTestId}`);
}
await act(async () => {
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
await Promise.resolve();
});
}
describe("emailed-link landing parsing", () => {
it("recognizes exactly Ekklesia's own two landings", () => {
expect(readAuthLanding("?ekklesia_auth=verify-email")).toEqual({ kind: "verified" });
expect(readAuthLanding("?ekklesia_auth=verify-email&error=TOKEN_EXPIRED")).toEqual({
expired: true,
kind: "verification-failed",
});
expect(readAuthLanding("?ekklesia_auth=verify-email&error=INVALID_TOKEN")).toEqual({
expired: false,
kind: "verification-failed",
});
expect(readAuthLanding("?ekklesia_auth=reset-password&token=abc123")).toEqual({
kind: "reset-password",
token: "abc123",
});
expect(readAuthLanding("?ekklesia_auth=reset-password&error=INVALID_TOKEN")).toEqual({
kind: "reset-link-invalid",
});
});
it("ignores anything that is not one of them, so a query cannot steer the shell", () => {
for (const search of [
"",
"?",
"?token=abc123",
"?ekklesia_auth=dashboard",
"?ekklesia_auth=",
"?redirect=https://evil.example.test",
"?ekklesia_auth[]=verify-email",
]) {
expect(readAuthLanding(search)).toBeUndefined();
}
});
it("treats a reset landing with no usable token as an invalid link, never as a token", () => {
for (const search of [
"?ekklesia_auth=reset-password",
"?ekklesia_auth=reset-password&token=",
"?ekklesia_auth=reset-password&token=has%20space",
`?ekklesia_auth=reset-password&token=${"a".repeat(257)}`,
]) {
expect(readAuthLanding(search)).toEqual({ kind: "reset-link-invalid" });
}
});
it("names no destination of any kind, so there is nothing to redirect to", () => {
const landing = readAuthLanding(
"?ekklesia_auth=verify-email&callbackURL=https://evil.example.test&next=//evil.example.test",
);
expect(landing).toEqual({ kind: "verified" });
expect(JSON.stringify(landing)).not.toContain("evil");
});
});
describe("unverified sign-in", () => {
const unverified = (): Response =>
Response.json({ code: "EMAIL_NOT_VERIFIED", message: "Email not verified" }, { status: 403 });
it("offers verification instead of a credentials error, and never renders the Dashboard", async () => {
const stub = platform({ signIn: unverified });
await render({ apiBaseUrl, fetch: stub.fetch });
await setInput("login-email", "[email protected]");
await setInput("login-password", "correct horse battery staple");
await submit("host-login");
expect(query("host-verification-required")).not.toBeNull();
expect(query("host-login")).toBeNull();
// Not "your password is wrong": it was right. And not the Dashboard either — the server minted
// no session, so nothing about this state is authenticated.
expect(text()).not.toContain("Email or password is incorrect");
expect(text()).toContain("Your password was accepted");
expect(container.querySelector("aside")).toBeNull();
});
it("resends from the address the person just typed, and says nothing about whether it exists", async () => {
const stub = platform({ signIn: unverified });
await render({ apiBaseUrl, fetch: stub.fetch });
await setInput("login-email", "[email protected]");
await setInput("login-password", "correct horse battery staple");
await submit("host-login");
await submit("host-verification-required");
const resend = stub.calls.find((call) =>
call.url.endsWith("/api/auth/send-verification-email"),
);
expect(resend?.body).toBe(JSON.stringify({ email: "[email protected]" }));
expect(query("verification-resent")).not.toBeNull();
// The wording is a conditional, not a confirmation that an account exists.
expect(text()).toContain("If an unverified Ekklesia account exists");
});
it("keeps the password out of every request but the sign-in itself", async () => {
const stub = platform({ signIn: unverified });
await render({ apiBaseUrl, fetch: stub.fetch });
await setInput("login-email", "[email protected]");
await setInput("login-password", "correct horse battery staple");
await submit("host-login");
await submit("host-verification-required");
for (const call of stub.calls) {
expect(call.url).not.toContain("correct horse");
if (!call.url.endsWith("/api/auth/sign-in/email")) {
expect(call.body).not.toContain("correct horse");
}
}
});
});
describe("forgot password", () => {
it("reaches the recovery form from the sign-in screen and back again", async () => {
const stub = platform();
await render({ apiBaseUrl, fetch: stub.fetch });
await click("login-forgot-password");
expect(query("host-forgot-password")).not.toBeNull();
await click("forgot-cancel");
expect(query("host-login")).not.toBeNull();
});
it("shows one generic completion state, whatever the address", async () => {
const stub = platform();
await render({ apiBaseUrl, fetch: stub.fetch });
await click("login-forgot-password");
await setInput("forgot-email", "[email protected]");
await submit("host-forgot-password");
expect(query("host-forgot-password-sent")).not.toBeNull();
expect(text()).toContain("If an Ekklesia account exists for that address");
// Nothing on the screen distinguishes an address with an account from one without.
expect(text()).not.toContain("[email protected]");
expect(text()).not.toContain("No account");
});
it("submits once even when the form is submitted repeatedly", async () => {
let release: (() => void) | undefined;
const stub = platform({
requestPasswordReset: () => Response.json({ status: true }),
});
const gated = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith("/api/auth/request-password-reset")) {
await new Promise<void>((resolve) => {
release = resolve;
});
}
return (stub.fetch as unknown as typeof fetch)(input, init);
});
await render({ apiBaseUrl, fetch: gated as unknown as typeof fetch });
await click("login-forgot-password");
await setInput("forgot-email", "[email protected]");
await submit("host-forgot-password");
await submit("host-forgot-password");
await submit("host-forgot-password");
release?.();
await act(async () => {
await Promise.resolve();
});
// Every one of these triggers an email. A duplicate is not merely untidy.
const requests = stub.calls.filter((call) =>
call.url.endsWith("/api/auth/request-password-reset"),
);
expect(requests).toHaveLength(1);
});
it("reports a transport failure without a provider detail or a URL", async () => {
const stub = platform({
requestPasswordReset: () => new Response("relay 421 at smtp.example.org", { status: 500 }),
});
await render({ apiBaseUrl, fetch: stub.fetch });
await click("login-forgot-password");
await setInput("forgot-email", "[email protected]");
await submit("host-forgot-password");
expect(text()).toContain("Unable to sign in right now.");
expect(text()).not.toContain("smtp.example.org");
expect(text()).not.toContain("421");
expect(text()).not.toContain(apiBaseUrl);
});
});
describe("reset password", () => {
const landing: HostAuthLanding = { kind: "reset-password", token: "reset-token-abc" };
it("sets a new password and returns cleanly to sign-in", async () => {
const stub = platform();
await render({ apiBaseUrl, fetch: stub.fetch }, landing);
expect(query("host-reset-password")).not.toBeNull();
await setInput("reset-password", "a brand new passphrase");
await setInput("reset-password-confirmation", "a brand new passphrase");
await submit("host-reset-password");
const request = stub.calls.find((call) => call.url.endsWith("/api/auth/reset-password"));
expect(JSON.parse(request?.body ?? "{}")).toEqual({
newPassword: "a brand new passphrase",
token: "reset-token-abc",
});
expect(query("host-reset-password-complete")).not.toBeNull();
expect(text()).toContain(
"every existing Ekklesia session for this account has been signed out",
);
await click("reset-return-to-sign-in");
expect(query("host-login")).not.toBeNull();
});
it("blocks a mismatched confirmation in the browser, before anything is sent", async () => {
const stub = platform();
await render({ apiBaseUrl, fetch: stub.fetch }, landing);
await setInput("reset-password", "a brand new passphrase");
await setInput("reset-password-confirmation", "a brand new passphrasf");
await submit("host-reset-password");
expect(text()).toContain("The two passwords do not match.");
expect(stub.calls.some((call) => call.url.endsWith("/api/auth/reset-password"))).toBe(false);
});
it("surfaces the server's password policy without inventing one of its own", async () => {
const stub = platform({
resetPassword: () => Response.json({ code: "PASSWORD_TOO_SHORT" }, { status: 400 }),
});
await render({ apiBaseUrl, fetch: stub.fetch }, landing);
await setInput("reset-password", "short");
await setInput("reset-password-confirmation", "short");
await submit("host-reset-password");
// The server stays authoritative; the browser only echoes the bounds it was configured with.
expect(text()).toContain("between 8 and 128 characters");
const field = query("reset-password") as HTMLInputElement;
expect(field.minLength).toBe(8);
expect(field.maxLength).toBe(128);
});
it("reports a spent or expired token as a link problem, not a password problem", async () => {
const stub = platform({
resetPassword: () => Response.json({ code: "INVALID_TOKEN" }, { status: 400 }),
});
await render({ apiBaseUrl, fetch: stub.fetch }, landing);
await setInput("reset-password", "a brand new passphrase");
await setInput("reset-password-confirmation", "a brand new passphrase");
await submit("host-reset-password");
expect(text()).toContain("This password reset link is no longer valid");
});
it("never persists the token, and never renders it", async () => {
const stub = platform();
await render({ apiBaseUrl, fetch: stub.fetch }, landing);
await setInput("reset-password", "a brand new passphrase");
await setInput("reset-password-confirmation", "a brand new passphrase");
await submit("host-reset-password");
// A reset token is a bearer credential for one account. It lives in React state for the life of
// the form and nowhere else.
expect(localStorage.length).toBe(0);
expect(sessionStorage.length).toBe(0);
expect(text()).not.toContain("reset-token-abc");
expect(container.innerHTML).not.toContain("reset-token-abc");
expect(window.location.search).toBe("");
for (const call of stub.calls) {
expect(call.url).not.toContain("reset-token-abc");
}
});
it("shows a dead-end-free screen for an invalid reset link", async () => {
await render({ apiBaseUrl, fetch: platform().fetch }, { kind: "reset-link-invalid" });
expect(query("host-reset-link-invalid")).not.toBeNull();
expect(text()).toContain("Password reset links last one hour and can be used once");
await click("reset-invalid-continue");
expect(query("host-login")).not.toBeNull();
});
});
describe("verification landing", () => {
it("confirms success and offers sign-in, without pretending to be signed in", async () => {
await render({ apiBaseUrl, fetch: platform().fetch }, { kind: "verified" });
expect(query("host-verification-succeeded")).not.toBeNull();
expect(text()).toContain("This address is confirmed");
// Following an emailed link mints no session, so the Dashboard must not appear.
expect(container.querySelector("aside")).toBeNull();
await click("verified-continue");
expect(query("host-login")).not.toBeNull();
});
it("says an expired link has expired, which is the actionable version", async () => {
await render(
{ apiBaseUrl, fetch: platform().fetch },
{ expired: true, kind: "verification-failed" },
);
expect(query("host-verification-failed")).not.toBeNull();
expect(text()).toContain("This link has expired");
expect(text()).toContain("Ekklesia will send a new one");
});
it("says an unusable link is invalid rather than guessing that it expired", async () => {
await render(
{ apiBaseUrl, fetch: platform().fetch },
{ expired: false, kind: "verification-failed" },
);
expect(text()).toContain("This link is not valid");
expect(text()).not.toContain("has expired");
});
it("still renders the Dashboard for someone who is already signed in", async () => {
const authenticated = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/v1/principal")) {
return Response.json({ kind: "authenticated", userId: "user-1" });
}
if (url.endsWith("/api/v1/dashboard/navigation")) {
return Response.json({ groups: [], ungrouped: [] });
}
return new Response("{}", { status: 404 });
});
await render(
{ apiBaseUrl, fetch: authenticated as unknown as typeof fetch },
{ kind: "verified" },
);
// The principal is still what decides. A landing parameter never promotes anyone.
expect(query("host-verification-succeeded")).toBeNull();
expect(query("host-login")).toBeNull();
});
});
+9 -2
View File
@@ -295,7 +295,7 @@ describe("Ekklesia shell — browser authentication", () => {
expect(calls).toEqual([`${apiBaseUrl}/api/v1/principal`]); expect(calls).toEqual([`${apiBaseUrl}/api/v1/principal`]);
}); });
it("renders an accessible semantic email/password form with no signup or dead reset action", async () => { it("renders an accessible semantic email/password form with a real reset action and no signup", async () => {
const platform = statefulAuthPlatform(); const platform = statefulAuthPlatform();
await render({ apiBaseUrl, fetch: platform.fetch }); await render({ apiBaseUrl, fetch: platform.fetch });
@@ -312,8 +312,15 @@ describe("Ekklesia shell — browser authentication", () => {
expect(password.labels?.[0]?.textContent).toContain("Password"); expect(password.labels?.[0]?.textContent).toContain("Password");
expect(submit.textContent).toBe("Sign in"); expect(submit.textContent).toBe("Sign in");
expect(form?.tagName).toBe("FORM"); expect(form?.tagName).toBe("FORM");
// Public registration remains disabled, so the shell still offers no way to ask for an account.
expect(text()).not.toContain("Create account"); expect(text()).not.toContain("Create account");
expect(text()).not.toContain("Forgot password"); expect(text()).not.toContain("Sign up");
// Password recovery is no longer a dead action: Phase 7C Pass 2 gave it a real backend flow, so
// the control exists and is a real button rather than decorative text.
const forgot = query("login-forgot-password") as HTMLButtonElement;
expect(forgot.tagName).toBe("BUTTON");
expect(forgot.type).toBe("button");
expect(forgot.textContent).toBe("Forgot password?");
}); });
it("fails invalid credentials generically, clears the password, and never puts it in a URL", async () => { it("fails invalid credentials generically, clears the password, and never puts it in a URL", async () => {
+1
View File
@@ -17,6 +17,7 @@ export default defineConfig({
"apps/api/tests/api-http.integration.ts", "apps/api/tests/api-http.integration.ts",
"apps/api/tests/initial-owner.integration.ts", "apps/api/tests/initial-owner.integration.ts",
"apps/api/tests/device-pairing.integration.ts", "apps/api/tests/device-pairing.integration.ts",
"apps/api/tests/email-verification.integration.ts",
"apps/api/tests/module-database.integration.ts", "apps/api/tests/module-database.integration.ts",
], ],
passWithNoTests: false, passWithNoTests: false,