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
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user