Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,45 @@ describe('email verification', () => {
expect((await authedApi(cookieOf(res)).projects.get()).status).toBe(200);
});
});

describe('magic link without a mail provider', () => {
beforeEach(resetDb);

it('refuses to turn the magic link on before a provider is configured', async () => {
const { god } = await setup();

const res = await god.api.god['auth-settings'].put({ magicLink: true });

expect(res.status).toBe(400);
expect(res.error!.value).toMatchObject({ error: 'Configure an email provider first' });
});

it('drops the magic link when the mail provider is removed', async () => {
const { god } = await setup();
await god.api.god['email-settings'].put(smtpProvider);
expect((await god.api.god['auth-settings'].put({ magicLink: true })).status).toBe(200);

const removed = await god.api.god['email-settings'].put({
...smtpProvider,
smtp: { ...smtpProvider.smtp, enabled: false },
});

expect(removed.status).toBe(200);
const stored = (await god.api.god['auth-settings'].get()).data!;
expect(stored).toMatchObject({ magicLink: false });
expect((await authRequest('/sign-in/magic-link', { email: 'root@example.com' })).status).toBe(
403,
);
// The god form sends every setting back on save.
const saved = await god.api.god['auth-settings'].put({
registration: 'closed',
requireEmailVerification: stored.requireEmailVerification,
magicLink: stored.magicLink,
emailPassword: stored.emailPassword,
trustProviderEmails: stored.trustProviderEmails,
inviteTtlDays: stored.inviteTtlDays,
allowedEmailDomains: stored.allowedEmailDomains,
});
expect(saved.status).toBe(200);
});
});
3 changes: 2 additions & 1 deletion apps/web/src/features/god/services/god.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ export function useUpdateInstanceEmailSettings() {
mutationFn: (patch: InstanceEmailSettingsPatch) => updateInstanceEmailSettings(patch),
onSuccess: (data) => {
qc.setQueryData(qk.instanceEmailSettings, data);
// Turning a provider on unlocks the auth options that need outbound mail.
// Turning a provider on unlocks the auth options that need outbound mail, and
// turning it off clears them.
void qc.invalidateQueries({ queryKey: qk.instanceAuthSettings });
},
});
Expand Down
3 changes: 2 additions & 1 deletion packages/auth/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ and holds back `/sign-in/email` for an unconfirmed address while verification is
confirmation link again; a deactivated account and a disabled password form are 403s too. The requirement never outlives the mail provider: the api
refuses to turn it on without one, and `setEmailSettings` clears it when the provider
is removed, so the setting alone is what the gate, the sign-up endpoint and the
public `/auth-config` read — none of them checks the provider again.
public `/auth-config` read — none of them checks the provider again. The sign-in links
(`magicLink`) follow the same rule, so the magic-link sender reads only the setting.

`emailAndPassword.requireEmailVerification` is a getter over a module variable that
`hooks.before` refreshes from the setting on every password endpoint. better-auth
Expand Down
15 changes: 10 additions & 5 deletions packages/auth/src/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ export interface AuthSettings {
// mail provider: the api rejects turning it on while none is configured, and
// setEmailSettings clears it when the provider is removed.
requireEmailVerification: boolean;
// Offer sign-in by emailed link alongside the password.
// Offer sign-in by emailed link alongside the password. Needs a mail provider, under
// the same rule as requireEmailVerification.
magicLink: boolean;
// Offer the email/password form at all. Turning it off leaves single sign-on (and
// passkeys, which are added to an account that already exists) as the way in, so
Expand Down Expand Up @@ -272,10 +273,14 @@ export async function setEmailSettings(patch: InstanceEmailPatch): Promise<Insta
const next = await resolveEmailConfig(patch);
const redacted = toEmailDto(next);
await writeSecret(INSTANCE_EMAIL_SECRET_KEY, next, redacted);
// Without a provider no confirmation link can be sent, so the requirement goes
// with it rather than locking every new account out.
if (!hasEmailProvider(next) && (await getAuthSettings()).requireEmailVerification) {
await setAuthSettings({ requireEmailVerification: false });
// Without a provider no confirmation link or sign-in link can be sent, so both
// settings go with it rather than locking every new account out or answering a
// magic-link request with a mail that never leaves.
if (!hasEmailProvider(next)) {
const settings = await getAuthSettings();
if (settings.requireEmailVerification || settings.magicLink) {
await setAuthSettings({ requireEmailVerification: false, magicLink: false });
}
}
return redacted;
}
Expand Down
Loading