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
29 changes: 29 additions & 0 deletions .changeset/tidy-falcons-arrive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
'seamless-auth-api': minor
---

Let a magic link request choose where the link lands.

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

`GET /magic-link` now takes an optional `redirectUri` query parameter. A supplied value
is validated against the configured `origins`, exactly the way `resolveOAuthRedirectUri`
already validates an OAuth redirect, and a value outside them answers `400`. The token is
set as a `token` query parameter on the target, replacing one of that name the caller had
already put there so the client is never handed two.

**Additive.** Omit the parameter and the destination is unchanged, so no existing caller
has to do anything.

The redirect matching that OAuth had inline is now `src/lib/redirectAllowlist.ts` and
shared by both flows, so an auth server has one place where "may we send someone here"
is decided rather than one per flow.

The allowlist is the WebAuthn `origins` list because there is no dedicated one. A
destination that cannot be expressed as one of those, a custom scheme like `myapp://` or
a universal link on a host that is not a WebAuthn origin, needs a
`magic_link_redirect_uris` system config key. That key would live in
`@seamless-auth/types` and needs a coordinated release, so it is deliberately left as a
follow-up rather than bundled here.
18 changes: 18 additions & 0 deletions docs/api-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,24 @@ The SDK adapters branch on these exact codes (for example, the magic-link poll t
"pending"). Changing a branch-significant status code is a contract change; see the ripple
protocol in [ecosystem.md](./ecosystem.md).

### Magic link destination

`GET /magic-link` accepts an optional `redirectUri` query parameter deciding where the emailed
link lands. Omit it and the link keeps the tenant-wide destination, `frontend_url` falling back
to the first configured origin, which is what every existing caller gets.

A supplied value is validated against the configured `origins` the same way
`resolveOAuthRedirectUri` validates an OAuth redirect, and a value outside them answers `400`.
That is what lets one tenant serve a web client and a mobile client without them sharing a
single destination. The token is set as a `token` query parameter on the target, replacing one
of that name the caller had already put there.

The allowlist is the WebAuthn `origins` list because there is no dedicated one yet. A
destination that cannot be expressed as one of those, a custom scheme such as `myapp://` or a
universal link on a host that is not a WebAuthn origin, needs a `magic_link_redirect_uris`
system config key. That key lives in `@seamless-auth/types` and so needs a version bump and a
coordinated release across this API and both SDKs.

### Error body

Every `4xx` and `5xx` response uses one shape, with one additive extension for schema
Expand Down
49 changes: 49 additions & 0 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -3837,6 +3837,14 @@
"summary": "Request a magic login link",
"tags": ["MagicLinks"],
"security": [{ "bearerAuth": [] }],
"parameters": [
{
"schema": { "type": "string", "format": "uri" },
"required": false,
"name": "redirectUri",
"in": "query"
}
],
"responses": {
"200": {
"description": "HTTP 200",
Expand Down Expand Up @@ -3886,6 +3894,47 @@
}
}
},
"400": {
"description": "HTTP 400",
"content": {
"application/json": {
"example": {
"error": "string",
"message": "string",
"details": { "issues": [null] }
},
"schema": {
"type": "object",
"properties": {
"error": { "type": "string" },
"message": { "type": "string" },
"details": {
"type": "object",
"properties": {
"issues": {
"type": "array",
"items": {
"type": "object",
"properties": {
"path": {
"type": "array",
"items": { "anyOf": [{ "type": "string" }, { "type": "number" }] }
},
"code": { "type": "string" },
"message": { "type": "string" }
},
"required": ["path", "code", "message"]
}
}
},
"required": ["issues"]
}
},
"required": ["error"]
}
}
}
},
"403": {
"description": "HTTP 403",
"content": {
Expand Down
14 changes: 7 additions & 7 deletions resources/coverage-badge.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
33 changes: 28 additions & 5 deletions src/controllers/magicLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,18 @@
import crypto from 'crypto';
import { Request, Response } from 'express';
import { Op } from 'sequelize';
import { z } from 'zod';

import { getSystemConfig } from '../config/getSystemConfig.js';
import { canReturnExternalDelivery } from '../lib/externalDelivery.js';
import { MagicLinkToken } from '../models/magicLinks.js';
import { User } from '../models/users.js';
import { MagicLinkRequestQuerySchema } from '../schemas/magiclink.requests.js';
import { AuthEventService } from '../services/authEventService.js';
import { getLoginPolicy, isLoginMethodEnabled } from '../services/loginPolicyService.js';
import {
MagicLinkRedirectNotAllowedError,
resolveMagicLinkUrl,
} from '../services/magicLinkRedirect.js';
import { sendMagicLinkEmail } from '../services/messagingService.js';
import { issueSessionAndRespond } from '../services/sessionIssuance.js';
import { invalidateChallengesForUser } from '../services/webauthnChallengeService.js';
Expand Down Expand Up @@ -52,7 +57,16 @@ async function logMagicLinkFailure(req: Request, reason: string, userId?: string
});
}

