diff --git a/.changeset/tidy-jars-arrive.md b/.changeset/tidy-jars-arrive.md
new file mode 100644
index 0000000..a2e4620
--- /dev/null
+++ b/.changeset/tidy-jars-arrive.md
@@ -0,0 +1,28 @@
+---
+'@seamless-auth/types': minor
+---
+
+Add `magic_link_redirect_uris` to the system config.
+
+An exact-match allowlist of destinations a magic link may be sent to, empty by
+default. `seamless-auth-api` validates a requested destination against `origins`
+today, which covers a target whose host is already a WebAuthn origin and cannot
+express the two cases that need this: a custom application scheme such as
+`myapp://auth`, and a universal link on a host that should not also be a WebAuthn
+origin.
+
+Exact match rather than origin comparison, because neither of those has an origin
+worth comparing. Empty by default, so a deployment that sets nothing keeps comparing
+against `origins` exactly as it does now.
+
+Also exports `RedirectTargetSchema`, which is what entries are validated with, and
+which is stricter than `z.url()` on purpose. `z.url()` accepts anything the URL parser
+does, including `javascript:alert(1)` and `data:text/html,...`. A magic link
+destination is rendered as an href in an email, so one of those stored in config would
+be a script-execution sink reachable through the admin system-config API. The
+`javascript:`, `data:`, `vbscript:`, `file:`, `blob:` and `about:` schemes are refused,
+and everything else including arbitrary application schemes is allowed, since an
+allowlist of known-good schemes could not express the case this exists for.
+
+`SystemConfigPatchSchema` takes the field too, so the guard applies to the admin write
+path and not only to what a server seeds at boot.
diff --git a/src/schemas/systemConfig/schema.test.ts b/src/schemas/systemConfig/schema.test.ts
index 8818f96..8dc455e 100644
--- a/src/schemas/systemConfig/schema.test.ts
+++ b/src/schemas/systemConfig/schema.test.ts
@@ -76,6 +76,62 @@ describe('OAuthProviderUpdateSchema', () => {
});
});
+describe('magic_link_redirect_uris', () => {
+ it('defaults to empty, so a config predating the key parses unchanged', () => {
+ expect(SystemConfigSchema.parse(baseConfig).magic_link_redirect_uris).toEqual([]);
+ });
+
+ it('accepts an application scheme, which is the case it exists for', () => {
+ const parsed = SystemConfigSchema.parse({
+ ...baseConfig,
+ magic_link_redirect_uris: ['myapp://auth/magic', 'com.example.app://callback'],
+ });
+
+ expect(parsed.magic_link_redirect_uris).toEqual([
+ 'myapp://auth/magic',
+ 'com.example.app://callback',
+ ]);
+ });
+
+ it('accepts a universal link on a host that is not a configured origin', () => {
+ const parsed = SystemConfigSchema.parse({
+ ...baseConfig,
+ magic_link_redirect_uris: ['https://links.example.com/m'],
+ });
+
+ expect(parsed.magic_link_redirect_uris).toEqual(['https://links.example.com/m']);
+ });
+
+ // z.url() alone accepts these. A magic link target is rendered as an href in an
+ // email, so a javascript: or data: entry reachable through the admin API would be a
+ // script-execution sink.
+ it.each(['javascript:alert(1)', 'data:text/html,', 'file:///etc/passwd'])(
+ 'rejects %s',
+ (uri) => {
+ expect(
+ SystemConfigSchema.safeParse({ ...baseConfig, magic_link_redirect_uris: [uri] }).success,
+ ).toBe(false);
+ },
+ );
+
+ it('rejects a value that is not a URL at all', () => {
+ expect(
+ SystemConfigSchema.safeParse({ ...baseConfig, magic_link_redirect_uris: ['not a url'] })
+ .success,
+ ).toBe(false);
+ });
+
+ it('guards the patch surface the admin API writes through', () => {
+ expect(
+ SystemConfigPatchSchema.safeParse({ magic_link_redirect_uris: ['javascript:alert(1)'] })
+ .success,
+ ).toBe(false);
+ expect(
+ SystemConfigPatchSchema.safeParse({ magic_link_redirect_uris: ['myapp://auth'] }).success,
+ ).toBe(true);
+ });
+});
+
describe('SystemConfigSchema', () => {
it('applies the default lockout policy', () => {
const parsed = SystemConfigSchema.parse(baseConfig);
diff --git a/src/schemas/systemConfig/schema.ts b/src/schemas/systemConfig/schema.ts
index c595442..0b34d12 100644
--- a/src/schemas/systemConfig/schema.ts
+++ b/src/schemas/systemConfig/schema.ts
@@ -11,6 +11,38 @@ export const LoginMethodSchema = z.enum([
export type LoginMethod = z.infer;
+/**
+ * Schemes that must never be a redirect target.
+ *
+ * `z.url()` accepts anything the URL parser does, which includes
+ * `javascript:alert(1)` and `data:text/html,...`. A magic link target is rendered as
+ * an href in an email, so one of those stored in config would be a script-execution
+ * sink reachable through the admin API. Denied rather than allowlisted because the
+ * point of this list is to permit arbitrary application schemes (`myapp://`), which
+ * an allowlist of known-good schemes could not express.
+ */
+const DENIED_REDIRECT_PROTOCOLS = new Set([
+ 'javascript:',
+ 'data:',
+ 'vbscript:',
+ 'file:',
+ 'blob:',
+ 'about:',
+]);
+
+export const RedirectTargetSchema = z.url().refine(
+ (value) => {
+ try {
+ return !DENIED_REDIRECT_PROTOCOLS.has(new URL(value).protocol.toLowerCase());
+ } catch {
+ return false;
+ }
+ },
+ { message: 'Redirect target uses a scheme that cannot be a link destination' },
+);
+
+export type RedirectTarget = z.infer;
+
export const OAuthProviderIdSchema = z.string().regex(/^[a-z0-9-]{2,40}$/);
export type OAuthProviderId = z.infer;
@@ -212,6 +244,17 @@ export const SystemConfigSchema = z.object({
origins: z.array(z.url()).min(1),
frontend_url: z.url().optional(),
+
+ /**
+ * Exact-match destinations a magic link may be sent to, beyond what `origins`
+ * already covers. Empty by default, which leaves the server comparing a requested
+ * target against `origins` as it did before this key existed.
+ *
+ * Exact match rather than origin comparison, because the cases this exists for have
+ * no origin to compare: a custom scheme such as `myapp://auth`, or a universal link
+ * on a host that should not also be a WebAuthn origin.
+ */
+ magic_link_redirect_uris: z.array(RedirectTargetSchema).default([]),
});
export type SystemConfig = z.infer;
@@ -235,6 +278,7 @@ export const SystemConfigPatchSchema = z
delay_after: SystemConfigSchema.shape.delay_after.optional(),
rpid: SystemConfigSchema.shape.rpid.optional(),
origins: SystemConfigSchema.shape.origins.optional(),
+ magic_link_redirect_uris: z.array(RedirectTargetSchema).optional(),
})
.strict();