From 5725d16903e294eecb094ab5f0ce850e6de97461 Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Sun, 20 Sep 2026 21:54:38 +0900 Subject: [PATCH 1/5] 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 8677f65d7bd50a5595a6286c5784346502f1cea8 Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Sun, 20 Sep 2026 22:25:46 +0900 Subject: [PATCH 2/5] 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 367d04a..cba6675 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 5b98d3d..2fa1bf6 100644 --- a/packages/graphql/src/builder.ts +++ b/packages/graphql/src/builder.ts @@ -61,9 +61,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 bce194d..d1023b2 100644 --- a/packages/graphql/src/federation.test.ts +++ b/packages/graphql/src/federation.test.ts @@ -156,11 +156,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 9786876..bfa98c1 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 4c91ba7eacf49daf4e8aa9c0bd31b2ca3a32539e Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Sun, 20 Sep 2026 22:26:54 +0900 Subject: [PATCH 3/5] 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 0082eeffdb0d28a724cd731f8d32f7b5a2700230 Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Sun, 20 Sep 2026 22:40:27 +0900 Subject: [PATCH 4/5] 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 0a58cafe404f8d09160887f46de8a6c62903fd46 Mon Sep 17 00:00:00 2001 From: "Kim, Hyeonseo" Date: Thu, 24 Sep 2026 12:07:04 +0900 Subject: [PATCH 5/5] 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";