Merge pull request 'feat: verify email addresses and recover forgotten passwords' (#38) from phase-7c/email-verification-recovery into main
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/38
This commit was merged in pull request #38.
This commit is contained in:
@@ -81,3 +81,18 @@ S3_BUCKET=ekklesia-development
|
||||
# EKKLESIA_SMTP_PASSWORD=
|
||||
# [email protected]
|
||||
# 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.
|
||||
|
||||
@@ -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
|
||||
[API](docs/development/api.md) and
|
||||
[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
|
||||
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,
|
||||
@@ -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
|
||||
`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
|
||||
nothing sends mail yet. `pnpm doctor` reports the configuration and can probe connectivity **without
|
||||
sending anything**, while stating plainly that a successful connection does not establish
|
||||
deliverability. **Email verification, password reset, and invitations are Pass 2** — Better Auth's
|
||||
`sendResetPassword` and `sendVerificationEmail` hooks are still unimplemented, and this transport is
|
||||
the seam they will use. See [Outbound email](docs/development/email.md).
|
||||
Email remains **optional**: an installation with no SMTP configured is a complete deployment.
|
||||
`pnpm doctor` reports the configuration and can probe connectivity **without sending anything**,
|
||||
while stating plainly that a successful connection does not establish deliverability. 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
|
||||
`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 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
|
||||
```
|
||||
|
||||
@@ -31,8 +31,10 @@
|
||||
"@ekklesia/typescript-config": "workspace:*",
|
||||
"@types/node": "24.13.3",
|
||||
"@types/pg": "8.20.0",
|
||||
"@types/smtp-server": "3.5.13",
|
||||
"better-auth": "1.6.23",
|
||||
"pg": "8.22.0",
|
||||
"smtp-server": "3.19.3",
|
||||
"typescript": "7.0.2",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
|
||||
@@ -79,8 +79,10 @@ export interface ApiRuntimeConfig {
|
||||
* The validated host outbound-email provider, or `undefined` when this installation has none
|
||||
* (ADR 0037).
|
||||
*
|
||||
* `undefined` is a complete, valid deployment: nothing in the platform sends email yet, and a
|
||||
* future feature that needs to discovers a structured `MAIL_NOT_CONFIGURED` rather than crashing.
|
||||
* `undefined` is a valid deployment: the platform starts, every existing feature works, and the
|
||||
* 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
|
||||
* API response ever sees the host or the password.
|
||||
*/
|
||||
|
||||
+34
-16
@@ -13,7 +13,12 @@ import {
|
||||
import { createDashboardStore } from "@ekklesia/core/dashboard";
|
||||
import { createDeviceStore } from "@ekklesia/core/devices";
|
||||
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 {
|
||||
createModuleConfigurationStore,
|
||||
@@ -138,6 +143,15 @@ export interface StartApiOptions {
|
||||
/** Test-only escape hatch: never set by the executable entry point. See
|
||||
* `IdentityRateLimitOverride` for why production must leave this unset. */
|
||||
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
|
||||
* executable entry point. See `prepareStartupModuleInventory`. */
|
||||
readonly modulesDirectoryOverride?: string;
|
||||
@@ -430,6 +444,14 @@ export async function startApi(
|
||||
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 runtime: ReturnType<typeof createIdentityRuntime>;
|
||||
let authorizationStore: ReturnType<typeof createAuthorizationStore>;
|
||||
@@ -439,11 +461,22 @@ export async function startApi(
|
||||
runtime = createIdentityRuntime({
|
||||
config: config.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 } : {}),
|
||||
});
|
||||
authorizationStore = createAuthorizationStore(databaseHandle.authorization);
|
||||
deviceStore = createDeviceStore(databaseHandle.devices);
|
||||
} catch (error) {
|
||||
await safeCloseStorage(storageProvider);
|
||||
await safeCloseMail(mailProvider);
|
||||
await safeCloseDatabase(databaseHandle);
|
||||
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
|
||||
// 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.
|
||||
// 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 moduleConfigurationStore = createModuleConfigurationStore(
|
||||
databaseHandle.moduleConfiguration,
|
||||
|
||||
@@ -224,6 +224,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0004_module_lifecycle_state",
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
]);
|
||||
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
|
||||
@@ -522,6 +523,32 @@ function apiOrigin(running: RunningApi): string {
|
||||
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 {
|
||||
readonly clientAddress?: ClientAddressConfigInput;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
url: string,
|
||||
options: StartConfiguredApiOptions = {},
|
||||
@@ -571,7 +608,7 @@ async function startConfiguredApi(
|
||||
},
|
||||
{ environment },
|
||||
);
|
||||
return startApi(config, {
|
||||
const running = await startApi(config, {
|
||||
...("rateLimitOverride" in options
|
||||
? { ...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}) }
|
||||
: { rateLimitOverride: { max: 1000, window: 1 } }),
|
||||
@@ -585,6 +622,8 @@ async function startConfiguredApi(
|
||||
? {}
|
||||
: { drainTimeoutMsOverride: options.drainTimeoutMsOverride }),
|
||||
});
|
||||
databaseUrlByOrigin.set(apiOrigin(running), url);
|
||||
return running;
|
||||
}
|
||||
|
||||
async function startMigratedApi(
|
||||
@@ -763,6 +802,12 @@ async function signUp(
|
||||
headers: { "content-type": "application/json", origin: dashboardOrigin, ...headers },
|
||||
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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,9 @@ describe("API runtime configuration", () => {
|
||||
host: "127.0.0.1",
|
||||
identity: {
|
||||
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,
|
||||
secret: validSecret,
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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> {
|
||||
const config = createApiRuntimeConfig(
|
||||
/**
|
||||
* The production posture: public signup disabled, exactly as a real deployment must run — and, just
|
||||
* 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,
|
||||
host: "127.0.0.1",
|
||||
@@ -186,7 +190,10 @@ async function startApiWithoutSignup(url: string): Promise<RunningApi> {
|
||||
},
|
||||
{ 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 {
|
||||
@@ -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 () => {
|
||||
const admin = await openAdministrativeClient().catch(() => undefined);
|
||||
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:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
@@ -253,6 +254,7 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
|
||||
"core:0004_module_lifecycle_state",
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
"db-facade:0000_initial",
|
||||
]);
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
|
||||
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" });
|
||||
|
||||
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");
|
||||
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
@@ -43,6 +43,12 @@
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "0007_verified_bootstrap_owners",
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -50,6 +50,13 @@
|
||||
"when": 1787543773057,
|
||||
"tag": "0006_device_identity",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1787543773058,
|
||||
"tag": "0007_verified_bootstrap_owners",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export type InitialPlatformOwnerErrorCode =
|
||||
| "INITIAL_OWNER_EMAIL_INVALID"
|
||||
| "INITIAL_OWNER_IDENTITY_NOT_CREATED"
|
||||
| "INITIAL_OWNER_IDENTITY_UNUSABLE"
|
||||
| "INITIAL_OWNER_IDENTITY_NOT_VERIFIED"
|
||||
| "INITIAL_OWNER_OWNERSHIP_GRANT_FAILED"
|
||||
| "INITIAL_OWNER_PASSWORD_TOO_LONG"
|
||||
| "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.",
|
||||
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.",
|
||||
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:
|
||||
"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.",
|
||||
@@ -131,6 +134,25 @@ async function findIdentityByEmail(
|
||||
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 {
|
||||
if (error instanceof BootstrapIdentityError) {
|
||||
if (error.code === "BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT") {
|
||||
@@ -252,6 +274,29 @@ export async function createInitialPlatformOwner(
|
||||
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 {
|
||||
const grant =
|
||||
dependencies.grantOwnership ??
|
||||
|
||||
@@ -116,6 +116,7 @@ export interface DoctorReport {
|
||||
|
||||
export interface DoctorIdentityConfigurationOptions {
|
||||
readonly baseUrl?: string | undefined;
|
||||
readonly dashboardOrigin?: string | undefined;
|
||||
readonly environment?: CreateIdentityConfigOptions["environment"];
|
||||
readonly publicSignupEnabled?: string | undefined;
|
||||
readonly secret?: string | undefined;
|
||||
@@ -1193,7 +1194,7 @@ export async function diagnoseEkklesia(
|
||||
"skipped",
|
||||
"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 {
|
||||
|
||||
@@ -26,6 +26,9 @@ export type HostEnvironmentName = "development" | "production";
|
||||
*/
|
||||
export interface HostIdentityEnvironmentInput {
|
||||
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 secret: string | undefined;
|
||||
readonly trustedOrigins: readonly string[] | undefined;
|
||||
@@ -118,6 +121,7 @@ export function loadHostConfiguration(environment: HostEnvironment): HostConfigu
|
||||
environment: environment.NODE_ENV === "development" ? "development" : "production",
|
||||
identity: {
|
||||
baseUrl: environment.EKKLESIA_IDENTITY_BASE_URL,
|
||||
dashboardOrigin: environment.EKKLESIA_DASHBOARD_ORIGIN,
|
||||
publicSignupEnabled: environment.EKKLESIA_PUBLIC_SIGNUP_ENABLED,
|
||||
secret: environment.EKKLESIA_IDENTITY_SECRET,
|
||||
trustedOrigins: splitList(environment.EKKLESIA_IDENTITY_TRUSTED_ORIGINS),
|
||||
|
||||
@@ -5,6 +5,10 @@ export type IdentityConfigErrorCode =
|
||||
| "BASE_URL_INVALID"
|
||||
| "BASE_URL_LOOPBACK_IN_PRODUCTION"
|
||||
| "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_UNSUPPORTED_IN_PRODUCTION"
|
||||
| "SECRET_LOW_ENTROPY"
|
||||
@@ -127,6 +131,13 @@ const errorMessages: Readonly<Record<IdentityConfigErrorCode, string>> = Object.
|
||||
BASE_URL_LOOPBACK_IN_PRODUCTION:
|
||||
"The identity base URL must not resolve to a loopback host in production.",
|
||||
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 enablement must be an explicit boolean or the exact string "true"/"false".',
|
||||
PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION:
|
||||
@@ -163,6 +174,21 @@ export class IdentityConfigError extends Error {
|
||||
|
||||
export interface IdentityConfig {
|
||||
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) —
|
||||
* public signup never becomes reachable merely because this input was omitted, misspelled, or
|
||||
* loosely parsed. Development and test composition may enable it explicitly; production
|
||||
@@ -176,6 +202,8 @@ export interface IdentityConfig {
|
||||
|
||||
export interface IdentityConfigInput {
|
||||
readonly baseUrl?: unknown;
|
||||
/** Optional. Absent or empty means the Dashboard is served from `baseUrl`. */
|
||||
readonly dashboardOrigin?: unknown;
|
||||
readonly publicSignupEnabled?: unknown;
|
||||
readonly secret?: unknown;
|
||||
readonly trustedOrigins?: unknown;
|
||||
@@ -261,6 +289,45 @@ function validateBaseUrl(baseUrl: unknown, environment: IdentityEnvironment): st
|
||||
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 {
|
||||
if (typeof origin !== "string" || origin.length === 0) {
|
||||
throw new IdentityConfigError("TRUSTED_ORIGIN_INVALID");
|
||||
@@ -355,7 +422,19 @@ export function createIdentityConfig(
|
||||
const secret = validateSecret(input.secret);
|
||||
const baseUrl = validateBaseUrl(input.baseUrl, environment);
|
||||
const trustedOrigins = validateTrustedOrigins(input.trustedOrigins, environment);
|
||||
const dashboardOrigin = validateDashboardOrigin(
|
||||
input.dashboardOrigin,
|
||||
baseUrl,
|
||||
trustedOrigins,
|
||||
environment,
|
||||
);
|
||||
const publicSignupEnabled = validatePublicSignupEnabled(input.publicSignupEnabled, environment);
|
||||
|
||||
return Object.freeze({ baseUrl, publicSignupEnabled, secret, trustedOrigins });
|
||||
return Object.freeze({
|
||||
baseUrl,
|
||||
dashboardOrigin,
|
||||
publicSignupEnabled,
|
||||
secret,
|
||||
trustedOrigins,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,4 +2,5 @@ export * from "./bootstrap-identity.js";
|
||||
export * from "./client-address.js";
|
||||
export * from "./config.js";
|
||||
export * from "./database.js";
|
||||
export * from "./notifications.js";
|
||||
export * from "./runtime.js";
|
||||
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { getCookies } from "better-auth/cookies";
|
||||
import { sql } from "drizzle-orm";
|
||||
import type { IdentityConfig } from "./config.js";
|
||||
import type { IdentityDrizzleDatabase } from "./database.js";
|
||||
import type { IdentityNotifications } from "./notifications.js";
|
||||
import { ekklesiaCoreSession, identityAdapterSchema } from "./schema.js";
|
||||
|
||||
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.";
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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 db: IdentityDrizzleDatabase;
|
||||
/**
|
||||
* A narrow, explicit override for the sign-in/sign-up rate-limit window 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 strict built-in default
|
||||
* (3 requests / 10 seconds on sign-in and sign-up) applies unmodified.
|
||||
* How verification and password-reset messages are delivered.
|
||||
*
|
||||
* Injected rather than constructed here, for the same reason the database is: this file composes
|
||||
* 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;
|
||||
}
|
||||
@@ -264,9 +294,81 @@ export function createIdentityRuntime(
|
||||
enabled: true,
|
||||
// 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
|
||||
// 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,
|
||||
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: {
|
||||
disabled: false,
|
||||
@@ -293,6 +395,18 @@ export function createIdentityRuntime(
|
||||
...(rateLimitOverride
|
||||
? {
|
||||
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": {
|
||||
max: rateLimitOverride.max ?? 100,
|
||||
window: rateLimitOverride.window ?? 10,
|
||||
|
||||
@@ -177,6 +177,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0004_module_lifecycle_state",
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
|
||||
@@ -176,6 +176,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0004_module_lifecycle_state",
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
|
||||
@@ -126,7 +126,7 @@ Object storage
|
||||
|
||||
Outbound email
|
||||
○ 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`);
|
||||
});
|
||||
|
||||
@@ -62,6 +62,7 @@ const INTERPRETED_VARIABLES = Object.freeze([
|
||||
"EKKLESIA_CLIENT_ADDRESS_HEADER",
|
||||
"EKKLESIA_CLIENT_ADDRESS_MODE",
|
||||
"EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES",
|
||||
"EKKLESIA_DASHBOARD_ORIGIN",
|
||||
"EKKLESIA_DATABASE_CA",
|
||||
"EKKLESIA_DATABASE_TLS_MODE",
|
||||
"EKKLESIA_IDENTITY_BASE_URL",
|
||||
|
||||
@@ -26,6 +26,9 @@ describe("Core identity configuration", () => {
|
||||
|
||||
expect(config).toEqual({
|
||||
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,
|
||||
secret: validSecret,
|
||||
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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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("<script>");
|
||||
});
|
||||
|
||||
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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ import { identityAdapterSchema } from "../src/identity/schema.js";
|
||||
function validConfig(overrides: Partial<IdentityConfig> = {}): IdentityConfig {
|
||||
return Object.freeze({
|
||||
baseUrl: "https://identity.example.test",
|
||||
dashboardOrigin: "https://identity.example.test",
|
||||
publicSignupEnabled: false,
|
||||
secret: "correct-horse-battery-staple-0123456789",
|
||||
trustedOrigins: Object.freeze(["https://identity.example.test"]),
|
||||
@@ -109,18 +110,21 @@ describe("Core identity runtime construction boundary", () => {
|
||||
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() });
|
||||
|
||||
createIdentityRuntime({ config: validConfig(), db: fakeDb() });
|
||||
|
||||
expect(betterAuthMock).toHaveBeenCalledTimes(1);
|
||||
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([
|
||||
"advanced",
|
||||
"baseURL",
|
||||
"database",
|
||||
"emailAndPassword",
|
||||
"emailVerification",
|
||||
"logger",
|
||||
"onAPIError",
|
||||
"rateLimit",
|
||||
@@ -133,13 +137,60 @@ describe("Core identity runtime construction boundary", () => {
|
||||
enabled: true,
|
||||
maxPasswordLength: 128,
|
||||
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.baseURL).toBe(validConfig().baseUrl);
|
||||
expect(options.secret).toBe(validConfig().secret);
|
||||
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", () => {
|
||||
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() });
|
||||
|
||||
createIdentityRuntime({
|
||||
@@ -266,6 +317,10 @@ describe("Core identity runtime construction boundary", () => {
|
||||
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
|
||||
expect(options.rateLimit).toEqual({
|
||||
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-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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -42,30 +42,42 @@ async function readTextFiles(directory: string): Promise<Array<{ path: string; t
|
||||
}
|
||||
|
||||
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");
|
||||
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
|
||||
// reach. None may carry a mail transport.
|
||||
// reach. None may carry anything mail-related at all, in either dependency list.
|
||||
for (const manifest of [
|
||||
"packages/contracts/package.json",
|
||||
"packages/module-sdk/package.json",
|
||||
"packages/module-ui-sdk/package.json",
|
||||
"packages/module-ui-host/package.json",
|
||||
"surfaces/shell/package.json",
|
||||
"apps/api/package.json",
|
||||
]) {
|
||||
const parsed = await readJson(manifest);
|
||||
const dependencies = {
|
||||
...((parsed.dependencies ?? {}) as Record<string, string>),
|
||||
...((parsed.devDependencies ?? {}) as Record<string, string>),
|
||||
};
|
||||
const mailDependencies = Object.keys(dependencies).filter((name) =>
|
||||
/nodemailer|smtp|mailgun|sendgrid|postmark|resend/iu.test(name),
|
||||
);
|
||||
expect(mailDependencies, manifest).toEqual([]);
|
||||
expect(
|
||||
mailPackages([
|
||||
...Object.keys((parsed.dependencies ?? {}) as object),
|
||||
...Object.keys((parsed.devDependencies ?? {}) as object),
|
||||
]),
|
||||
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 () => {
|
||||
|
||||
@@ -178,6 +178,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0004_module_lifecycle_state",
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} finally {
|
||||
|
||||
@@ -181,6 +181,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
"core:0004_module_lifecycle_state",
|
||||
"core:0005_module_configuration",
|
||||
"core:0006_device_identity",
|
||||
"core:0007_verified_bootstrap_owners",
|
||||
]);
|
||||
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||
} 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.
|
||||
@@ -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
|
||||
- [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
|
||||
- [0038: Email verification and password recovery, with a trusted-bootstrap exception](0038-email-verification-and-password-recovery.md) — Accepted
|
||||
|
||||
+25
-2
@@ -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
|
||||
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,
|
||||
password, URL, or stack reaches browser output or logging. The form deliberately offers no signup
|
||||
or password-reset link because neither is a supported production flow.
|
||||
password, URL, or stack reaches browser output or logging. The form offers **no signup** control,
|
||||
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,
|
||||
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
|
||||
[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 sign-up is disabled by default** (`EKKLESIA_PUBLIC_SIGNUP_ENABLED` unset or anything other
|
||||
|
||||
@@ -3,9 +3,14 @@
|
||||
Ekklesia can send transactional email through a host-configured SMTP transport
|
||||
([ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md)).
|
||||
|
||||
**Nothing in the platform sends email yet.** This pass added the transport; email verification,
|
||||
password reset, and invitations are Phase 7C Pass 2. An installation with no SMTP configured is a
|
||||
complete, valid deployment — the platform starts and every existing feature works.
|
||||
Two things send email: **email verification** and **password recovery**
|
||||
([ADR 0038](../architecture/decisions/0038-email-verification-and-password-recovery.md)). Invitations
|
||||
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
|
||||
|
||||
@@ -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.**
|
||||
|
||||
## Next
|
||||
## What the platform actually sends
|
||||
|
||||
Phase 7C Pass 2 wires Better Auth's `sendResetPassword` and `emailVerification.sendVerificationEmail`
|
||||
hooks — both currently unimplemented — to this transport. See
|
||||
[Identity](identity.md#outbound-email-and-the-verification-seam).
|
||||
| message | when | contents |
|
||||
| --- | --- | --- |
|
||||
| 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
@@ -253,12 +253,12 @@ logs, and error carry no relation name, column name, SQL, driver message, host,
|
||||
|
||||
## What is and is not operational
|
||||
|
||||
Email/password sign-in, principal resolution, sign-out, and the Dashboard's host-owned browser flow
|
||||
are operational — see [API](api.md) and ADR 0034. Public registration remains a future product
|
||||
policy, not a production capability: it is disabled by default, may be explicitly enabled only for
|
||||
development/test, and production rejects it. No email verification, password recovery, invitation
|
||||
flow, email delivery, social or OAuth provider, passkey, magic link, two-factor authentication, or
|
||||
roles/groups/teams/tenants model exists.
|
||||
Email/password sign-in, principal resolution, sign-out, the Dashboard's host-owned browser flow,
|
||||
email verification, and password recovery are operational — see [API](api.md), ADR 0034, and
|
||||
ADR 0038. Public registration remains a future product policy, not a production capability: it is
|
||||
disabled by default, may be explicitly enabled only for development/test, and production rejects it.
|
||||
No invitation flow, social or OAuth provider, passkey, magic link, two-factor authentication,
|
||||
email-change verification, or roles/groups/teams/tenants model exists.
|
||||
|
||||
Core's authorization foundation (platform owners, explicit grants, and an immutable permission
|
||||
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
|
||||
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
|
||||
([ADR 0037](../architecture/decisions/0037-host-provided-outbound-email.md), see
|
||||
[Outbound email](email.md)) — but **no identity flow uses it yet.**
|
||||
A verified email address is a **precondition for holding a session**
|
||||
([ADR 0038](../architecture/decisions/0038-email-verification-and-password-recovery.md)). Better Auth
|
||||
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
|
||||
neither:
|
||||
That is why there is no "unverified principal": a correct password on an unverified address carries
|
||||
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 |
|
||||
| `emailVerification.sendVerificationEmail({ user, url, token })` | Email verification |
|
||||
| token minting, expiry, consumption, password hashing, session revocation | Better Auth |
|
||||
| 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
|
||||
and closes it on every shutdown path, so the remaining work is to inject it into
|
||||
`createIdentityRuntime` and write the two message bodies.
|
||||
Ekklesia implements no token table, no token hashing, and no expiry clock. A structural test asserts
|
||||
that none appears.
|
||||
|
||||
**The `url` and `token` those hooks receive are credentials.** The entire security of both flows is
|
||||
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.
|
||||
### Token semantics, stated precisely
|
||||
|
||||
Until that pass lands: email verification, password recovery, and invitations do not exist, and
|
||||
`requireEmailVerification` stays off — turning it on without a working sender would lock every
|
||||
account out.
|
||||
| | verification | password reset |
|
||||
| --- | --- | --- |
|
||||
| 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
|
||||
|
||||
@@ -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,
|
||||
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
|
||||
|
||||
It is prompted for twice, invisibly, when a terminal is attached. `--password` is **refused with an
|
||||
|
||||
@@ -166,7 +166,7 @@ Object storage
|
||||
|
||||
Outbound email
|
||||
○ 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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
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
|
||||
adds no open-redirect input. It exposes no signup or recovery link: production signup is prohibited
|
||||
and no email delivery/recovery system exists. The existing cookie stays host-only; exact trusted
|
||||
The flow remains at the current Dashboard URL and accepts no callback/return target, so it adds no
|
||||
open-redirect input. It exposes **no signup** link, because production signup is prohibited. It does
|
||||
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
|
||||
boundary. Broadening the cookie to a parent domain or inventing a Dashboard hostname would be a
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
- 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
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
|
||||
Generated
+6
@@ -67,12 +67,18 @@ importers:
|
||||
'@types/pg':
|
||||
specifier: 8.20.0
|
||||
version: 8.20.0
|
||||
'@types/smtp-server':
|
||||
specifier: 3.5.13
|
||||
version: 3.5.13
|
||||
better-auth:
|
||||
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])))
|
||||
pg:
|
||||
specifier: 8.22.0
|
||||
version: 8.22.0
|
||||
smtp-server:
|
||||
specifier: 3.19.3
|
||||
version: 3.19.3
|
||||
typescript:
|
||||
specifier: 7.0.2
|
||||
version: 7.0.2
|
||||
|
||||
@@ -3,6 +3,19 @@ import { resolveApiUrl } from "./host-protocol.js";
|
||||
|
||||
const SIGN_IN_PATH = "/api/auth/sign-in/email";
|
||||
const SIGN_OUT_PATH = "/api/auth/sign-out";
|
||||
const REQUEST_PASSWORD_RESET_PATH = "/api/auth/request-password-reset";
|
||||
const RESET_PASSWORD_PATH = "/api/auth/reset-password";
|
||||
const SEND_VERIFICATION_EMAIL_PATH = "/api/auth/send-verification-email";
|
||||
|
||||
/**
|
||||
* The identity system's own password bounds, mirrored here for immediate form feedback only.
|
||||
*
|
||||
* The server re-checks both from the same configuration and remains authoritative; this exists so
|
||||
* someone typing a five-character password is told before a round trip, not so the browser decides
|
||||
* anything.
|
||||
*/
|
||||
export const HOST_PASSWORD_MIN_LENGTH = 8;
|
||||
export const HOST_PASSWORD_MAX_LENGTH = 128;
|
||||
|
||||
export interface HostSignInCredentials {
|
||||
readonly email: string;
|
||||
@@ -11,12 +24,18 @@ export interface HostSignInCredentials {
|
||||
|
||||
export type HostAuthErrorCode =
|
||||
| "AUTH_CREDENTIALS_INVALID"
|
||||
| "AUTH_EMAIL_NOT_VERIFIED"
|
||||
| "AUTH_REQUEST_FAILED"
|
||||
| "AUTH_RESET_TOKEN_INVALID"
|
||||
| "AUTH_PASSWORD_INVALID"
|
||||
| "AUTH_SIGN_OUT_FAILED";
|
||||
|
||||
const errorMessages: Readonly<Record<HostAuthErrorCode, string>> = Object.freeze({
|
||||
AUTH_CREDENTIALS_INVALID: "Email or password is incorrect.",
|
||||
AUTH_EMAIL_NOT_VERIFIED: "Verify your email address before signing in.",
|
||||
AUTH_PASSWORD_INVALID: `Choose a password between ${HOST_PASSWORD_MIN_LENGTH} and ${HOST_PASSWORD_MAX_LENGTH} characters.`,
|
||||
AUTH_REQUEST_FAILED: "Unable to sign in right now.",
|
||||
AUTH_RESET_TOKEN_INVALID: "This password reset link is no longer valid. Request a new one.",
|
||||
AUTH_SIGN_OUT_FAILED: "Unable to sign out right now.",
|
||||
});
|
||||
|
||||
@@ -64,10 +83,33 @@ async function postJson(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the identity system's own error code out of a failed response.
|
||||
*
|
||||
* Only the `code` field is ever taken, and only to choose between this module's own fixed messages —
|
||||
* the response body itself is never surfaced, so a future change to the identity system's prose can
|
||||
* never leak into the interface. A body that is missing, malformed, or not JSON simply yields
|
||||
* `undefined` and the caller falls back to its generic failure.
|
||||
*/
|
||||
async function failureCode(response: Response): Promise<string | undefined> {
|
||||
try {
|
||||
const body = (await response.json()) as { readonly code?: unknown } | null;
|
||||
return typeof body?.code === "string" ? body.code : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses Ekklesia's existing Better Auth transport without exposing Better Auth to browser packages.
|
||||
* Success means only that the mutation succeeded; the shell must resolve `/api/v1/principal` before
|
||||
* it renders authenticated content rather than trusting a response token or user object.
|
||||
*
|
||||
* A correct password on an unverified address is **not** a success and mints no session — the
|
||||
* identity system refuses it before creating one (ADR 0038). It is reported as its own error so the
|
||||
* shell can offer verification rather than claiming the password was wrong, which would be both
|
||||
* untrue and unactionable. Reaching that state already required the correct password, so it reveals
|
||||
* nothing an attacker did not supply.
|
||||
*/
|
||||
export async function signInWithEmail(
|
||||
config: EkklesiaHostConfig,
|
||||
@@ -75,6 +117,9 @@ export async function signInWithEmail(
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const response = await postJson(config, SIGN_IN_PATH, credentials, "AUTH_REQUEST_FAILED", signal);
|
||||
if (response.status === 403 && (await failureCode(response)) === "EMAIL_NOT_VERIFIED") {
|
||||
throw new HostAuthError("AUTH_EMAIL_NOT_VERIFIED");
|
||||
}
|
||||
if (response.status === 400 || response.status === 401) {
|
||||
throw new HostAuthError("AUTH_CREDENTIALS_INVALID");
|
||||
}
|
||||
@@ -83,6 +128,90 @@ export async function signInWithEmail(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks for a password-reset message.
|
||||
*
|
||||
* Resolves for **every** address the identity system accepts as syntactically valid, whether or not
|
||||
* an account exists, whether or not that account is verified, and whether or not the message could
|
||||
* actually be delivered. That is the whole point: the caller has no way to tell the cases apart, so
|
||||
* neither does anyone using the form. The identity system answers the same way in each case, and a
|
||||
* delivery failure is reported to the operator out of band instead (ADR 0038).
|
||||
*
|
||||
* A transport failure or a rate-limit refusal is still surfaced — those describe this request, not
|
||||
* the existence of an account.
|
||||
*/
|
||||
export async function requestPasswordReset(
|
||||
config: EkklesiaHostConfig,
|
||||
email: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const response = await postJson(
|
||||
config,
|
||||
REQUEST_PASSWORD_RESET_PATH,
|
||||
{ email },
|
||||
"AUTH_REQUEST_FAILED",
|
||||
signal,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new HostAuthError("AUTH_REQUEST_FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes a password reset with the token from an emailed link.
|
||||
*
|
||||
* The token travels in the request body, never in a URL this function builds, and is not retained
|
||||
* afterwards. The new password is likewise only ever a body field.
|
||||
*/
|
||||
export async function resetPassword(
|
||||
config: EkklesiaHostConfig,
|
||||
input: { readonly token: string; readonly newPassword: string },
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const response = await postJson(
|
||||
config,
|
||||
RESET_PASSWORD_PATH,
|
||||
{ newPassword: input.newPassword, token: input.token },
|
||||
"AUTH_REQUEST_FAILED",
|
||||
signal,
|
||||
);
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
const code = await failureCode(response);
|
||||
if (code === "PASSWORD_TOO_SHORT" || code === "PASSWORD_TOO_LONG") {
|
||||
throw new HostAuthError("AUTH_PASSWORD_INVALID");
|
||||
}
|
||||
if (code === "INVALID_TOKEN") {
|
||||
throw new HostAuthError("AUTH_RESET_TOKEN_INVALID");
|
||||
}
|
||||
throw new HostAuthError("AUTH_REQUEST_FAILED");
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks for a fresh verification message.
|
||||
*
|
||||
* Enumeration-safe in exactly the same way as {@link requestPasswordReset}: the identity system
|
||||
* answers identically for an unknown address, an already-verified one, and an unverified one, and
|
||||
* this function does not distinguish them either.
|
||||
*/
|
||||
export async function resendVerificationEmail(
|
||||
config: EkklesiaHostConfig,
|
||||
email: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const response = await postJson(
|
||||
config,
|
||||
SEND_VERIFICATION_EMAIL_PATH,
|
||||
{ email },
|
||||
"AUTH_REQUEST_FAILED",
|
||||
signal,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new HostAuthError("AUTH_REQUEST_FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
/** Invalidates the server session. Hiding the shell without this successful mutation is not logout. */
|
||||
export async function signOutHost(config: EkklesiaHostConfig, signal?: AbortSignal): Promise<void> {
|
||||
const response = await postJson(config, SIGN_OUT_PATH, {}, "AUTH_SIGN_OUT_FAILED", signal);
|
||||
@@ -90,3 +219,61 @@ export async function signOutHost(config: EkklesiaHostConfig, signal?: AbortSign
|
||||
throw new HostAuthError("AUTH_SIGN_OUT_FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The two query values Ekklesia's own emailed links carry back to the Dashboard.
|
||||
*
|
||||
* Kept in step with `core/src/identity/notifications.ts`, which builds the links. They are query
|
||||
* parameters on the Dashboard root rather than distinct paths because the shell is a static bundle:
|
||||
* a deep path would oblige every deployment's static host to rewrite unknown paths to `index.html`
|
||||
* before an emailed link resolved at all.
|
||||
*/
|
||||
const AUTH_LANDING_PARAMETER = "ekklesia_auth";
|
||||
const VERIFY_EMAIL_LANDING = "verify-email";
|
||||
const RESET_PASSWORD_LANDING = "reset-password";
|
||||
|
||||
/** What an emailed link asked this browser to show. */
|
||||
export type HostAuthLanding =
|
||||
| { readonly kind: "verified" }
|
||||
| { readonly kind: "verification-failed"; readonly expired: boolean }
|
||||
| { readonly kind: "reset-password"; readonly token: string }
|
||||
| { readonly kind: "reset-link-invalid" };
|
||||
|
||||
/** A reset token is opaque to this shell, but it still has to end up in a URL and a request body, so
|
||||
* anything that is not a plain bounded token is treated as no token at all. */
|
||||
const RESET_TOKEN_PATTERN = /^[\w-]{1,256}$/u;
|
||||
|
||||
/**
|
||||
* Classifies the current location's query into a host-owned auth screen.
|
||||
*
|
||||
* A pure function of a query string: it reads no global, so the shell's one browser-global boundary
|
||||
* stays in `browser/main.tsx`. It recognizes **only** Ekklesia's own two landings and ignores
|
||||
* everything else, so an arbitrary query cannot steer the shell into an auth state — and it produces
|
||||
* no URL and no destination of any kind, so there is nothing here an open redirect could ride on.
|
||||
*/
|
||||
export function readAuthLanding(search: string): HostAuthLanding | undefined {
|
||||
let parameters: URLSearchParams;
|
||||
try {
|
||||
parameters = new URLSearchParams(search);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const landing = parameters.get(AUTH_LANDING_PARAMETER);
|
||||
const error = parameters.get("error");
|
||||
if (landing === VERIFY_EMAIL_LANDING) {
|
||||
if (error !== null) {
|
||||
// Better Auth distinguishes an expired token from a malformed or unknown one, and so does
|
||||
// this: "your link expired, here is how to get another" is actionable where "invalid" is not.
|
||||
return { expired: error === "TOKEN_EXPIRED", kind: "verification-failed" };
|
||||
}
|
||||
return { kind: "verified" };
|
||||
}
|
||||
if (landing === RESET_PASSWORD_LANDING) {
|
||||
const token = parameters.get("token");
|
||||
if (error !== null || token === null || !RESET_TOKEN_PATTERN.test(token)) {
|
||||
return { kind: "reset-link-invalid" };
|
||||
}
|
||||
return { kind: "reset-password", token };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,11 @@ import type { ModuleUiImporter } from "@ekklesia/module-ui-host";
|
||||
import { type ReactElement, useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
HostAuthError,
|
||||
type HostAuthLanding,
|
||||
type HostSignInCredentials,
|
||||
requestPasswordReset,
|
||||
resendVerificationEmail,
|
||||
resetPassword,
|
||||
signInWithEmail,
|
||||
signOutHost,
|
||||
} from "../auth-protocol.js";
|
||||
@@ -15,8 +19,11 @@ import {
|
||||
loadHostPrincipal,
|
||||
} from "../host-protocol.js";
|
||||
import { DevicePanel } from "./DevicePanel.js";
|
||||
import { ForgotPasswordForm } from "./ForgotPasswordForm.js";
|
||||
import { LoginForm } from "./LoginForm.js";
|
||||
import { ModuleUiFrame } from "./ModuleUiFrame.js";
|
||||
import { ResetPasswordForm } from "./ResetPasswordForm.js";
|
||||
import { ResetLinkInvalid, VerificationNotice, VerificationResult } from "./VerificationScreens.js";
|
||||
|
||||
type AuthenticatedPrincipal = Extract<
|
||||
HostBootstrap["principal"],
|
||||
@@ -37,11 +44,61 @@ type ResolvedBootstrapState = Extract<
|
||||
{ readonly status: "ready" | "anonymous" | "access-denied" | "failed" }
|
||||
>;
|
||||
|
||||
/**
|
||||
* Which host-owned screen an unauthenticated visitor sees.
|
||||
*
|
||||
* A small explicit union rather than a client-side router: these four screens are the entirety of
|
||||
* the platform's anonymous surface, and a routing library would be a dependency and an extra URL
|
||||
* authority for a state machine that fits on a page. Only `reset-password` carries anything, and
|
||||
* what it carries came from an emailed link the shell has already stripped from the address bar.
|
||||
*
|
||||
* None of these states is authenticated, and none can become authenticated: the shell renders the
|
||||
* Dashboard only after `/api/v1/principal` says so, exactly as before.
|
||||
*/
|
||||
type AuthView =
|
||||
| { readonly kind: "sign-in" }
|
||||
| { readonly kind: "forgot-password" }
|
||||
| { readonly kind: "verification-required"; readonly email: string }
|
||||
| { readonly kind: "verification-result"; readonly outcome: VerificationOutcome }
|
||||
| { readonly kind: "reset-password"; readonly token: string }
|
||||
| { readonly kind: "reset-link-invalid" };
|
||||
|
||||
type VerificationOutcome =
|
||||
| { readonly kind: "verified" }
|
||||
| { readonly kind: "failed"; readonly expired: boolean };
|
||||
|
||||
function authViewFromLanding(landing: HostAuthLanding | undefined): AuthView {
|
||||
if (!landing) {
|
||||
return { kind: "sign-in" };
|
||||
}
|
||||
if (landing.kind === "verified") {
|
||||
return { kind: "verification-result", outcome: { kind: "verified" } };
|
||||
}
|
||||
if (landing.kind === "verification-failed") {
|
||||
return {
|
||||
kind: "verification-result",
|
||||
outcome: { expired: landing.expired, kind: "failed" },
|
||||
};
|
||||
}
|
||||
if (landing.kind === "reset-password") {
|
||||
return { kind: "reset-password", token: landing.token };
|
||||
}
|
||||
return { kind: "reset-link-invalid" };
|
||||
}
|
||||
|
||||
export interface HostAppProps {
|
||||
/** Must be a stable reference: bootstrap re-runs whenever it changes, so a fresh object literal
|
||||
* built during render would re-fetch forever. The browser entry point builds it once at module
|
||||
* scope. */
|
||||
readonly config: EkklesiaHostConfig;
|
||||
/**
|
||||
* What an emailed verification or reset link asked this browser to show, already parsed.
|
||||
*
|
||||
* Supplied by the entry point rather than read here, so this component still touches no browser
|
||||
* global. It only ever selects among the host's own anonymous screens: it can name no destination,
|
||||
* so there is nothing an open redirect could use.
|
||||
*/
|
||||
readonly authLanding?: HostAuthLanding;
|
||||
/** Test-only seam, forwarded to {@link ModuleUiFrame}. Production composition leaves it unset. */
|
||||
readonly importModule?: ModuleUiImporter;
|
||||
}
|
||||
@@ -201,6 +258,10 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
const generationRef = useRef(0);
|
||||
const mountedRef = useRef(true);
|
||||
const { config, importModule } = props;
|
||||
// Initialized once from the landing the entry point parsed. A later sign-out or a "back to sign
|
||||
// in" action moves it back to the form; nothing re-reads the URL, so a stale emailed parameter
|
||||
// cannot resurrect a screen the person has left.
|
||||
const [authView, setAuthView] = useState<AuthView>(() => authViewFromLanding(props.authLanding));
|
||||
|
||||
const beginTransition = useCallback(() => {
|
||||
activeTransitionRef.current?.abort();
|
||||
@@ -306,6 +367,10 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
if (!isCurrent(controller, generation)) {
|
||||
return;
|
||||
}
|
||||
// Returning to the anonymous shell always returns to the sign-in form. Without this, signing
|
||||
// out while a verification or reset screen was the last anonymous view would show that screen
|
||||
// again, describing an emailed link the person has long since finished with.
|
||||
setAuthView({ kind: "sign-in" });
|
||||
setState(
|
||||
principal.kind === "anonymous" ? { status: "anonymous" } : { status: "sign-out-failed" },
|
||||
);
|
||||
@@ -349,6 +414,7 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
transition.controller.abort();
|
||||
activeTransitionRef.current = undefined;
|
||||
setSelectedKey(undefined);
|
||||
setAuthView({ kind: "sign-in" });
|
||||
setState({ status: "anonymous" });
|
||||
}
|
||||
})
|
||||
@@ -382,7 +448,46 @@ export function HostApp(props: HostAppProps): ReactElement {
|
||||
return <HostMessage testId="host-loading">Loading Ekklesia…</HostMessage>;
|
||||
}
|
||||
if (state.status === "anonymous") {
|
||||
return <LoginForm onAuthenticate={authenticate} />;
|
||||
const returnToSignIn = (): void => setAuthView({ kind: "sign-in" });
|
||||
if (authView.kind === "forgot-password") {
|
||||
return (
|
||||
<ForgotPasswordForm
|
||||
onRequestReset={(email, signal) => requestPasswordReset(config, email, signal)}
|
||||
onReturnToSignIn={returnToSignIn}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (authView.kind === "verification-required") {
|
||||
return (
|
||||
<VerificationNotice
|
||||
email={authView.email}
|
||||
onResend={(email, signal) => resendVerificationEmail(config, email, signal)}
|
||||
onReturnToSignIn={returnToSignIn}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (authView.kind === "verification-result") {
|
||||
return <VerificationResult onReturnToSignIn={returnToSignIn} outcome={authView.outcome} />;
|
||||
}
|
||||
if (authView.kind === "reset-password") {
|
||||
return (
|
||||
<ResetPasswordForm
|
||||
onResetPassword={(input, signal) => resetPassword(config, input, signal)}
|
||||
onReturnToSignIn={returnToSignIn}
|
||||
token={authView.token}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (authView.kind === "reset-link-invalid") {
|
||||
return <ResetLinkInvalid onReturnToSignIn={returnToSignIn} />;
|
||||
}
|
||||
return (
|
||||
<LoginForm
|
||||
onAuthenticate={authenticate}
|
||||
onEmailNotVerified={(email) => setAuthView({ email, kind: "verification-required" })}
|
||||
onForgotPassword={() => setAuthView({ kind: "forgot-password" })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (state.status === "failed") {
|
||||
return (
|
||||
|
||||
@@ -7,61 +7,23 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { HostAuthError, type HostSignInCredentials } from "../auth-protocol.js";
|
||||
import { AuthCard, authStyles } from "./AuthCard.js";
|
||||
|
||||
export interface LoginFormProps {
|
||||
readonly onAuthenticate: (
|
||||
credentials: HostSignInCredentials,
|
||||
signal: AbortSignal,
|
||||
) => 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. */
|
||||
export function LoginForm(props: LoginFormProps): ReactElement {
|
||||
const [email, setEmail] = useState("");
|
||||
@@ -108,11 +70,18 @@ export function LoginForm(props: LoginFormProps): ReactElement {
|
||||
setPassword("");
|
||||
await authentication;
|
||||
} 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(
|
||||
error instanceof HostAuthError ? error.message : "Unable to sign in right now.",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (attemptRef.current === attempt) {
|
||||
attemptRef.current = undefined;
|
||||
@@ -142,20 +111,11 @@ export function LoginForm(props: LoginFormProps): ReactElement {
|
||||
};
|
||||
|
||||
return (
|
||||
<main data-testid="host-login" style={loginStyles.host}>
|
||||
<section aria-labelledby="ekklesia-login-heading" style={loginStyles.card}>
|
||||
<h1 id="ekklesia-login-heading" style={loginStyles.heading}>
|
||||
Sign in to Ekklesia
|
||||
</h1>
|
||||
<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>
|
||||
<AuthCard heading="Sign in to Ekklesia" headingId="ekklesia-login-heading" testId="host-login">
|
||||
<p style={authStyles.introduction}>Use your existing Ekklesia account.</p>
|
||||
<form aria-busy={pending} onKeyDown={submitOnEnter} onSubmit={submit} style={authStyles.form}>
|
||||
<label style={authStyles.field}>
|
||||
<span style={authStyles.label}>Email</span>
|
||||
<input
|
||||
autoComplete="username"
|
||||
data-testid="login-email"
|
||||
@@ -164,13 +124,13 @@ export function LoginForm(props: LoginFormProps): ReactElement {
|
||||
name="email"
|
||||
onChange={(event) => setEmail(event.currentTarget.value)}
|
||||
required
|
||||
style={loginStyles.input}
|
||||
style={authStyles.input}
|
||||
type="email"
|
||||
value={email}
|
||||
/>
|
||||
</label>
|
||||
<label style={loginStyles.field}>
|
||||
<span style={loginStyles.label}>Password</span>
|
||||
<label style={authStyles.field}>
|
||||
<span style={authStyles.label}>Password</span>
|
||||
<input
|
||||
aria-describedby={errorMessage ? "ekklesia-login-error" : undefined}
|
||||
autoComplete="current-password"
|
||||
@@ -180,21 +140,29 @@ export function LoginForm(props: LoginFormProps): ReactElement {
|
||||
onChange={(event) => setPassword(event.currentTarget.value)}
|
||||
ref={passwordRef}
|
||||
required
|
||||
style={loginStyles.input}
|
||||
style={authStyles.input}
|
||||
type="password"
|
||||
value={password}
|
||||
/>
|
||||
</label>
|
||||
{errorMessage ? (
|
||||
<p id="ekklesia-login-error" role="alert" style={loginStyles.error}>
|
||||
<p id="ekklesia-login-error" role="alert" style={authStyles.error}>
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : undefined}
|
||||
<button disabled={pending} style={loginStyles.button} type="submit">
|
||||
<button disabled={pending} style={authStyles.button} type="submit">
|
||||
{pending ? "Signing in…" : "Sign in"}
|
||||
</button>
|
||||
<button
|
||||
data-testid="login-forgot-password"
|
||||
disabled={pending}
|
||||
onClick={props.onForgotPassword}
|
||||
style={authStyles.secondaryAction}
|
||||
type="button"
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</AuthCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { type HostAuthLanding, readAuthLanding } from "../auth-protocol.js";
|
||||
import type { EkklesiaHostConfig } from "../host-protocol.js";
|
||||
import { HostApp } from "./HostApp.js";
|
||||
|
||||
@@ -23,9 +24,42 @@ const config: EkklesiaHostConfig = {
|
||||
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");
|
||||
if (!container) {
|
||||
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} />,
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -295,7 +295,7 @@ describe("Ekklesia shell — browser authentication", () => {
|
||||
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();
|
||||
await render({ apiBaseUrl, fetch: platform.fetch });
|
||||
|
||||
@@ -312,8 +312,15 @@ describe("Ekklesia shell — browser authentication", () => {
|
||||
expect(password.labels?.[0]?.textContent).toContain("Password");
|
||||
expect(submit.textContent).toBe("Sign in");
|
||||
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("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 () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ export default defineConfig({
|
||||
"apps/api/tests/api-http.integration.ts",
|
||||
"apps/api/tests/initial-owner.integration.ts",
|
||||
"apps/api/tests/device-pairing.integration.ts",
|
||||
"apps/api/tests/email-verification.integration.ts",
|
||||
"apps/api/tests/module-database.integration.ts",
|
||||
],
|
||||
passWithNoTests: false,
|
||||
|
||||
Reference in New Issue
Block a user