feat: verify email addresses and recover forgotten passwords
Ekklesia could authenticate a person and, since Pass 1, send a transactional email — but it had no way to establish that an address belonged to whoever claimed it, and no way back in for someone who forgot their password. A verified address is now a precondition for holding a session. Better Auth enforces that after the password check and before any session exists, so an unverified sign-in returns 403 with no cookie. That is why no "unverified principal" was needed: a correct password on an unverified address carries exactly the authority of no password, and /api/v1/principal is unchanged. Better Auth keeps every token. Ekklesia adds no token table, no hashing, and no expiry clock. It supplies a recipient, a subject, and two bodies, and the mail provider supplies everything about SMTP. Verification and reset links are rebuilt from a validated Dashboard origin, taking only the token — never the URL Better Auth prebuilds from a caller-supplied callbackURL — so no request can steer where a recipient lands. Following a verification link mints no session, because mail infrastructure dereferences links. Reset revokes that person's other sessions and leaves managed devices alone. Recovery answers identically for an address with an account, without one, already verified, or undeliverable — including when SMTP is broken, which is why the delivery adapter never throws. The initial platform owner is the one exception: created already verified by an operator command with no mail prerequisite, so a fresh server can be signed in to before a relay exists. Migration 0007 extends that to owners of installations that already exist. Public signup stays disabled; verification is not a licence to self-register. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3581ad7c8e
commit
ea28f8aa2e
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user