From ef62a0aff724a4bc910d9c04858c20718be73832 Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Sun, 20 Sep 2026 21:54:38 +0900 Subject: [PATCH 01/12] Add --login-origin Option --- packages/drfed/src/parser.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/drfed/src/parser.ts b/packages/drfed/src/parser.ts index 8ba2240..31a01ed 100644 --- a/packages/drfed/src/parser.ts +++ b/packages/drfed/src/parser.ts @@ -22,7 +22,7 @@ import { message, optionNames } from "@optique/core/message"; import { map, optional, withDefault } from "@optique/core/modifiers"; import type { InferValue } from "@optique/core/parser"; import { flag, option } from "@optique/core/primitives"; -import { email, socketAddress, url } from "@optique/core/valueparser"; +import { email, origin, socketAddress, url } from "@optique/core/valueparser"; import { loggingOptions } from "@optique/logtape"; import { path } from "@optique/run/valueparser"; import { LogTapeTransport } from "@upyo/logtape"; @@ -124,6 +124,14 @@ const emailFromParser = optional( }), ); +const loginOriginParser = option( + "--login-origin", + origin({ allowedProtocols: ["http:", "https:"] }), + { + description: message`The frontend origin allowed in email login links.`, + }, +); + const serverParser = object("DrFed server", { address: withDefault( option("--listen", "-l", socketAddress({ requirePort: true }), { @@ -146,6 +154,7 @@ const serverParser = object("DrFed server", { }), ), rootOrigin: rootOriginParser, + loginOrigin: loginOriginParser, emailFrom: emailFromParser, mailer: smtpParser, seed: seedParser, From 2b923773232e10f263d1eda5d601d5eaba59d444 Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Sun, 20 Sep 2026 22:25:46 +0900 Subject: [PATCH 02/12] Use --login-origin Option --- packages/drfed/src/index.ts | 22 ++-------------------- packages/drfed/src/parser.test.ts | 5 +++-- packages/graphql/src/auth.test.ts | 1 + packages/graphql/src/auth/expand.ts | 6 +++--- packages/graphql/src/auth/magic-link.ts | 2 +- packages/graphql/src/builder.ts | 4 ++-- packages/graphql/src/federation.test.ts | 3 +-- packages/graphql/src/harness.test.ts | 4 ++-- packages/graphql/src/index.ts | 6 +++--- 9 files changed, 18 insertions(+), 35 deletions(-) diff --git a/packages/drfed/src/index.ts b/packages/drfed/src/index.ts index c0c084b..5ae173a 100644 --- a/packages/drfed/src/index.ts +++ b/packages/drfed/src/index.ts @@ -41,24 +41,6 @@ import seedData from "./seed.ts"; import { createFetchHandler, warnAboutStrandedInstances } from "./serving.ts"; async function runServer(options: ServerOptions) { - const values = process.env.DRFED_LOGIN_ORIGINS?.split(",").map((value) => - value.trim(), - ); - if (values == null || values.some((value) => value === "")) { - throw new TypeError("DRFED_LOGIN_ORIGINS must contain valid origins."); - } - const loginOrigins = new Set( - values.map((value) => { - const url = new URL(value); - if (url.protocol !== "https:" && url.protocol !== "http:") { - throw new TypeError( - `Unsupported login origin protocol: ${url.protocol}`, - ); - } - return url.origin; - }), - ); - const { credentials } = options.drizzle; if (options.drizzle.migrate) await migrate({ credentials }); if (options.seed) await seedData(options.drizzle.db); @@ -67,13 +49,13 @@ async function runServer(options: ServerOptions) { ? new PgliteKvStore(credentials.client) : new PostgresKvStore(credentials.client); const federation = await createFederation(options.drizzle.db, { kv }); - const { emailFrom, mailer, rootOrigin } = options; + const { emailFrom, mailer, rootOrigin, loginOrigin } = options; const yogaServer = createYogaServer(options.drizzle.db, federation, { rootOrigin, emailFrom, mailer, - loginOrigins, + loginOrigin, }); await warnAboutStrandedInstances(options.drizzle.db, rootOrigin); const server = serve({ diff --git a/packages/drfed/src/parser.test.ts b/packages/drfed/src/parser.test.ts index 8d7d64e..baf1920 100644 --- a/packages/drfed/src/parser.test.ts +++ b/packages/drfed/src/parser.test.ts @@ -129,16 +129,17 @@ describe("drfed-server", () => { "--email-from=postmaster@mail.example", ]); assert.notEqual(accepted.code, 0); - assert.match(accepted.stderr, /DRFED_LOGIN_ORIGINS/u); + assert.match(accepted.stderr, /Missing option .*--login-origin/u); const rejected = await run([ "--data-path", dataPath, "--root-origin=https://drfed.net", + "--login-origin=https://drfed.net", "--email-from=not-an-address", ]); assert.notEqual(rejected.code, 0); - assert.doesNotMatch(rejected.stderr, /DRFED_LOGIN_ORIGINS/u); + assert.match(rejected.stderr, /Expected a valid email address/u); } finally { await rm(dataPath, { force: true, recursive: true }); } diff --git a/packages/graphql/src/auth.test.ts b/packages/graphql/src/auth.test.ts index b1c00b0..8dbffe5 100644 --- a/packages/graphql/src/auth.test.ts +++ b/packages/graphql/src/auth.test.ts @@ -197,6 +197,7 @@ describe("email authentication", () => { equal(message.sender.address, "postmaster@mail.example"); }, new URL("https://drfed.example"), + new URL("https://drfed.test"), "postmaster@mail.example", ); }); diff --git a/packages/graphql/src/auth/expand.ts b/packages/graphql/src/auth/expand.ts index 7be37c3..bcf7944 100644 --- a/packages/graphql/src/auth/expand.ts +++ b/packages/graphql/src/auth/expand.ts @@ -21,14 +21,14 @@ export interface ExpandVerifyUrlParams { template: Template; challengeId: `${string}-${string}-${string}-${string}-${string}`; code: string; - loginOrigins: ReadonlySet; + loginOrigin: string; } export default function expandVerifyUrl({ template, challengeId, code, - loginOrigins, + loginOrigin, }: ExpandVerifyUrlParams): string { assertVariable(template, "challengeId"); assertVariable(template, "code"); @@ -45,7 +45,7 @@ export default function expandVerifyUrl({ if (url.protocol !== "https:" && url.protocol !== "http:") { throw invalidVerifyUrl("Verify URL must use HTTP or HTTPS."); } - if (!loginOrigins.has(url.origin)) { + if (loginOrigin !== url.origin) { throw invalidVerifyUrl(`Verify URL origin is not allowed: ${url.origin}.`); } return url.href; diff --git a/packages/graphql/src/auth/magic-link.ts b/packages/graphql/src/auth/magic-link.ts index c5e6369..f1723bf 100644 --- a/packages/graphql/src/auth/magic-link.ts +++ b/packages/graphql/src/auth/magic-link.ts @@ -65,7 +65,7 @@ builder.mutationFields((t) => ({ const loginUrl = expandVerifyUrl({ challengeId, code, - loginOrigins: ctx.loginOrigins, + loginOrigin: ctx.loginOrigin.origin, template: verifyUrl, }); const account = await findAccount(email, ctx); diff --git a/packages/graphql/src/builder.ts b/packages/graphql/src/builder.ts index a3a33b3..0461591 100644 --- a/packages/graphql/src/builder.ts +++ b/packages/graphql/src/builder.ts @@ -62,9 +62,9 @@ export interface ServerContext { readonly emailFrom: string; /** - * Origin list for login. + * Origin for login. */ - readonly loginOrigins: ReadonlySet; + readonly loginOrigin: URL; /** * The root origin of this deployment, which every instance's subdomain is diff --git a/packages/graphql/src/federation.test.ts b/packages/graphql/src/federation.test.ts index 440da65..163eb90 100644 --- a/packages/graphql/src/federation.test.ts +++ b/packages/graphql/src/federation.test.ts @@ -179,11 +179,10 @@ describe("createFederation()", () => { describe("createYogaServer()", () => { it("does not mutate the federation instance", async () => { await withTestHarness(({ db, mailer, federation }) => { - const loginOrigins = new Set(["https://drfed.test"]); assert.doesNotThrow(() => createYogaServer(db, federation, { mailer, - loginOrigins, + loginOrigin: new URL("https://drfed.test"), rootOrigin: new URL("https://drfed.test"), }), ); diff --git a/packages/graphql/src/harness.test.ts b/packages/graphql/src/harness.test.ts index 6a68e94..bbe6cb6 100644 --- a/packages/graphql/src/harness.test.ts +++ b/packages/graphql/src/harness.test.ts @@ -172,16 +172,16 @@ export async function withTestHarness( // oxlint-disable-next-line promise/prefer-await-to-callbacks callback: (harness: TestHarness) => Promise | T, rootOrigin: URL = new URL("https://drfed.org"), + loginOrigin: URL = new URL("https://drfed.test"), emailFrom?: string, ): Promise> { return await withTemporaryDatabase(async (db) => { const mailer = new MockTransport(); const federation = await createFederation(db, { kv: new MemoryKvStore() }); - const loginOrigins = new Set(["https://drfed.test"]); const yoga = createYogaServer(db, federation, { mailer, - loginOrigins, rootOrigin, + loginOrigin, emailFrom, }); const fetch: TestFetch = yoga.fetch.bind(yoga); diff --git a/packages/graphql/src/index.ts b/packages/graphql/src/index.ts index f12885d..51d9fcf 100644 --- a/packages/graphql/src/index.ts +++ b/packages/graphql/src/index.ts @@ -46,9 +46,9 @@ export interface YogaServerOptions { emailFrom?: string | undefined; /** - * Origin list for login. + * Origin for login. */ - loginOrigins: ReadonlySet; + loginOrigin: URL; /** * The root origin of this deployment. Every instance is served from a @@ -116,7 +116,7 @@ const fillOptions = ( // at drfed.org would fail the SPF and DMARC checks of every deployment but // the project's own, and the login mail would be rejected or junked. emailFrom: opt.emailFrom ?? `noreply@${canonicalHostname(opt.rootOrigin)}`, - loginOrigins: opt.loginOrigins, + loginOrigin: opt.loginOrigin, rootOrigin: opt.rootOrigin, }); From 3224fff892e5a90248cbc74fe9c6ddae92f583b5 Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Sun, 20 Sep 2026 22:26:54 +0900 Subject: [PATCH 03/12] Update dev.mts --- scripts/dev.mts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/dev.mts b/scripts/dev.mts index f0301d7..8e2fb25 100644 --- a/scripts/dev.mts +++ b/scripts/dev.mts @@ -49,6 +49,7 @@ try { // DNS or /etc/hosts setup, which is what makes per-instance subdomains usable // in development. const defaultRootOrigin = "http://drfed.localhost:8888"; +const defaultLoginOrigin = "http://drfed.locaㅣhost:3000"; const isWindows = process.platform === "win32"; const pnpm = isWindows ? "pnpm.cmd" : "pnpm"; @@ -317,13 +318,13 @@ try { const serverArgs: string[] = [ "--watch", - "--env-file=.env", "bin/drfed-server.mjs", "--pglite-data-path", "../../.pgdata", "--listen=0.0.0.0:8888", "--log-format=color", `--root-origin=${process.env.DRFED_ROOT_ORIGIN ?? defaultRootOrigin}`, + `--login-origin=${process.env.DRFED_LOGIN_ORIGIN ?? defaultLoginOrigin}`, ]; const logLevel = process.env.usage_log_level; From 28b18687c31db00f402a17a903b803faec596fb5 Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Sun, 20 Sep 2026 22:40:27 +0900 Subject: [PATCH 04/12] Update .env.example --- packages/drfed/.env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/drfed/.env.example b/packages/drfed/.env.example index 85abd2a..0661973 100644 --- a/packages/drfed/.env.example +++ b/packages/drfed/.env.example @@ -1,2 +1,2 @@ -DRFED_LOGIN_ORIGINS=https://drfed.example.com,http://localhost:3000 +DRFED_LOGIN_ORIGIN=https://drfed.example.com DRFED_ROOT_ORIGIN=http://drfed.localhost:8888 From 812c0c11f5a2ddf5c4f3d1d0a8bb50eeb4a0ab5f Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Thu, 24 Sep 2026 12:07:04 +0900 Subject: [PATCH 05/12] Fix typo in dev.mts --- scripts/dev.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/dev.mts b/scripts/dev.mts index 8e2fb25..c412dc8 100644 --- a/scripts/dev.mts +++ b/scripts/dev.mts @@ -49,7 +49,7 @@ try { // DNS or /etc/hosts setup, which is what makes per-instance subdomains usable // in development. const defaultRootOrigin = "http://drfed.localhost:8888"; -const defaultLoginOrigin = "http://drfed.locaㅣhost:3000"; +const defaultLoginOrigin = "http://drfed.localhost:3000"; const isWindows = process.platform === "win32"; const pnpm = isWindows ? "pnpm.cmd" : "pnpm"; From 985f4ef70b8720c54fb0d2fa51353dda8ba5d0dc Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Wed, 30 Sep 2026 13:13:54 +0900 Subject: [PATCH 06/12] Add regression red test for login linkOrigin --- packages/graphql/src/auth.test.ts | 42 +++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/packages/graphql/src/auth.test.ts b/packages/graphql/src/auth.test.ts index 8dbffe5..59e9d1c 100644 --- a/packages/graphql/src/auth.test.ts +++ b/packages/graphql/src/auth.test.ts @@ -514,4 +514,46 @@ describe("email authentication", () => { equal(error?.path[0], "revokeSession"); }); }); + + for (const [configuredOrigin, linkOrigin] of [ + ["https://app.example.", "https://app.example"], + ["http://127.0.0.1:3000", "http://127.0.0.1:3000"], + ["http://[::1]:3000", "http://[::1]:3000"], + ] as const) { + it(`accepts login links on ${linkOrigin} with ${configuredOrigin} configured`, async () => { + await withTestHarness( + async ({ db, mailer, post }) => { + await db.insert(schema.accounts).values({ + id: accountId, + email, + name: "Tachibana Sherry", + }); + + const response = await post({ + query: loginMutation, + variables: { + email, + verifyUrl: + `${linkOrigin}/verify` + + "?challengeId={challengeId}&code={code}", + }, + }); + + equal(response.status, okStatus); + const body = await response.json(); + equal(body.errors, undefined); + ok(body.data.loginByEmail.challengeId); + + const messages = mailer.getSentMessages(); + equal(messages.length, 1); + + const [message] = messages; + ok(message); + ok(message.content.text?.includes(`${linkOrigin}/verify?`)); + }, + new URL("https://drfed.org"), + new URL(configuredOrigin), + ); + }); + } }); From 94ec1bec524652859cb446feb3bae08403cd3a38 Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Wed, 30 Sep 2026 13:20:07 +0900 Subject: [PATCH 07/12] Check loginOrigin in fillOptions --- packages/graphql/src/index.ts | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/graphql/src/index.ts b/packages/graphql/src/index.ts index 51d9fcf..cb567f7 100644 --- a/packages/graphql/src/index.ts +++ b/packages/graphql/src/index.ts @@ -109,16 +109,21 @@ function mockTransport() { const fillOptions = ( opt: YogaServerOptions, -): Omit => ({ - mailer: opt.mailer ?? mockTransport(), - // Derived from the deployment's own domain rather than the project's, so - // that the operator's mail server is authorized to send it. A From address - // at drfed.org would fail the SPF and DMARC checks of every deployment but - // the project's own, and the login mail would be rejected or junked. - emailFrom: opt.emailFrom ?? `noreply@${canonicalHostname(opt.rootOrigin)}`, - loginOrigin: opt.loginOrigin, - rootOrigin: opt.rootOrigin, -}); +): Omit => { + const loginOrigin = new URL(opt.loginOrigin); + loginOrigin.hostname = canonicalHostname(loginOrigin); + + return { + mailer: opt.mailer ?? mockTransport(), + // Derived from the deployment's own domain rather than the project's, so + // that the operator's mail server is authorized to send it. A From address + // at drfed.org would fail the SPF and DMARC checks of every deployment but + // the project's own, and the login mail would be rejected or junked. + emailFrom: opt.emailFrom ?? `noreply@${canonicalHostname(opt.rootOrigin)}`, + loginOrigin, + rootOrigin: opt.rootOrigin, + }; +}; const getAccessToken = (headers: Headers) => /^Bearer (?[^\s]+)$/u.exec(headers.get("Authorization") ?? "")?.groups From 22f026ea5b080599929c0d7d68cbc7d34fbe5356 Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Wed, 30 Sep 2026 13:29:28 +0900 Subject: [PATCH 08/12] Change .env.example to multiple option --- packages/drfed/.env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/drfed/.env.example b/packages/drfed/.env.example index 0661973..d99738f 100644 --- a/packages/drfed/.env.example +++ b/packages/drfed/.env.example @@ -1,2 +1,2 @@ -DRFED_LOGIN_ORIGIN=https://drfed.example.com +DRFED_LOGIN_ORIGINS=https://drfed.example.com,https://drfed.exeample2.com DRFED_ROOT_ORIGIN=http://drfed.localhost:8888 From 8f2dd31de57958831cf94591d6d3f509aaa4ad85 Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Wed, 30 Sep 2026 14:01:39 +0900 Subject: [PATCH 09/12] Make --login-origin to multiple option --- packages/drfed/src/index.ts | 4 ++-- packages/drfed/src/parser.test.ts | 23 ++++++++++------------- packages/drfed/src/parser.ts | 22 ++++++++++++++-------- packages/graphql/src/auth.test.ts | 4 ++-- packages/graphql/src/auth/expand.ts | 6 +++--- packages/graphql/src/auth/magic-link.ts | 2 +- packages/graphql/src/builder.ts | 4 ++-- packages/graphql/src/federation.test.ts | 2 +- packages/graphql/src/harness.test.ts | 4 ++-- packages/graphql/src/index.ts | 15 ++++++++++----- 10 files changed, 47 insertions(+), 39 deletions(-) diff --git a/packages/drfed/src/index.ts b/packages/drfed/src/index.ts index 5ae173a..6d6a572 100644 --- a/packages/drfed/src/index.ts +++ b/packages/drfed/src/index.ts @@ -49,13 +49,13 @@ async function runServer(options: ServerOptions) { ? new PgliteKvStore(credentials.client) : new PostgresKvStore(credentials.client); const federation = await createFederation(options.drizzle.db, { kv }); - const { emailFrom, mailer, rootOrigin, loginOrigin } = options; + const { emailFrom, mailer, rootOrigin, loginOrigins } = options; const yogaServer = createYogaServer(options.drizzle.db, federation, { rootOrigin, emailFrom, mailer, - loginOrigin, + loginOrigins, }); await warnAboutStrandedInstances(options.drizzle.db, rootOrigin); const server = serve({ diff --git a/packages/drfed/src/parser.test.ts b/packages/drfed/src/parser.test.ts index baf1920..6cd3e75 100644 --- a/packages/drfed/src/parser.test.ts +++ b/packages/drfed/src/parser.test.ts @@ -32,9 +32,7 @@ const commandTimeout = 30_000; // The binary rather than the parser module, because what matters here is the // contract the installed command exposes. Parsing `--pglite-data-path` opens // a database as a side effect, so every case below either fails during parsing -// or takes the schema-generation branch, which needs no database at all. That -// is also why none of them need `DRFED_LOGIN_ORIGINS`: the server never gets -// far enough to read it. +// or takes the schema-generation branch, which needs no database at all. const binary = join( dirname(fileURLToPath(import.meta.url)), "..", @@ -50,10 +48,8 @@ async function run( process.execPath, [binary, ...args], { - // A deliberately minimal environment. Leaving `DRFED_LOGIN_ORIGINS` - // out means that a command line which parses successfully still stops - // immediately instead of starting a server, whatever the developer - // happens to have exported. + // A deliberately minimal environment, independent of what the + // developer happens to have exported. env: { PATH: process.env.PATH ?? "" }, timeout: commandTimeout, }, @@ -86,10 +82,8 @@ describe("drfed-server", () => { }); it("no longer accepts the old --root-domain option", async () => { - // Everything else on this command line is valid, so the only thing that - // can go wrong is the retired option. If it were reinstated, parsing - // would succeed and the run would instead stop on the missing - // `DRFED_LOGIN_ORIGINS`, which says something else entirely. + // If the retired option were reinstated, parsing would instead stop on + // the missing --login-origin, which says something else entirely. const dataPath = await mkdtemp(join(tmpdir(), "drfed-parser-test-")); try { const { code, stderr } = await run([ @@ -121,7 +115,7 @@ describe("drfed-server", () => { const dataPath = await mkdtemp(join(tmpdir(), "drfed-parser-test-")); try { // Valid: parsing gets past the option and stops only on the missing - // login origins, which is the next thing the server reads. + // required --login-origin option. const accepted = await run([ "--data-path", dataPath, @@ -129,7 +123,10 @@ describe("drfed-server", () => { "--email-from=postmaster@mail.example", ]); assert.notEqual(accepted.code, 0); - assert.match(accepted.stderr, /Missing option .*--login-origin/u); + assert.match( + accepted.stderr, + /Expected at least 1 values, but got only 0\./u, + ); const rejected = await run([ "--data-path", diff --git a/packages/drfed/src/parser.ts b/packages/drfed/src/parser.ts index 31a01ed..1b0ad73 100644 --- a/packages/drfed/src/parser.ts +++ b/packages/drfed/src/parser.ts @@ -19,7 +19,7 @@ import { PGlite } from "@electric-sql/pglite"; import { getLogger } from "@logtape/drizzle-orm"; import { merge, object, or } from "@optique/core/constructs"; import { message, optionNames } from "@optique/core/message"; -import { map, optional, withDefault } from "@optique/core/modifiers"; +import { map, multiple, optional, withDefault } from "@optique/core/modifiers"; import type { InferValue } from "@optique/core/parser"; import { flag, option } from "@optique/core/primitives"; import { email, origin, socketAddress, url } from "@optique/core/valueparser"; @@ -124,12 +124,18 @@ const emailFromParser = optional( }), ); -const loginOriginParser = option( - "--login-origin", - origin({ allowedProtocols: ["http:", "https:"] }), - { - description: message`The frontend origin allowed in email login links.`, - }, +const loginOriginParser = map( + multiple( + option( + "--login-origin", + origin({ allowedProtocols: ["http:", "https:"] }), + { + description: message`The frontend origin allowed in email login links.`, + }, + ), + { min: 1 }, + ), + (values) => new Set(values.map((value) => value.origin)), ); const serverParser = object("DrFed server", { @@ -154,7 +160,7 @@ const serverParser = object("DrFed server", { }), ), rootOrigin: rootOriginParser, - loginOrigin: loginOriginParser, + loginOrigins: loginOriginParser, emailFrom: emailFromParser, mailer: smtpParser, seed: seedParser, diff --git a/packages/graphql/src/auth.test.ts b/packages/graphql/src/auth.test.ts index 59e9d1c..c737bfe 100644 --- a/packages/graphql/src/auth.test.ts +++ b/packages/graphql/src/auth.test.ts @@ -197,7 +197,7 @@ describe("email authentication", () => { equal(message.sender.address, "postmaster@mail.example"); }, new URL("https://drfed.example"), - new URL("https://drfed.test"), + new Set(["https://drfed.test"]), "postmaster@mail.example", ); }); @@ -552,7 +552,7 @@ describe("email authentication", () => { ok(message.content.text?.includes(`${linkOrigin}/verify?`)); }, new URL("https://drfed.org"), - new URL(configuredOrigin), + new Set([configuredOrigin]), ); }); } diff --git a/packages/graphql/src/auth/expand.ts b/packages/graphql/src/auth/expand.ts index bcf7944..7be37c3 100644 --- a/packages/graphql/src/auth/expand.ts +++ b/packages/graphql/src/auth/expand.ts @@ -21,14 +21,14 @@ export interface ExpandVerifyUrlParams { template: Template; challengeId: `${string}-${string}-${string}-${string}-${string}`; code: string; - loginOrigin: string; + loginOrigins: ReadonlySet; } export default function expandVerifyUrl({ template, challengeId, code, - loginOrigin, + loginOrigins, }: ExpandVerifyUrlParams): string { assertVariable(template, "challengeId"); assertVariable(template, "code"); @@ -45,7 +45,7 @@ export default function expandVerifyUrl({ if (url.protocol !== "https:" && url.protocol !== "http:") { throw invalidVerifyUrl("Verify URL must use HTTP or HTTPS."); } - if (loginOrigin !== url.origin) { + if (!loginOrigins.has(url.origin)) { throw invalidVerifyUrl(`Verify URL origin is not allowed: ${url.origin}.`); } return url.href; diff --git a/packages/graphql/src/auth/magic-link.ts b/packages/graphql/src/auth/magic-link.ts index f1723bf..c5e6369 100644 --- a/packages/graphql/src/auth/magic-link.ts +++ b/packages/graphql/src/auth/magic-link.ts @@ -65,7 +65,7 @@ builder.mutationFields((t) => ({ const loginUrl = expandVerifyUrl({ challengeId, code, - loginOrigin: ctx.loginOrigin.origin, + loginOrigins: ctx.loginOrigins, template: verifyUrl, }); const account = await findAccount(email, ctx); diff --git a/packages/graphql/src/builder.ts b/packages/graphql/src/builder.ts index 0461591..5f79c46 100644 --- a/packages/graphql/src/builder.ts +++ b/packages/graphql/src/builder.ts @@ -62,9 +62,9 @@ export interface ServerContext { readonly emailFrom: string; /** - * Origin for login. + * Origins for login. */ - readonly loginOrigin: URL; + readonly loginOrigins: ReadonlySet; /** * The root origin of this deployment, which every instance's subdomain is diff --git a/packages/graphql/src/federation.test.ts b/packages/graphql/src/federation.test.ts index 163eb90..1bc213c 100644 --- a/packages/graphql/src/federation.test.ts +++ b/packages/graphql/src/federation.test.ts @@ -182,7 +182,7 @@ describe("createYogaServer()", () => { assert.doesNotThrow(() => createYogaServer(db, federation, { mailer, - loginOrigin: new URL("https://drfed.test"), + loginOrigins: new Set(["https://drfed.test"]), rootOrigin: new URL("https://drfed.test"), }), ); diff --git a/packages/graphql/src/harness.test.ts b/packages/graphql/src/harness.test.ts index bbe6cb6..b5768af 100644 --- a/packages/graphql/src/harness.test.ts +++ b/packages/graphql/src/harness.test.ts @@ -172,7 +172,7 @@ export async function withTestHarness( // oxlint-disable-next-line promise/prefer-await-to-callbacks callback: (harness: TestHarness) => Promise | T, rootOrigin: URL = new URL("https://drfed.org"), - loginOrigin: URL = new URL("https://drfed.test"), + loginOrigins: ReadonlySet = new Set(["https://drfed.test"]), emailFrom?: string, ): Promise> { return await withTemporaryDatabase(async (db) => { @@ -181,7 +181,7 @@ export async function withTestHarness( const yoga = createYogaServer(db, federation, { mailer, rootOrigin, - loginOrigin, + loginOrigins, emailFrom, }); const fetch: TestFetch = yoga.fetch.bind(yoga); diff --git a/packages/graphql/src/index.ts b/packages/graphql/src/index.ts index cb567f7..b380dac 100644 --- a/packages/graphql/src/index.ts +++ b/packages/graphql/src/index.ts @@ -46,9 +46,9 @@ export interface YogaServerOptions { emailFrom?: string | undefined; /** - * Origin for login. + * Origins for login. */ - loginOrigin: URL; + loginOrigins: ReadonlySet; /** * The root origin of this deployment. Every instance is served from a @@ -110,8 +110,13 @@ function mockTransport() { const fillOptions = ( opt: YogaServerOptions, ): Omit => { - const loginOrigin = new URL(opt.loginOrigin); - loginOrigin.hostname = canonicalHostname(loginOrigin); + const loginOrigins = new Set(); + + for (const loginOrigin of opt.loginOrigins) { + const url = new URL(loginOrigin); + url.hostname = canonicalHostname(url); + loginOrigins.add(url.origin); + } return { mailer: opt.mailer ?? mockTransport(), @@ -120,7 +125,7 @@ const fillOptions = ( // at drfed.org would fail the SPF and DMARC checks of every deployment but // the project's own, and the login mail would be rejected or junked. emailFrom: opt.emailFrom ?? `noreply@${canonicalHostname(opt.rootOrigin)}`, - loginOrigin, + loginOrigins, rootOrigin: opt.rootOrigin, }; }; From 6a81447a4fa0e6bacb011344c2469ecebf554e06 Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Wed, 30 Sep 2026 14:08:29 +0900 Subject: [PATCH 10/12] Change dev.mts to take multiple value --- scripts/dev.mts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/dev.mts b/scripts/dev.mts index c412dc8..46ca3fd 100644 --- a/scripts/dev.mts +++ b/scripts/dev.mts @@ -49,7 +49,7 @@ try { // DNS or /etc/hosts setup, which is what makes per-instance subdomains usable // in development. const defaultRootOrigin = "http://drfed.localhost:8888"; -const defaultLoginOrigin = "http://drfed.localhost:3000"; +const defaultLoginOrigins = "http://localhost:3000"; const isWindows = process.platform === "win32"; const pnpm = isWindows ? "pnpm.cmd" : "pnpm"; @@ -324,7 +324,9 @@ try { "--listen=0.0.0.0:8888", "--log-format=color", `--root-origin=${process.env.DRFED_ROOT_ORIGIN ?? defaultRootOrigin}`, - `--login-origin=${process.env.DRFED_LOGIN_ORIGIN ?? defaultLoginOrigin}`, + ...(process.env.DRFED_LOGIN_ORIGINS ?? defaultLoginOrigins) + .split(",") + .map((value) => `--login-origin=${value.trim()}`), ]; const logLevel = process.env.usage_log_level; From f3a74a1c6dc6868cdce29fe97aca4c70e1c6f53e Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Wed, 30 Sep 2026 14:11:54 +0900 Subject: [PATCH 11/12] Match docs with new --login-orgin option Assited-by Codex: gpt-6.1-sol Prompt `` Fix CLI's readme and dev.mts' comment about --login-origin ``` --- packages/drfed/README.md | 26 ++++++++++++++++++-------- scripts/dev.mts | 9 ++++----- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/packages/drfed/README.md b/packages/drfed/README.md index eca68cd..b3b68bc 100644 --- a/packages/drfed/README.md +++ b/packages/drfed/README.md @@ -12,8 +12,10 @@ Usage ----- ~~~~ sh -drfed-server --root-origin https://drfed.example.com --data-path .pgdata drfed-server --root-origin https://drfed.example.com \ + --login-origin https://drfed.example.com --data-path .pgdata +drfed-server --root-origin https://drfed.example.com \ + --login-origin https://drfed.example.com \ --database-url postgres://localhost/drfed ~~~~ @@ -33,7 +35,8 @@ and is carried into every instance, which is what makes a development deployment work: ~~~~ sh -drfed-server --root-origin http://drfed.localhost:8888 --data-path .pgdata +drfed-server --root-origin http://drfed.localhost:8888 \ + --login-origin http://localhost:3000 --data-path .pgdata ~~~~ Requests are routed by the authority they arrive on: @@ -71,15 +74,16 @@ names are already part of the actor URIs the rest of the fediverse has stored, so the server only warns at startup about instances it can no longer reach. -Environment ------------ +Login origins +------------- -`DRFED_LOGIN_ORIGINS` is required and accepts a comma-separated list of HTTP -or HTTPS origins allowed in email login links: +`--login-origin` specifies an HTTP or HTTPS origin allowed in email login +links. At least one is required; repeat the option to allow multiple origins: ~~~~ sh -DRFED_LOGIN_ORIGINS=https://drfed.example.com,http://localhost:3000 \ - drfed-server --root-origin https://drfed.example.com --data-path .pgdata +drfed-server --root-origin https://drfed.example.com --data-path .pgdata \ + --login-origin https://drfed.example.com \ + --login-origin http://localhost:3000 ~~~~ For repository development, create the environment file loaded by @@ -89,6 +93,11 @@ For repository development, create the environment file loaded by cp packages/drfed/.env.example packages/drfed/.env ~~~~ +`mise run dev` reads `DRFED_LOGIN_ORIGINS` as a comma-separated list and passes +each value as a `--login-origin` option. If unset, it defaults to +`http://localhost:3000`. The installed CLI does not read this variable; +pass `--login-origin` explicitly. + That file also carries `DRFED_ROOT_ORIGIN`, which `mise run dev` passes as `--root-origin`. It defaults to `http://drfed.localhost:8888`; every subdomain of `localhost` resolves to the loopback address without any DNS or */etc/hosts* @@ -101,6 +110,7 @@ Options | Option | Short | Description | | ------------------------- | ----- | -------------------------------------------------------------------- | | `--root-origin ORIGIN` | `-r` | Origin instances are subdomains of (required) | +| `--login-origin ORIGIN` | | Origin allowed in login links (required; may be repeated) | | `--listen HOST:PORT` | `-l` | Address to listen on (default: `localhost:8888`) | | `--pglite-data-path PATH` | `-d` | Directory for PGlite storage | | `--postgres-url URL` | `-D` | PostgreSQL connection URL | diff --git a/scripts/dev.mts b/scripts/dev.mts index 46ca3fd..bc12a7c 100644 --- a/scripts/dev.mts +++ b/scripts/dev.mts @@ -34,15 +34,14 @@ interface ShutdownOptions { const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const packagesDir = join(root, "packages"); -// The server itself is started with `--env-file`, but the root origin has to -// be known here, to be passed as a command-line option. Loading the same file -// keeps the two in one place. It is not committed, so tolerate its absence. +// Load development origins from .env and pass them as --root-origin and +// repeated --login-origin options. The file is not committed, so tolerate +// its absence. const envFile = join(packagesDir, "drfed", ".env"); try { process.loadEnvFile(envFile); } catch { - // Left to `drfed-server` to complain about, since it needs - // `DRFED_LOGIN_ORIGINS` from the same file anyway. + // Use development defaults when the file is unavailable. } // Any subdomain of `localhost` resolves to the loopback address without any From a5dc7d928fd1c302f1c449cf81ef823cf92eee3f Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Wed, 30 Sep 2026 14:26:19 +0900 Subject: [PATCH 12/12] Make CLI regression test for --login-origin Assisted-by Codex:gpt-6.1-sol Prompt ``` Make a CLI regression test in login.test.ts. ``` --- packages/drfed/src/login.test.ts | 134 +++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 packages/drfed/src/login.test.ts diff --git a/packages/drfed/src/login.test.ts b/packages/drfed/src/login.test.ts new file mode 100644 index 0000000..ce0c4af --- /dev/null +++ b/packages/drfed/src/login.test.ts @@ -0,0 +1,134 @@ +// DrFed: A web-based platform for developing and debugging ActivityPub apps +// Copyright (C) 2026 DrFed team +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { mkdtemp, rm } from "node:fs/promises"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import process from "node:process"; +import { it } from "node:test"; +import { fileURLToPath } from "node:url"; + +const serverTimeout = 30_000; +const requestTimeout = 5_000; +const binary = fileURLToPath( + new URL("../bin/drfed-server.mjs", import.meta.url), +); + +// oxlint-disable-next-line max-statements +it("normalizes CLI login origins and preserves the allowlist", async () => { + // The CLI requires a nonzero port; obtain an available one from the OS. + const socket = createServer(); + socket.listen(0, "127.0.0.1"); + await once(socket, "listening"); + const address = socket.address(); + assert.ok(address != null && typeof address !== "string"); + await socket[Symbol.asyncDispose](); + + const dataPath = await mkdtemp(join(tmpdir(), "drfed-login-test-")); + const child = spawn( + process.execPath, + [ + binary, + "--data-path", + dataPath, + `--listen=127.0.0.1:${address.port}`, + "--root-origin=https://drfed.example", + "--login-origin=https://app.example.", + "--login-origin=http://127.0.0.1:3000", + "--login-origin=http://[::1]:3000", + ], + { + env: { PATH: process.env.PATH ?? "" }, + timeout: serverTimeout, + killSignal: "SIGKILL", + stdio: ["ignore", "pipe", "pipe"], + }, + ); + const ready = Promise.withResolvers(); + const closed = Promise.withResolvers(); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => { + stdout += chunk.toString(); + if (stdout.includes("Listening on:")) ready.resolve(); + }); + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.once("error", ready.reject); + child.once("close", (code, signal) => { + ready.reject( + new Error(`CLI exited before listening (${code}, ${signal}): ${stderr}`), + ); + closed.resolve(); + }); + + try { + await ready.promise; + const endpoint = `http://127.0.0.1:${address.port}/graphql`; + await Promise.all( + [ + "https://app.example", + "http://127.0.0.1:3000", + "http://[::1]:3000", + "https://untrusted.example", + ].map(async (origin) => { + const response = await fetch(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + signal: AbortSignal.timeout(requestTimeout), + body: JSON.stringify({ + query: ` + mutation Login($email: Email!, $verifyUrl: URITemplate!) { + loginByEmail(email: $email, verifyUrl: $verifyUrl) { + challengeId + } + } + `, + variables: { + email: "unknown@example.com", + verifyUrl: `${origin}/verify?challengeId={challengeId}&code={code}`, + }, + }), + }); + assert.equal(response.status, 200); + const body = await response.json(); + if (origin === "https://untrusted.example") { + assert.equal(body.data, null); + assert.equal( + body.errors[0].message, + `Verify URL origin is not allowed: ${origin}.`, + ); + } else { + assert.equal( + body.errors, + undefined, + `${origin}: ${JSON.stringify(body)}`, + ); + assert.ok(body.data.loginByEmail.challengeId); + } + }), + ); + } finally { + child.kill("SIGTERM"); + await closed.promise; + await rm(dataPath, { force: true, recursive: true }); + } +});