diff --git a/packages/drfed/.env.example b/packages/drfed/.env.example
index 85abd2a..d99738f 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_ORIGINS=https://drfed.example.com,https://drfed.exeample2.com
DRFED_ROOT_ORIGIN=http://drfed.localhost:8888
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/packages/drfed/src/index.ts b/packages/drfed/src/index.ts
index c0c084b..6d6a572 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,7 +49,7 @@ 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, loginOrigins } = options;
const yogaServer = createYogaServer(options.drizzle.db, federation, {
rootOrigin,
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 });
+ }
+});
diff --git a/packages/drfed/src/parser.test.ts b/packages/drfed/src/parser.test.ts
index 8d7d64e..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,16 +123,20 @@ 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,
+ /Expected at least 1 values, but got only 0\./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/drfed/src/parser.ts b/packages/drfed/src/parser.ts
index 8ba2240..1b0ad73 100644
--- a/packages/drfed/src/parser.ts
+++ b/packages/drfed/src/parser.ts
@@ -19,10 +19,10 @@ 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, 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,20 @@ const emailFromParser = optional(
}),
);
+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", {
address: withDefault(
option("--listen", "-l", socketAddress({ requirePort: true }), {
@@ -146,6 +160,7 @@ const serverParser = object("DrFed server", {
}),
),
rootOrigin: rootOriginParser,
+ 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 b1c00b0..c737bfe 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 Set(["https://drfed.test"]),
"postmaster@mail.example",
);
});
@@ -513,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 Set([configuredOrigin]),
+ );
+ });
+ }
});
diff --git a/packages/graphql/src/builder.ts b/packages/graphql/src/builder.ts
index a3a33b3..5f79c46 100644
--- a/packages/graphql/src/builder.ts
+++ b/packages/graphql/src/builder.ts
@@ -62,7 +62,7 @@ export interface ServerContext {
readonly emailFrom: string;
/**
- * Origin list for login.
+ * Origins for login.
*/
readonly loginOrigins: ReadonlySet;
diff --git a/packages/graphql/src/federation.test.ts b/packages/graphql/src/federation.test.ts
index 440da65..1bc213c 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,
+ 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 6a68e94..b5768af 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"),
+ loginOrigins: ReadonlySet = new Set(["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,
+ loginOrigins,
emailFrom,
});
const fetch: TestFetch = yoga.fetch.bind(yoga);
diff --git a/packages/graphql/src/index.ts b/packages/graphql/src/index.ts
index f12885d..b380dac 100644
--- a/packages/graphql/src/index.ts
+++ b/packages/graphql/src/index.ts
@@ -46,7 +46,7 @@ export interface YogaServerOptions {
emailFrom?: string | undefined;
/**
- * Origin list for login.
+ * Origins for login.
*/
loginOrigins: ReadonlySet;
@@ -109,16 +109,26 @@ 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)}`,
- loginOrigins: opt.loginOrigins,
- rootOrigin: opt.rootOrigin,
-});
+): Omit => {
+ 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(),
+ // 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)}`,
+ loginOrigins,
+ rootOrigin: opt.rootOrigin,
+ };
+};
const getAccessToken = (headers: Headers) =>
/^Bearer (?[^\s]+)$/u.exec(headers.get("Authorization") ?? "")?.groups
diff --git a/scripts/dev.mts b/scripts/dev.mts
index f0301d7..bc12a7c 100644
--- a/scripts/dev.mts
+++ b/scripts/dev.mts
@@ -34,21 +34,21 @@ 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
// DNS or /etc/hosts setup, which is what makes per-instance subdomains usable
// in development.
const defaultRootOrigin = "http://drfed.localhost:8888";
+const defaultLoginOrigins = "http://localhost:3000";
const isWindows = process.platform === "win32";
const pnpm = isWindows ? "pnpm.cmd" : "pnpm";
@@ -317,13 +317,15 @@ 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}`,
+ ...(process.env.DRFED_LOGIN_ORIGINS ?? defaultLoginOrigins)
+ .split(",")
+ .map((value) => `--login-origin=${value.trim()}`),
];
const logLevel = process.env.usage_log_level;