Skip to content

Commit d609f60

Browse files
fix(plugin-auth): the Magic Link mail states the lifetime the link really has, five minutes (#25238)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuACdPWFJTLDcTrByFCHLzqb)_ Fixes #25112 Clause-②: no Metadata-protocol: none ## What changed The `auth.magic_link` mail said "This link expires in 10 minutes" while the link it carries lived 300 s: `magicLink()` was mounted without `expiresIn` (so better-auth's own default applied) and `sendMagicLink` passed a separate hard-coded `expiresInMinutes: 10`. - `packages/plugins/plugin-auth/src/auth-manager.ts`: one local constant, `magicLinkExpiresInSec = 5 * 60`, beside the mount. The mount now passes `expiresIn: magicLinkExpiresInSec`, and the mail's `expiresInMinutes` is `Math.round(magicLinkExpiresInSec / 60)`. The copy and the link read one source. - The link's **lifetime does not change**. It was 300 s, measured in the installed better-auth 1.7.12 (`dist/plugins/magic-link/index.mjs`, `expiresAt: new Date(Date.now() + (opts.expiresIn || 300) * 1e3)`), and it stays 300 s, now stated rather than inherited, so a dependency bump cannot move it under the mail. - The seat's ruling on the card's open value (claim comment 6341542393), executed as written: keep the measured 300 s, state it from one constant, derive the mail from it. Lengthening the lifetime would relax a sign-in credential and stays with the maintainer. ## Where the fix lives, and why not in plugin-email All four locale rows of `auth.magic_link` in `packages/plugins/plugin-email/src/templates/auth-templates.ts` (en-US, zh-CN, ja-JP, es-ES), HTML and text faces, render the lifetime only from `{{expiresInMinutes}}`. None hard-codes a number. The false value was produced entirely by the producer in plugin-auth, so plugin-email does not move and takes no changeset. ## Tests - `auth-email-locale.test.ts`: the existing payload pin asserted the false `10`; it now asserts `5`. A new pin asserts the mount states `expiresIn: 300` and that the mail's `expiresInMinutes * 60` equals the mount's `expiresIn`. - Ablation, on head `145974256` with the fix committed first, through `scripts/ablation-replace.mjs` (anchor hit 1, then 0; blob restored to the HEAD blob `c3dd34d7bf34` both times): 1. `expiresInMinutes` set back to the literal `10`: 2 failed / 67 passed (`expected 10 to be 5`, `expected 600 to be 300`). 2. `expiresIn` deleted from the mount: 1 failed / 68 passed (`expected undefined to be 300`). - `pnpm --filter @objectstack/plugin-auth build && typecheck && test`: 185 files passed, 3487 passed and 10 skipped. ## Gates `dispatch-gates --commands` derived 70 commands for this diff, and all 70 exited 0. Reconciliation: `70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN`. Two of them first answered PREREQUISITE NOT MET (exit 3): `check-plugin-teardown-shape --self-test` on a shallow clone, and `check:dual-build-cjs-loads` without a full build. Both were re-run after `git fetch --unshallow` and `pnpm build`, and both exited 0. ## Acceptance notes - `plugin-email`'s template tests feed `auth.magic_link` the sample value `expiresInMinutes: 10` (`plain-text-faces.test.ts`, `auth-templates-locales.test.ts`). It is only render data and makes no claim about the link's lifetime, so it is left as is. - `check:auth-mount-ledger` tracks route mounts, not plugin options, so it does not read this change. --- _Generated by [Claude Code](https://claude.ai/code/session_016g7nYP9wdNx1GDa5XDr7Uy)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016g7nYP9wdNx1GDa5XDr7Uy Co-authored-by: Claude <noreply@anthropic.com>
1 parent 80ec71d commit d609f60

3 files changed

Lines changed: 36 additions & 2 deletions

File tree

‎.changeset/25112-magic-link-ttl.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
fix(plugin-auth): the Magic Link mail states the lifetime the link really has, five minutes
6+
7+
Clause-②: no
8+
9+
Metadata-protocol: none
10+
11+
The `auth.magic_link` mail said "This link expires in 10 minutes", while the link it carried lived 300 seconds: `magicLink()` was mounted without `expiresIn`, so better-auth's own default applied, and the mail's `expiresInMinutes` was a separate hard-coded `10`. The mount now states `expiresIn: 300` explicitly, and the mail's `expiresInMinutes` is derived from that same value, so every locale of the mail now reads "5 minutes". The link's lifetime does not change. Nothing to do on upgrade; a customised `auth.magic_link` template that hard-codes its own number should read `expiresInMinutes` instead.

‎packages/plugins/plugin-auth/src/auth-email-locale.test.ts‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1052,7 +1052,19 @@ describe("#15106 — the magic link reads the recipient's own sys_user.locale",
10521052
expect(sent[0].to).toBe('ada@example.com');
10531053
expect(sent[0].data.magicLinkUrl).toBe('http://x/magic');
10541054
expect(sent[0].data.token).toBe('t');
1055-
expect(sent[0].data.expiresInMinutes).toBe(10);
1055+
expect(sent[0].data.expiresInMinutes).toBe(5);
1056+
});
1057+
1058+
it('the mail states the lifetime the mount gives the link, from one source', async () => {
1059+
// The copy reads "expires in N minutes"; the link lives the mount's
1060+
// `expiresIn` seconds. Both must be the same number, and the mount must
1061+
// state it rather than lean on the vendor default (300 s today), which a
1062+
// dependency bump could move under the mail.
1063+
const { capturedConfig } = await boot('es-ES');
1064+
const magic = capturedConfig.plugins.find((p: any) => p.id === 'magic-link');
1065+
expect(magic._opts.expiresIn).toBe(300);
1066+
const sent = await driveMagicLink({ deployment: 'es-ES' });
1067+
expect(sent[0].data.expiresInMinutes * 60).toBe(magic._opts.expiresIn);
10561068
});
10571069

