Skip to content

feat(magic-link): let a request choose where the link lands - #240

Merged
Bccorb merged 1 commit into
mainfrom
feat/magic-link-redirect-target
Aug 31, 2026
Merged

feat(magic-link): let a request choose where the link lands#240
Bccorb merged 1 commit into
mainfrom
feat/magic-link-redirect-target

Conversation

@Bccorb

@Bccorb Bccorb commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Closes #141, taking the first of the two options in the issue: a per-request redirect target validated against an allowlist, following the resolveOAuthRedirectUri precedent the issue points at.

What changed

GET /magic-link takes an optional redirectUri query parameter. It is a GET with no body, so the query is where this belongs.

  • Omitted: destination is unchanged, frontend_url falling back to the first origin. Every existing caller is unaffected.
  • Supplied and allowed: the link goes there, with the token set as a token query parameter.
  • Supplied and not allowed: 400, and no email is sent.

The token is set, not appended. A caller that already put ?token= on the target would otherwise leave which one wins up to whoever parses it.

Shared redirect matching

OAuth had parseUrl, sameOrigin and allowedRedirect inline in oauthService.ts. Rather than copy them, they move to src/lib/redirectAllowlist.ts and both flows use them. An open redirect in an auth server hands an attacker a link on the tenant's own domain pointing wherever they like, so it is worth having one reviewable implementation rather than one per flow. All 67 existing OAuth tests pass unchanged against the extracted version.

The allowlist, and the limit of this PR

The allowlist is the configured WebAuthn origins, because there is no dedicated one and adding magic_link_redirect_uris means adding a key to SystemConfigSchema, which lives in @seamless-auth/types and needs a version bump plus a coordinated release across this API and both SDKs.

So this covers a tenant whose mobile destination is a path or host already among its origins, which is the common case, and does not cover a custom scheme (myapp://) or a universal link on a host that should not be a WebAuthn origin. That needs the shared-package change, and I have not made it unilaterally. Documented in docs/api-contract.md and called out in the changeset. Say the word and I will do it as a coordinated follow-up.

Contract change and blast radius

Additive, so nothing breaks, but the feature is not reachable from a browser or mobile client until the adapter forwards it:

  • seamless-auth-server: requestMagicLinkHandler calls ${authServerUrl}/magic-link with no query. It needs to accept redirectUri on its input and append it. Until then only direct API and backend callers can use this.
  • seamless-auth-react: createSeamlessAuthClient posts /magic-link to the adapter with an empty body; it would need to pass the target through.
  • @seamless-auth/types: no change needed for this PR. The request schema is deliberately kept local in src/schemas/magiclink.requests.ts with a note to move it once a client adopts the field.

Worth recording from the survey: the React SDK issues POST /magic-link while this API registers GET. That is not a bug, the adapter translates POST to GET, which is the documented topology.

Verification

10 new unit tests on the resolver, including near-miss hosts (localhost:5174.evil.example), a differing port, a non-URL, and the token-replacement case. 3 new integration tests on the route. openapi.json and src/generated/api.ts regenerated; the diff is purely the new optional parameter and the 400. Full suite 1207 passed, 1 skipped; typecheck, lint and format clean.

The link was built from one tenant-wide value, frontend_url falling back to the
first configured origin, so a tenant with both a web app and a mobile app could
not serve both. A link has to arrive in one or the other.

GET /magic-link now takes an optional redirectUri query parameter, validated
against the configured origins the same way resolveOAuthRedirectUri validates an
OAuth redirect. A value outside them answers 400. The token is set rather than
appended, so a caller that already put a token on the target does not get to
decide which one the client reads.

Additive: omit the parameter and the destination is what it was.

The redirect matching OAuth had inline moves to lib/redirectAllowlist.ts and is
now shared, so there is one place where an open redirect would have to get past
rather than one per flow.

The allowlist is the WebAuthn origins list because there is no dedicated one. A
custom scheme, or a universal link on a host that is not a WebAuthn origin,
needs a magic_link_redirect_uris key in @seamless-auth/types and a coordinated
release, so that is left as a follow-up.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Magic link redirect target is tenant wide, so web and mobile cannot both receive links

1 participant