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