10581070
it('a placeholder address is still refused BEFORE any recipient read', async () => {

‎packages/plugins/plugin-auth/src/auth-manager.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4216,7 +4216,18 @@ export class AuthManager {
42164216
await this.addOptionalPlugin(plugins, 'magicLink', async () => {
42174217
const { magicLink } = await import('better-auth/plugins/magic-link');
42184218
// magic-link reuses the `verification` table — no extra schema mapping needed.
4219+
//
4220+
// The link's lifetime, in seconds, and the ONE source for it: the mount
4221+
// states it and the mail's `expiresInMinutes` is derived from it, so the
4222+
// copy says what the link does. 300 s is the lifetime the link already
4223+
// had through the vendor default (`opts.expiresIn || 300`, better-auth
4224+
// 1.7.12 `plugins/magic-link/index.mjs`), now written down so a
4225+
// dependency bump cannot move it under the mail. ⛔ Lengthening it
4226+
// relaxes a sign-in credential's validity — a security-boundary change
4227+
// for the maintainer, not a copy fix.
4228+
const magicLinkExpiresInSec = 5 * 60;
42194229
return magicLink({
4230+
expiresIn: magicLinkExpiresInSec,
42204231
// #14319 — this one is called with the endpoint CTX, not a Request
42214232
// (measured; magic-link/index.mjs `sendMagicLink({...}, ctx)`).
42224233
sendMagicLink: async ({ email: recipientEmail, url, token }, ctx?: unknown) => {
@@ -4280,7 +4291,7 @@ export class AuthManager {
42804291
data: {
42814292
magicLinkUrl: url,
42824293
token,
4283-
expiresInMinutes: 10,
4294+
expiresInMinutes: Math.round(magicLinkExpiresInSec / 60),
42844295
appName: this.getAppName(),
42854296
},
42864297
// [ADR-0131 D7, Q2 A] A sign-in link for a person: identity mail.

0 commit comments

Comments
 (0)