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