From f2e3faa2c8487184480ede692b63486ab04bff87 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Fri, 10 Jul 2026 10:02:41 -0400 Subject: [PATCH] fix(magic-link): build redirect from a dedicated frontend_url config Magic links were built from origins[0], so the emailed link pointed at whichever origin happened to be first, which could be a non-frontend host (for example an API origin). Add an optional frontend_url system config (env FRONTEND_URL) and use it as the base for the redirect, falling back to origins[0] when unset. --- .changeset/magic-link-frontend-url.md | 9 +++++++ src/config/systemConfig.envMap.ts | 1 + src/controllers/magicLinks.ts | 3 ++- src/schemas/systemConfig.schema.ts | 4 ++++ tests/integration/magicLink/magicLink.spec.ts | 24 +++++++++++++++++++ 5 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 .changeset/magic-link-frontend-url.md diff --git a/.changeset/magic-link-frontend-url.md b/.changeset/magic-link-frontend-url.md new file mode 100644 index 0000000..ac37bec --- /dev/null +++ b/.changeset/magic-link-frontend-url.md @@ -0,0 +1,9 @@ +--- +'seamless-auth-api': patch +--- + +Build the magic-link redirect from a dedicated frontend URL. Add an optional +`frontend_url` system config (env `FRONTEND_URL`) and use it as the base for +emailed magic links, falling back to `origins[0]` when unset. Previously the +link was always built from the first configured origin, which could point at a +non-frontend origin (for example an API host) depending on origin ordering. diff --git a/src/config/systemConfig.envMap.ts b/src/config/systemConfig.envMap.ts index 10d6b73..9b5f687 100644 --- a/src/config/systemConfig.envMap.ts +++ b/src/config/systemConfig.envMap.ts @@ -17,5 +17,6 @@ export const SYSTEM_CONFIG_ENV_MAP = { delay_after: 'DELAY_AFTER', rpid: 'RPID', origins: 'ORIGINS', + frontend_url: 'FRONTEND_URL', app_name: 'APP_NAME', } as const; diff --git a/src/controllers/magicLinks.ts b/src/controllers/magicLinks.ts index 7b5ed6a..4126fe1 100644 --- a/src/controllers/magicLinks.ts +++ b/src/controllers/magicLinks.ts @@ -73,7 +73,8 @@ export async function requestMagicLink(req: Request, res: Response) { const tokenHash = hashSha256(rawToken); const config = await getSystemConfig(); - const redirect_url = `${config.origins[0]}/verify-magiclink?token=${rawToken}`; + const frontendUrl = config.frontend_url ?? config.origins[0]; + const redirect_url = `${frontendUrl}/verify-magiclink?token=${rawToken}`; const { ip_hash, user_agent_hash } = hashDeviceFingerprint(req.ip, req.headers['user-agent']); diff --git a/src/schemas/systemConfig.schema.ts b/src/schemas/systemConfig.schema.ts index a1baaa7..2fcfe33 100644 --- a/src/schemas/systemConfig.schema.ts +++ b/src/schemas/systemConfig.schema.ts @@ -77,6 +77,10 @@ export const SystemConfigSchema = z.object({ rpid: z.string().min(1), origins: z.array(z.url()).min(1), + + // Base URL of the user-facing frontend, used to build links emailed to users + // (e.g. magic links). Falls back to origins[0] when unset. + frontend_url: z.url().optional(), }); export type SystemConfig = z.infer; diff --git a/tests/integration/magicLink/magicLink.spec.ts b/tests/integration/magicLink/magicLink.spec.ts index e8def22..bbc1317 100644 --- a/tests/integration/magicLink/magicLink.spec.ts +++ b/tests/integration/magicLink/magicLink.spec.ts @@ -99,6 +99,30 @@ describe('GET /magic-link', () => { expect(res.body.delivery.magicLinkUrl).toContain(res.body.delivery.token); }); + it('builds the magic link from frontend_url when set, regardless of origins order', async () => { + (getSystemConfig as any).mockResolvedValue({ + available_roles: ['user', 'admin'], + default_roles: ['user'], + access_token_ttl: '15m', + refresh_token_ttl: '1h', + origins: ['http://localhost:3000', 'http://localhost:5001'], + frontend_url: 'http://localhost:5001', + login_methods: ['passkey', 'magic_link'], + passkey_login_fallback_enabled: true, + }); + (MagicLinkToken.update as any).mockResolvedValue([1]); + (MagicLinkToken.create as any).mockResolvedValue({ id: 'link-1' }); + + const res = await request(app) + .get('/magic-link') + .set('x-seamless-auth-delivery-mode', 'external'); + + expect(res.status).toBe(200); + expect(res.body.delivery.magicLinkUrl).toContain( + 'http://localhost:5001/verify-magiclink?token=', + ); + }); + it('returns an error when direct magic-link delivery fails', async () => { (MagicLinkToken.update as any).mockResolvedValue([1]); (MagicLinkToken.create as any).mockResolvedValue({ id: 'link-1' });