feat(magic-link): let a request choose where the link lands - #240
Merged
Conversation
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.
This was referenced Aug 31, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #141, taking the first of the two options in the issue: a per-request redirect target validated against an allowlist, following the
resolveOAuthRedirectUriprecedent the issue points at.What changed
GET /magic-linktakes an optionalredirectUriquery parameter. It is a GET with no body, so the query is where this belongs.frontend_urlfalling back to the first origin. Every existing caller is unaffected.tokenquery parameter.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,sameOriginandallowedRedirectinline inoauthService.ts. Rather than copy them, they move tosrc/lib/redirectAllowlist.tsand 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 addingmagic_link_redirect_urismeans adding a key toSystemConfigSchema, which lives in@seamless-auth/typesand 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 indocs/api-contract.mdand 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:
requestMagicLinkHandlercalls${authServerUrl}/magic-linkwith no query. It needs to acceptredirectUrion its input and append it. Until then only direct API and backend callers can use this.createSeamlessAuthClientposts/magic-linkto the adapter with an empty body; it would need to pass the target through.src/schemas/magiclink.requests.tswith a note to move it once a client adopts the field.Worth recording from the survey: the React SDK issues
POST /magic-linkwhile this API registersGET. 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.jsonandsrc/generated/api.tsregenerated; the diff is purely the new optional parameter and the 400. Full suite 1207 passed, 1 skipped; typecheck, lint and format clean.