export async function requestMagicLink(req: Request, res: Response) {
// The query is Zod-validated by defineRoute before this runs, so redirectUri is a string
// or absent rather than Express 5's wider parsed-query type.
type MagicLinkRequest = Request<
Record<string, string>,
unknown,
unknown,
z.infer<typeof MagicLinkRequestQuerySchema>
>;

export async function requestMagicLink(req: MagicLinkRequest, res: Response) {
const authReq = req as AuthenticatedRequest;
const preAuthUser = authReq.user;
const useExternalDelivery = await canReturnExternalDelivery(req);
Expand All @@ -72,9 +86,18 @@ export async function requestMagicLink(req: Request, res: Response) {
const rawToken = crypto.randomBytes(32).toString('base64url');
const tokenHash = hashSha256(rawToken);

const config = await getSystemConfig();
const frontendUrl = config.frontend_url ?? config.origins[0];
const redirect_url = `${frontendUrl}/verify-magiclink?token=${rawToken}`;
let redirect_url: string;

try {
redirect_url = await resolveMagicLinkUrl(rawToken, req.query.redirectUri);
} catch (error) {
if (error instanceof MagicLinkRedirectNotAllowedError) {
await logMagicLinkFailure(req, 'Redirect URI not allowed', user.id);
return res.status(400).json({ error: 'Redirect URI is not allowed' });
}

throw error;
}

const { ip_hash, user_agent_hash } = hashDeviceFingerprint(req.ip, req.headers['user-agent']);

Expand Down
34 changes: 33 additions & 1 deletion src/generated/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3749,7 +3749,9 @@ export interface paths {
/** Request a magic login link */
get: {
parameters: {
query?: never;
query?: {
redirectUri?: string;
};
header?: never;
path?: never;
cookie?: never;
Expand Down Expand Up @@ -3795,6 +3797,36 @@ export interface paths {
};
};
};
/** @description HTTP 400 */
400: {
headers: {
[name: string]: unknown;
};
content: {
/**
* @example {
* "error": "string",
* "message": "string",
* "details": {
* "issues": [
* null
* ]
* }
* }
*/
'application/json': {
error: string;
message?: string;
details?: {
issues: {
path: (string | number)[];
code: string;
message: string;
}[];
};
};
};
};
/** @description HTTP 403 */
403: {
headers: {
Expand Down
48 changes: 48 additions & 0 deletions src/lib/redirectAllowlist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* Copyright © 2026 Fells Code, LLC
* Licensed under the GNU Affero General Public License v3.0
* See LICENSE file in the project root for full license information
*/

/**
* Where a flow is allowed to send a browser or an app after it completes.
*
* One implementation for every flow that takes a redirect target from the caller. An
* open redirect in an auth server hands an attacker a link on the tenant's own domain
* that lands wherever they choose, which is worth having in one reviewable place rather
* than once per flow.
*/

function parseUrl(value: string) {
try {
return new URL(value);
} catch {
return null;
}
}

export function sameOrigin(value: string, allowedOrigin: string) {
const parsedValue = parseUrl(value);
const parsedAllowedOrigin = parseUrl(allowedOrigin);

if (!parsedValue || !parsedAllowedOrigin) return false;

return parsedValue.origin === parsedAllowedOrigin.origin;
}

/**
* An explicit allowlist is matched exactly, because that is the only way to express a
* target whose origin cannot be derived from a configured web origin: a mobile universal
* link on a different host, or a custom scheme. Falling back to origin comparison only
* when no allowlist is configured keeps an instance that has never set one working as it
* did.
*/
export function allowedRedirect(value: string, allowedValues: string[], fallbackOrigins: string[]) {
if (!parseUrl(value)) return false;

if (allowedValues.length > 0) {
return allowedValues.some((allowedValue) => value === allowedValue);
}

return fallbackOrigins.some((origin) => sameOrigin(value, origin));
}
7 changes: 6 additions & 1 deletion src/routes/magicLink.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import {
import { createRouter } from '../lib/createRouter.js';
import { magicLinkEmailLimiter, magicLinkIpLimiter } from '../middleware/rateLimit.js';
import { ErrorSchema, InternalErrorSchema, MessageSchema } from '../schemas/generic.responses.js';
import { MagicLinkVerifyParamsSchema } from '../schemas/magiclink.requests.js';
import {
MagicLinkRequestQuerySchema,
MagicLinkVerifyParamsSchema,
} from '../schemas/magiclink.requests.js';
import { MagicLinkPollSuccessSchema } from '../schemas/magiclink.responses.js';

const magicLinkRouter = createRouter('/magic-link');
Expand All @@ -26,8 +29,10 @@ magicLinkRouter.get(
middleware: [magicLinkIpLimiter, magicLinkEmailLimiter],

schemas: {
query: MagicLinkRequestQuerySchema,
response: {
200: MessageSchema,
400: ErrorSchema,
403: ErrorSchema,
500: InternalErrorSchema,
},
Expand Down
16 changes: 16 additions & 0 deletions src/schemas/magiclink.requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,20 @@
* See LICENSE file in the project root for full license information
*/

import { z } from 'zod';

export { MagicLinkVerifyParamsSchema } from '@seamless-auth/types';

/**
* Kept local rather than added to `@seamless-auth/types`, which would need a version bump
* and a coordinated release across this API and both SDKs before anything could use it.
* Move it there once a client adopts the field.
*/
export const MagicLinkRequestQuerySchema = z.object({
/**
* Where the link should land. Validated against the configured origins, so this cannot
* be used to point a link on the tenant's domain at somewhere else. Omit it to keep the
* tenant-wide destination.
*/
redirectUri: z.url().optional(),
});
Loading
Loading