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