diff --git a/.hongdown.toml b/.hongdown.toml index f9750aa..2d7757f 100644 --- a/.hongdown.toml +++ b/.hongdown.toml @@ -6,4 +6,5 @@ exclude = [ "AGENTS.md", "CLAUDE.md", "node_modules/", + "plans/", ] diff --git a/.oxfmtrc.json b/.oxfmtrc.json index fdaf92b..abb763b 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -5,6 +5,7 @@ "ignorePatterns": [ "*.md", "**/*.md", + "plans/", "**/dist/*.{cjs,d.cts,d.mts,d.ts,js,mjs}" ] } diff --git a/.oxlintrc.json b/.oxlintrc.json index 4a2b932..776479b 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -17,7 +17,7 @@ { "ignoreConsecutiveComments": true } ], "eslint/eqeqeq": ["off", "smart"], - "eslint/func-style": ["warn", "declaration"], + "eslint/func-style": ["off"], "eslint/id-length": ["warn", { "exceptionPatterns": ["^_", "^[Tertv]$"] }], "eslint/init-declarations": "off", "eslint/max-params": ["warn", { "max": 4 }], @@ -50,6 +50,7 @@ ], "import/no-namespace": "off", "import/no-nodejs-modules": "off", + "import/no-relative-parent-imports": "off", "import/prefer-default-export": "off", "jsdoc/require-param-type": "off", "jsdoc/require-returns-type": "off", diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 99e2f7d..7591fb2 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,3 @@ { - "recommendations": ["oxc.oxc-vscode"] + "recommendations": ["oxc.oxc-vscode", "hverlin.mise-vscode"] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 377d71c..a69d107 100755 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -9,6 +9,7 @@ "files.trimFinalNewlines": true, "oxc.enable.oxfmt": true, "prettier.enable": false, + "oxc.enable": true, "[css]": { "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true diff --git a/cspell.jsonc b/cspell.jsonc new file mode 100644 index 0000000..29e3c4c --- /dev/null +++ b/cspell.jsonc @@ -0,0 +1,18 @@ +{ + "words": [ + "drfed", + "fedify", + "HMAC", + "logtape", + "mailpit", + "metavar", + "optique", + "oxlint", + "pglite", + "psql", + "smtps", + "upyo", + "valueparser", + "varchar", + ], +} diff --git a/mise.toml b/mise.toml index c60217c..20a4474 100644 --- a/mise.toml +++ b/mise.toml @@ -1,6 +1,7 @@ min_version = "2026.6.10" [tools] +"aqua:axllent/mailpit" = "latest" "aqua:dahlia/hongdown" = "0.4.3" "github:nushell/nushell" = "latest" node = "26" @@ -108,6 +109,7 @@ let name = (if "usage_name" in $env { ["--name" $env.usage_name] } else { [] }) let custom = (if "usage_custom" in $env { ["--custom"] } else { [] }) pnpm exec drizzle-kit generate ...$name ...$custom """ +depends_post = ["fmt"] [tasks.dev] description = "Run the development server" diff --git a/packages/drfed/package.json b/packages/drfed/package.json index 31ff52a..f022e7c 100644 --- a/packages/drfed/package.json +++ b/packages/drfed/package.json @@ -74,6 +74,7 @@ "@optique/core": "^1.1.0", "@optique/logtape": "catalog:", "@optique/run": "^1.1.0", + "@upyo/smtp": "^0.5.0", "drizzle-orm": "catalog:", "pg": "catalog:", "srvx": "^0.11.16" diff --git a/packages/drfed/src/index.ts b/packages/drfed/src/index.ts index c40bc5d..ad66f79 100644 --- a/packages/drfed/src/index.ts +++ b/packages/drfed/src/index.ts @@ -27,6 +27,7 @@ import { serve } from "srvx"; import metadata from "../package.json" with { type: "json" }; import type { Options } from "./parser.ts"; import program from "./program.ts"; +import seedData from "./seed.ts"; // oxlint-disable-next-line max-lines-per-function export async function main(): Promise { @@ -69,7 +70,12 @@ export async function main(): Promise { if (options.drizzle.migrate) { await migrate({ credentials: options.drizzle.credentials }); } - const yogaServer = createYogaServer(options.drizzle.db); + if (options.seed) { + await seedData(options.drizzle.db); + } + const yogaServer = createYogaServer(options.drizzle.db, { + mailer: options.mailer, + }); const server = serve({ fetch: yogaServer.fetch.bind(yogaServer), hostname: options.address.host, @@ -77,6 +83,7 @@ export async function main(): Promise { port: options.address.port, }); function shutdown() { + options.mailer?.closeAllConnections(); // oxlint-disable-next-line promise/catch-or-return promise/prefer-await-to-then no-magic-numbers server.close().then(() => process.exit(0)); } diff --git a/packages/drfed/src/parser.ts b/packages/drfed/src/parser.ts index 0a28bfc..c2bf884 100644 --- a/packages/drfed/src/parser.ts +++ b/packages/drfed/src/parser.ts @@ -13,16 +13,19 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . + import { relations, schema } from "@drfed/models"; +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, withDefault } from "@optique/core/modifiers"; +import { map, optional, withDefault } from "@optique/core/modifiers"; import type { InferValue } from "@optique/core/parser"; -import { option } from "@optique/core/primitives"; +import { flag, option } from "@optique/core/primitives"; import { socketAddress, url } from "@optique/core/valueparser"; import { loggingOptions } from "@optique/logtape"; import { path } from "@optique/run/valueparser"; +import { SmtpTransport } from "@upyo/smtp"; import { drizzle as drizzlePostgres } from "drizzle-orm/node-postgres"; import { drizzle as drizzlePglite } from "drizzle-orm/pglite"; @@ -36,18 +39,21 @@ const pgliteParser = map( description: message`The path to the directory where the PGlite database files will be stored. Mutually exclusive with ${optionNames(["--postgres-url", "--database-url", "-D"])}.`, }, ), - (dbPath) => ({ - credentials: { - driver: "pglite" as const, - url: dbPath, - }, - db: drizzlePglite({ - connection: { dataDir: dbPath }, - relations, - schema, - logger: getLogger(), - }), - }), + (dbPath) => { + const client = new PGlite(dbPath); + return { + credentials: { + driver: "pglite" as const, + client, + }, + db: drizzlePglite({ + client, + relations, + schema, + logger: getLogger(), + }), + }; + }, ); const postgresParser = map( @@ -75,6 +81,24 @@ const postgresParser = map( }), ); +const smtpParser = map( + option("--smtp-url", "-s", url({ allowedProtocols: ["smtp:"] }), { + description: message`The URL of the SMTP server to deliver emails through.`, + }), + (smtpUrl) => + new SmtpTransport({ + host: smtpUrl.hostname, + port: smtpUrl.port === "" ? SMTP_DEFAULT_PORT : Number(smtpUrl.port), + // SMTPS for later + secure: false, + }), +); +const SMTP_DEFAULT_PORT = 25; + +const seedParser = flag("--seed", { + description: message`Seeding initial data for dev or test.`, +}); + export const parser = object({ logging: loggingOptions({ level: "option", short: "-L" }), address: withDefault( @@ -97,6 +121,8 @@ export const parser = object({ ), }), ), + mailer: optional(smtpParser), + seed: seedParser, }); export type Options = InferValue; diff --git a/packages/drfed/src/seed.ts b/packages/drfed/src/seed.ts new file mode 100644 index 0000000..c7d65af --- /dev/null +++ b/packages/drfed/src/seed.ts @@ -0,0 +1,54 @@ +// 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 { type Database, schema } from "@drfed/models"; + +export default async function seedData(db: Database): Promise { + await db.transaction(async (tx) => { + await seedAccounts(tx); + }); +} + +const accountId = "00000000-0000-4000-8000-000000000001"; +const memberId = "00000000-0000-4000-8000-000000000002"; +const pendingMemberId = "00000000-0000-4000-8000-000000000003"; +const created = new Date("2026-06-24T00:00:00.000Z"); + +async function seedAccounts(db: Database): Promise { + await db + .insert(schema.accounts) + .values([ + { + id: accountId, + email: "owner@example.com", + name: "Owner", + created, + }, + { + id: memberId, + email: "member@example.com", + name: "Member", + created, + }, + { + id: pendingMemberId, + email: "pending@example.com", + name: "Pending", + created, + }, + ]) + .onConflictDoNothing(); +} diff --git a/packages/graphql/package.json b/packages/graphql/package.json index 0ca4f60..6837d14 100644 --- a/packages/graphql/package.json +++ b/packages/graphql/package.json @@ -88,16 +88,20 @@ "@electric-sql/pglite": "catalog:", "@logtape/testing-node": "catalog:", "@types/node": "catalog:", + "@upyo/mock": "^0.5.0", "tsdown": "catalog:", "typescript": "catalog:" }, "dependencies": { "@drfed/models": "workspace:*", + "@fedify/uri-template": "^2.3.1", "@logtape/graphql-yoga": "catalog:", "@logtape/logtape": "catalog:", "@pothos/core": "^4.13.0", "@pothos/plugin-drizzle": "^0.17.4", + "@pothos/plugin-errors": "^4.9.1", "@pothos/plugin-relay": "^4.7.0", + "@upyo/core": "^0.5.0", "drizzle-orm": "catalog:", "graphql": "^16.14.2", "graphql-scalars": "^1.25.0", diff --git a/packages/graphql/src/auth.test.ts b/packages/graphql/src/auth.test.ts new file mode 100644 index 0000000..b4037aa --- /dev/null +++ b/packages/graphql/src/auth.test.ts @@ -0,0 +1,159 @@ +// 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 . + +// oxlint-disable max-lines-per-function max-statements no-magic-numbers + +import { deepEqual, equal, ok } from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { schema } from "@drfed/models"; + +import { withTestHarness } from "./harness.test.ts"; + +const okStatus = 200; +const accountId = "00000000-0000-4000-8000-000000000001"; +const email = "noreply@drfed.org"; +const verifyUrl = "https://drfed.org/transports/mock?token={token}&code={code}"; + +const loginMutation = ` + mutation Login($email: Email!, $verifyUrl: String) { + loginByEmail(email: $email, verifyUrl: $verifyUrl) { + ... on SendMail { + token + } + } + } +`; + +const completeLoginMutation = ` + mutation CompleteLogin($token: UUID!, $code: String!) { + completeLoginChallenge(token: $token, code: $code) { + id + accessToken + account { + uuid + email + } + } + } +`; + +const viewerQuery = ` + query Viewer { + viewer { + uuid + email + } + } +`; + +const revokeSessionMutation = ` + mutation RevokeSession($session: UUID!) { + revokeSession(session: $session) { + revoke + } + } +`; + +const loginUrlPattern = + /https:\/\/drfed\.org\/transports\/mock\?token=[0-9a-f-]+&code=[0-9a-z]+/u; + +describe("email authentication", () => { + it("logs in, authenticates the viewer, and revokes the session", async () => { + await withTestHarness(async ({ db, mailer, post }) => { + await db.insert(schema.accounts).values({ + id: accountId, + email, + name: "Login Test", + }); + + const loginResponse = await post({ + query: loginMutation, + variables: { email, verifyUrl }, + }); + equal(loginResponse.status, okStatus); + + const loginBody = await loginResponse.json(); + equal(loginBody.errors, undefined); + + const { token } = loginBody.data.loginByEmail; + equal(typeof token, "string"); + + const messages = mailer.getSentMessages(); + equal(messages.length, 1); + + const [message] = messages; + ok(message); + equal(typeof message.content.text, "string"); + + const urlMatch = message.content.text?.match(loginUrlPattern); + ok(urlMatch); + + const loginUrl = new URL(urlMatch[0]); + equal(loginUrl.origin, "https://drfed.org"); + equal(loginUrl.pathname, "/transports/mock"); + equal(loginUrl.searchParams.get("token"), token); + + const code = loginUrl.searchParams.get("code"); + ok(code); + + const completeResponse = await post({ + query: completeLoginMutation, + variables: { token, code }, + }); + equal(completeResponse.status, okStatus); + + const completeBody = await completeResponse.json(); + equal(completeBody.errors, undefined); + + const session = completeBody.data.completeLoginChallenge; + ok(session); + equal(session.account.uuid, accountId); + equal(session.account.email, email); + equal(typeof session.accessToken, "string"); + + const authorization = { + headers: { authorization: `Bearer ${session.accessToken}` }, + }; + const viewerResponse = await post({ query: viewerQuery }, authorization); + equal(viewerResponse.status, okStatus); + deepEqual(await viewerResponse.json(), { + data: { viewer: { uuid: accountId, email } }, + }); + + const revokeResponse = await post( + { + query: revokeSessionMutation, + variables: { session: session.id }, + }, + authorization, + ); + equal(revokeResponse.status, okStatus); + deepEqual(await revokeResponse.json(), { + data: { revokeSession: { revoke: true } }, + }); + + const revokedViewerResponse = await post( + { query: viewerQuery }, + authorization, + ); + equal(revokedViewerResponse.status, okStatus); + deepEqual(await revokedViewerResponse.json(), { + data: { viewer: null }, + }); + }); + }); +}); diff --git a/packages/graphql/src/auth/challenge.ts b/packages/graphql/src/auth/challenge.ts new file mode 100644 index 0000000..b953dbd --- /dev/null +++ b/packages/graphql/src/auth/challenge.ts @@ -0,0 +1,119 @@ +// 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 . + +// oxlint-disable no-magic-numbers + +import type { Database } from "@drfed/models"; +import { loginTokens, sessions } from "@drfed/models/schema"; +import { and, eq, gt, isNull } from "drizzle-orm/sql/expressions"; + +import builder, { type UserContext } from "../builder.ts"; +import { LoginChallengeError } from "./errors.ts"; +import { equalSecretHashes, generateAccessToken, hashSecret } from "./hash.ts"; + +const SessionRef = builder.drizzleObject("sessions", { + name: "Session", + fields: (t) => ({ + id: t.expose("id", { type: "UUID" }), + accessToken: t.string({ + nullable: true, + resolve(session) { + const accessToken = + "accessToken" in session ? session.accessToken : null; + return typeof accessToken === "string" ? accessToken : null; + }, + }), + account: t.relation("account"), + created: t.expose("created", { type: "DateTime" }), + expires: t.expose("expires", { type: "DateTime" }), + }), +}); + +builder.mutationFields((t) => ({ + completeLoginChallenge: t.drizzleField({ + type: SessionRef, + nullable: true, + description: "Complete login challenge.", + args: { + token: t.arg({ type: "UUID", required: true }), + code: t.arg({ type: "String", required: true }), + }, + async resolve(query, _root, { token, code }, ctx) { + try { + const tokenHash = await hashSecret(token.toLowerCase()); + const now = new Date(Temporal.Now.instant().toString()); + const row = await findToken(tokenHash, now, ctx); + const id = crypto.randomUUID(); + const accessToken = generateAccessToken(); + const accessHash = await hashSecret(accessToken); + + await verifyCode(code, row.codeHash); + await ctx.db.transaction(async (tx) => { + await consumeToken(row.id, now, tx); + await insertSession(id, row.accountId, accessHash, tx); + }); + + const session = await ctx.db.query.sessions.findFirst( + query({ where: { id } }), + ); + return session == null ? null : { ...session, accessToken }; + } catch (error) { + if (error instanceof LoginChallengeError) { + return null; + } + throw error; + } + }, + }), +})); + +const findToken = async (tokenHash: string, now: Date, ctx: UserContext) => + (await ctx.db.query.loginTokens.findFirst({ + where: { tokenHash, expires: { gt: now }, consumed: { isNull: true } }, + })) ?? + new LoginChallengeError( + `Can't find an alive login token: ${tokenHash}.`, + ).throw(); + +const verifyCode = async (userCode: string, dbCodeHash: string) => + (await equalSecretHashes( + await hashSecret(userCode.toLowerCase()), + dbCodeHash, + )) || + new LoginChallengeError(`The user's code and DB's one don't match`).throw(); + +const consumeToken = async (tokenId: string, now: Date, tx: Database) => + ( + await tx + .update(loginTokens) + .set({ consumed: now }) + .where( + and( + eq(loginTokens.id, tokenId), + isNull(loginTokens.consumed), + gt(loginTokens.expires, now), + ), + ) + .returning({ consumed: loginTokens.consumed }) + )[0]?.consumed ?? + new LoginChallengeError("Updating `consumed` failed.").throw(); + +const insertSession = async ( + id: string, + accountId: string, + tokenHash: string, + tx: Database, +) => await tx.insert(sessions).values({ id, accountId, tokenHash }); diff --git a/packages/graphql/src/auth/entry.ts b/packages/graphql/src/auth/entry.ts new file mode 100644 index 0000000..d739369 --- /dev/null +++ b/packages/graphql/src/auth/entry.ts @@ -0,0 +1,35 @@ +// 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 . + +// oxlint-disable import/no-unassigned-import +import "./magic-link.ts"; +import "./challenge.ts"; +import "./revoke.ts"; +import builder from "../builder.ts"; + +builder.queryFields((t) => ({ + viewer: t.drizzleField({ + type: "accounts", + nullable: true, + description: "`Account` if authorized, else `null`", + resolve: (query, _root, _args, ctx) => + ctx.session == null + ? null + : ctx.db.query.accounts.findFirst( + query({ where: { id: ctx.session.accountId } }), + ), + }), +})); diff --git a/packages/graphql/src/auth/errors.ts b/packages/graphql/src/auth/errors.ts new file mode 100644 index 0000000..da97b01 --- /dev/null +++ b/packages/graphql/src/auth/errors.ts @@ -0,0 +1,43 @@ +// 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 . +// oxlint-disable func-names func-style id-length + +// oxlint-disable max-classes-per-file no-throw-literal +export class Throwable extends Error { + throw(): never { + throw this; + } +} + +export class LoginChallengeError extends Throwable { + constructor(message: string) { + super("Error while login challenge."); + this.name = "LoginChallengeError"; + this.message = message; + } +} + +export class VerifyUrlExpandingError extends Throwable { + constructor(message: string) { + super("Error while expand verify URL."); + this.name = "VerifyUrlExpandingError"; + this.message = message; + } +} + +export function throwError(error: Error): never { + throw error; +} diff --git a/packages/graphql/src/auth/expand.ts b/packages/graphql/src/auth/expand.ts new file mode 100644 index 0000000..fbaa281 --- /dev/null +++ b/packages/graphql/src/auth/expand.ts @@ -0,0 +1,75 @@ +// 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 { Template } from "@fedify/uri-template"; +import { getLogger } from "@logtape/logtape"; + +import { VerifyUrlExpandingError } from "./errors.ts"; + +export interface ExpandVerifyUrlParams { + template: string | undefined | null; + token: `${string}-${string}-${string}-${string}-${string}`; + code: string; + origins: ReadonlySet; +} + +export default function expandVerifyUrl({ + template, + token, + code, + origins, +}: ExpandVerifyUrlParams): string | null { + // Showing the errors occurring here to the user poses a security threat, + // so they are handled as `null` and then processed as a fallback in + // `expandVerifyUrl`. + try { + if (template == null) { + return null; + } + const url = expandUrl(template, token, code); + assertAllow(url, origins); + return url.toString(); + } catch (error) { + // Almost of errors that can occur in this code could be + logger.warn(`Error while expand verify URL: {error}`, { error }); + return null; + } +} + +function expandUrl(template: string, token: string, code: string) { + const expanded = Template.parse(template).expand({ token, code }); + if (!(expanded.includes(token) && expanded.includes(code))) { + throw new VerifyUrlExpandingError( + // oxlint-disable-next-line prefer-template + "`template` doesn't seem have `token` or `code` variables. `template:`" + + template, + ); + } + const url = URL.canParse(expanded) + ? new URL(expanded) + : new VerifyUrlExpandingError( + `Unsupported verify URL scheme: ${template}.`, + ).throw(); + return url; +} + +const assertAllow = (url: URL, origins: ReadonlySet) => + !origins.has(url.origin) && + new VerifyUrlExpandingError( + `Origin not allowed for verify URL: ${url.origin}.`, + ).throw(); + +const logger = getLogger(["drfed", "graphql", "expand"]); diff --git a/packages/graphql/src/auth/hash.ts b/packages/graphql/src/auth/hash.ts new file mode 100644 index 0000000..eff57d8 --- /dev/null +++ b/packages/graphql/src/auth/hash.ts @@ -0,0 +1,64 @@ +// 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 . +// oxlint-disable func-names func-style id-length + +// oxlint-disable no-magic-numbers +const BASE36_CHAR = "0123456789abcdefghijklmnopqrstuvwxyz"; + +// It's not strictly uniform random sampling, but it's negligible enough. +export const generateBase36Code = (len: number): string => + Array.from( + // Choose `Uint16Array` cause `2 ** 16 % 36 = 16` + // It's greater than `2 ** 8 % 36 = 2 ** 32 % 36 = 4`. + crypto.getRandomValues(new Uint16Array(len)), + (num) => BASE36_CHAR[num % BASE36_CHAR.length], + ).join(""); + +export const hashSecret = async (raw: string): Promise => + new Uint8Array( + await crypto.subtle.digest("SHA-256", textEncoder.encode(raw)), + ).toHex(); + +const textEncoder = new TextEncoder(); + +export async function equalSecretHashes( + leftHash: string, + rightHash: string, +): Promise { + const macs = (await Promise.all([leftHash, rightHash].map(getHmac))).map( + (buf) => new Uint8Array(buf), + ); + + // oxlint-disable-next-line no-bitwise id-length + const difference = macs[0]!.reduce((p, c, i) => p | (c ^ macs[1]![i]!), 0); + + return difference === 0; +} +const compareKey = await crypto.subtle.generateKey( + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], +); +const getHmac = (hash: string) => + crypto.subtle.sign("HMAC", compareKey, textEncoder.encode(hash)); + +const ACCESS_TOKEN_BYTES = 32; + +export const generateAccessToken = (): string => + crypto.getRandomValues(new Uint8Array(ACCESS_TOKEN_BYTES)).toBase64({ + alphabet: "base64url", + omitPadding: true, + }); diff --git a/packages/graphql/src/auth/magic-link.ts b/packages/graphql/src/auth/magic-link.ts new file mode 100644 index 0000000..ea60909 --- /dev/null +++ b/packages/graphql/src/auth/magic-link.ts @@ -0,0 +1,98 @@ +// 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 . + +// oxlint-disable no-magic-numbers + +import { schema } from "@drfed/models"; + +import builder, { type UserContext } from "../builder.ts"; +import { generateBase36Code, hashSecret } from "./hash.ts"; +import { logReceipt, sendMail } from "./mail.ts"; + +interface SendMailShape { + readonly token: string; +} + +const SendMailRef = builder.objectRef("SendMail").implement({ + description: "The mail sent.", + fields: (t) => ({ + token: t.expose("token", { + type: "UUID", + description: "Token for login.", + }), + }), +}); + +builder.mutationFields((t) => ({ + loginByEmail: t.field({ + type: SendMailRef, + description: "Send a magic link to email. Always returns `token: UUID`.", + args: { + email: t.arg({ + type: "Email", + required: true, + description: "The email address of the `Account`.", + }), + verifyUrl: t.arg({ + type: "String", + required: false, + description: + "The URL's origin must be in the server's allowlist. " + + "When omitted, the email carries the raw token and code.", + }), + }, + async resolve(_root, { email, verifyUrl }, ctx) { + const token = crypto.randomUUID(); + const account = await findAccount(email, ctx); + if (account == null) { + // Returned token even if the account does not exist to prevent account + // enumeration. Consider mailing in a worker to prevent timing attacks. + return { token }; + } + const verifier = { + token, + template: verifyUrl, + code: generateBase36Code(CODE_LEN), + }; + + await insertToken(account.id, verifier, ctx); + // Do not check the email was sent. Instructs users to request a resend if + // the email does not arrive after a few minutes at the web page. + logReceipt(await sendMail(account.email, verifier, ctx)); + + return { token }; + }, + }), +})); + +const CODE_LEN = 6; + +const findAccount = async (email: string, ctx: UserContext) => + await ctx.db.query.accounts.findFirst({ + where: { email }, + }); + +const insertToken = async ( + accountId: string, + { token, code }: { token: string; code: string }, + ctx: UserContext, +) => + await ctx.db.insert(schema.loginTokens).values({ + id: crypto.randomUUID(), + accountId, + tokenHash: await hashSecret(token), + codeHash: await hashSecret(code), + }); diff --git a/packages/graphql/src/auth/mail.ts b/packages/graphql/src/auth/mail.ts new file mode 100644 index 0000000..bde71a8 --- /dev/null +++ b/packages/graphql/src/auth/mail.ts @@ -0,0 +1,57 @@ +// 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 { getLogger } from "@logtape/logtape"; +import { type MessageContent, type Receipt, createMessage } from "@upyo/core"; + +import type { UserContext } from "../builder.ts"; +import expandVerifyUrl, { type ExpandVerifyUrlParams } from "./expand.ts"; + +export const sendMail = async ( + to: string, + verifier: Omit, + ctx: UserContext, +) => + await ctx.mailer.send( + createMessage({ + from: ctx.emailFrom, + to, + subject: "Sign in to DrFed", + content: renderLoginEmail({ ...verifier, origins: ctx.origins }), + }), + ); + +const renderLoginEmail = (verifier: ExpandVerifyUrlParams): MessageContent => ({ + text: `Hello, Welcome to DrFed! If you request to login to DrFed, please ${ + // Prevent over 80. + linkOrRaw(expandVerifyUrl(verifier), verifier) + }. Otherwise, please just ignore this mail.`, +}); + +const linkOrRaw = ( + link: string | null, + { token, code }: ExpandVerifyUrlParams, +) => (link == null ? `use token: ${token} and code: ${code}` : `open ${link}`); + +export function logReceipt(receipt: Receipt): void { + if (receipt.successful) { + logger.debug(`${receipt.messageId} sent successfully.`); + return; + } + logger.warn(`Sending a mail failed: ${receipt.errorMessages.join("\n")}`); +} + +const logger = getLogger(["drfed", "graphql", "auth"]); diff --git a/packages/graphql/src/auth/revoke.ts b/packages/graphql/src/auth/revoke.ts new file mode 100644 index 0000000..362a2f9 --- /dev/null +++ b/packages/graphql/src/auth/revoke.ts @@ -0,0 +1,66 @@ +// 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 . + +// oxlint-disable no-magic-numbers + +import { sessions } from "@drfed/models/schema"; +import { and, eq } from "drizzle-orm/sql/expressions"; + +import builder, { type UserContext } from "../builder.ts"; + +builder.mutationFields((t) => ({ + revokeSession: t.field({ + type: LogoutSuccessRef, + description: "Revokes a session. Return always `revoke: true`.", + args: { + session: t.arg({ + type: "UUID", + required: true, + description: "The session ID to revoke.", + }), + }, + async resolve(_query, { session }, ctx) { + if (ctx.session != null) { + await deleteSession(session, ctx); + } + // Return always true to prevent brute-force attack. + return { revoke: true }; + }, + }), +})); + +interface LogoutSuccessShape { + revoke: boolean; +} + +const LogoutSuccessRef = builder + .objectRef("LogoutSuccess") + .implement({ + description: "The session revoked.", + fields: (t) => ({ + revoke: t.expose("revoke", { + type: "Boolean", + description: "Revoking status.", + }), + }), + }); + +const deleteSession = (id: string, ctx: UserContext) => + ctx.db + .delete(sessions) + .where( + and(eq(sessions.id, id), eq(sessions.accountId, ctx.session!.accountId)), + ); diff --git a/packages/graphql/src/builder.ts b/packages/graphql/src/builder.ts index 029f2e6..8ada1cd 100644 --- a/packages/graphql/src/builder.ts +++ b/packages/graphql/src/builder.ts @@ -13,10 +13,14 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . + import { type Database, normalizeEmail, relations } from "@drfed/models"; +import type { Account, Session } from "@drfed/models/schema"; import SchemaBuilder, { type ObjectRef } from "@pothos/core"; import DrizzlePlugin from "@pothos/plugin-drizzle"; +import ErrorsPlugin from "@pothos/plugin-errors"; import RelayPlugin from "@pothos/plugin-relay"; +import type { Transport } from "@upyo/core"; import { getTableConfig } from "drizzle-orm/pg-core"; import { DateTimeResolver, UUIDResolver } from "graphql-scalars"; @@ -35,13 +39,38 @@ export interface ServerContext { * The database instance. */ readonly db: Database; + + /** + * Email sending services. + */ + readonly mailer: Transport; + + /** + * Email address to send. + */ + readonly emailFrom: string; + + /** + * Origin list. + */ + readonly origins: ReadonlySet; } /** * The user-related context data for the GraphQL server, which include every * field from the {@link ServerContext}. */ -export interface UserContext extends ServerContext {} +export interface UserContext extends ServerContext { + /** + * Session information. + */ + readonly session?: Session; + + /** + * Current viewer. + */ + readonly account?: Account; +} export interface SchemaTypes { Context: UserContext; @@ -84,7 +113,8 @@ export const builder = new SchemaBuilder({ getTableConfig, relations, }, - plugins: [DrizzlePlugin, RelayPlugin], + plugins: [DrizzlePlugin, RelayPlugin, ErrorsPlugin], + errors: { defaultTypes: [] }, }); builder.addScalarType("DateTime", DateTimeResolver); diff --git a/packages/graphql/src/harness.test.ts b/packages/graphql/src/harness.test.ts index f679fca..31a5bfb 100644 --- a/packages/graphql/src/harness.test.ts +++ b/packages/graphql/src/harness.test.ts @@ -17,6 +17,7 @@ import { createYogaServer } from "@drfed/graphql"; import type { ServerContext, UserContext } from "@drfed/graphql/builder"; import { type Database, migrate, relations, schema } from "@drfed/models"; import { PGlite } from "@electric-sql/pglite"; +import { MockTransport } from "@upyo/mock"; import { drizzle } from "drizzle-orm/pglite"; import type { YogaServerInstance } from "graphql-yoga"; @@ -56,6 +57,11 @@ export interface TestHarness { */ readonly db: Database; + /** + * The temporary mailer. + */ + readonly mailer: MockTransport; + /** * The test server's `fetch()` function, bound to the Yoga server instance. */ @@ -152,11 +158,13 @@ export async function withTestHarness( callback: (harness: TestHarness) => Promise | T, ): Promise> { return await withTemporaryDatabase(async (db) => { - const yoga = createYogaServer(db); + const mailer = new MockTransport(); + const yoga = createYogaServer(db, { mailer }); const fetch: TestFetch = yoga.fetch.bind(yoga); const harness: TestHarness = { db, + mailer, fetch, yoga, post(body, init) { diff --git a/packages/graphql/src/index.ts b/packages/graphql/src/index.ts index ea95121..115ec9a 100644 --- a/packages/graphql/src/index.ts +++ b/packages/graphql/src/index.ts @@ -13,33 +13,99 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . + import type { Database } from "@drfed/models"; import { getYogaLogger } from "@logtape/graphql-yoga"; +import { getLogger } from "@logtape/logtape"; +import type { Transport } from "@upyo/core"; +import { MockTransport } from "@upyo/mock"; import { type YogaServerInstance, createYoga, useExecutionCancellation, } from "graphql-yoga"; +import { hashSecret } from "./auth/hash.ts"; import type { ServerContext, UserContext } from "./builder.ts"; import { schema } from "./schema.ts"; +/** + * Options for Yoga server. + */ +export interface YogaServerOptions { + /** + * Email sending services. + */ + mailer?: Transport | undefined; + + /** + * Email address to send. + */ + emailFrom?: string; + + /** + * Origin list. + */ + origins?: ReadonlySet; +} + /** * Creates a Yoga server instance with the provided schema and context. - * @param db The database instance. + * @param {Database} db The database instance. + * @param {YogaServerOptions} _options Options for server. * @returns A `YogaServerInstance` configured with the schema and context for * handling GraphQL requests. */ export function createYogaServer( db: Database, + _options: YogaServerOptions = {}, ): YogaServerInstance { + const options = fillOptions(_options); return createYoga({ - // oxlint-disable-next-line require-await async context(ctx) { - return { db, request: ctx.request }; + const anonymous = { db, request: ctx.request, ...options }; + const accessToken = getAccessToken(ctx.request.headers); + if (accessToken == null) { + return anonymous; + } + const authenticated = await findSession(accessToken, db); + if (authenticated == null) { + return anonymous; + } + const { account, ...session } = authenticated; + return { ...anonymous, session, account }; }, plugins: [useExecutionCancellation()], schema, logging: getYogaLogger(), }); } + +function mockTransport() { + logger.warn( + "Without `mailer` option, use default `MockTransport` as mailer. " + + "With default `MockTransport`, you can't read any mails.", + ); + return new MockTransport(); +} + +const fillOptions = (opt: YogaServerOptions): Required => ({ + mailer: opt?.mailer ?? mockTransport(), + emailFrom: opt?.emailFrom ?? "noreply@drfed.org", + origins: opt?.origins ?? new Set(["https://drfed.org"]), +}); + +const getAccessToken = (headers: Headers) => + /^Bearer (?[^\s]+)$/u.exec(headers.get("Authorization") ?? "")?.groups + ?.token ?? null; + +const findSession = async (accessToken: string, db: Database) => + await db.query.sessions.findFirst({ + where: { + tokenHash: await hashSecret(accessToken), + expires: { gt: new Date(Temporal.Now.instant().toString()) }, + }, + with: { account: true }, + }); + +const logger = getLogger(["drfed", "graphql", "yoga"]); diff --git a/packages/graphql/src/schema.ts b/packages/graphql/src/schema.ts index e612e9f..84bfa4f 100644 --- a/packages/graphql/src/schema.ts +++ b/packages/graphql/src/schema.ts @@ -16,9 +16,10 @@ // oxlint-disable import/no-unassigned-import import "./account.ts"; import "./instance.ts"; +import "./auth/entry.ts"; import builder from "./builder.ts"; builder.queryType({}); -// Builder.mutationType({}); +builder.mutationType({}); export const schema = builder.toSchema(); diff --git a/packages/models/drizzle/20260716000333_email_auth/migration.sql b/packages/models/drizzle/20260716000333_email_auth/migration.sql new file mode 100644 index 0000000..b249846 --- /dev/null +++ b/packages/models/drizzle/20260716000333_email_auth/migration.sql @@ -0,0 +1,20 @@ +CREATE TABLE "login_tokens" ( + "id" uuid PRIMARY KEY, + "accountId" uuid NOT NULL, + "tokenHash" varchar(64) NOT NULL UNIQUE, + "codeHash" varchar(64) NOT NULL, + "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + "expires" timestamp with time zone DEFAULT CURRENT_TIMESTAMP + INTERVAL '15 minutes' NOT NULL, + "consumed" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE "sessions" ( + "id" uuid PRIMARY KEY, + "accountId" uuid NOT NULL, + "tokenHash" varchar(64) NOT NULL UNIQUE, + "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + "expires" timestamp with time zone DEFAULT CURRENT_TIMESTAMP + INTERVAL '1 month' NOT NULL +); +--> statement-breakpoint +ALTER TABLE "login_tokens" ADD CONSTRAINT "login_tokens_accountId_accounts_id_fkey" FOREIGN KEY ("accountId") REFERENCES "accounts"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_accountId_accounts_id_fkey" FOREIGN KEY ("accountId") REFERENCES "accounts"("id") ON DELETE CASCADE; \ No newline at end of file diff --git a/packages/models/drizzle/20260716000333_email_auth/snapshot.json b/packages/models/drizzle/20260716000333_email_auth/snapshot.json new file mode 100644 index 0000000..f3e0982 --- /dev/null +++ b/packages/models/drizzle/20260716000333_email_auth/snapshot.json @@ -0,0 +1,575 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "3cf3ea04-80a5-4f1b-80a7-9b1ced252582", + "prevIds": ["15845826-f718-42c3-85f2-fed368f261f4"], + "ddl": [ + { + "isRlsEnabled": false, + "name": "accounts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "instance_members", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "instances", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "login_tokens", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "sessions", + "entityType": "tables", + "schema": "public" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "email", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "admin", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "accounts" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accountId", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "instanceId", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "admin", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accepted", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "instance_members" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "varchar(100)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "instances" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "login_tokens" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accountId", + "entityType": "columns", + "schema": "public", + "table": "login_tokens" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "login_tokens" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "codeHash", + "entityType": "columns", + "schema": "public", + "table": "login_tokens" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "login_tokens" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP + INTERVAL '15 minutes'", + "generated": null, + "identity": null, + "name": "expires", + "entityType": "columns", + "schema": "public", + "table": "login_tokens" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "consumed", + "entityType": "columns", + "schema": "public", + "table": "login_tokens" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "uuid", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "accountId", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tokenHash", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP", + "generated": null, + "identity": null, + "name": "created", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "type": "timestamp with time zone", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "CURRENT_TIMESTAMP + INTERVAL '1 month'", + "generated": null, + "identity": null, + "name": "expires", + "entityType": "columns", + "schema": "public", + "table": "sessions" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "accountId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"accepted\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "instance_members_accountId_index", + "entityType": "indexes", + "schema": "public", + "table": "instance_members" + }, + { + "nameExplicit": false, + "columns": [ + { + "value": "instanceId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": "\"accepted\" IS NOT NULL", + "with": "", + "method": "btree", + "concurrently": false, + "name": "instance_members_instanceId_index", + "entityType": "indexes", + "schema": "public", + "table": "instance_members" + }, + { + "nameExplicit": false, + "columns": ["accountId"], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "instance_members_accountId_accounts_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "instance_members" + }, + { + "nameExplicit": false, + "columns": ["instanceId"], + "schemaTo": "public", + "tableTo": "instances", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "NO ACTION", + "name": "instance_members_instanceId_instances_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "instance_members" + }, + { + "nameExplicit": false, + "columns": ["accountId"], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "login_tokens_accountId_accounts_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "login_tokens" + }, + { + "nameExplicit": false, + "columns": ["accountId"], + "schemaTo": "public", + "tableTo": "accounts", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "sessions_accountId_accounts_id_fkey", + "entityType": "fks", + "schema": "public", + "table": "sessions" + }, + { + "columns": ["instanceId", "accountId"], + "nameExplicit": false, + "name": "instance_members_pkey", + "entityType": "pks", + "schema": "public", + "table": "instance_members" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "accounts_pkey", + "schema": "public", + "table": "accounts", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "instances_pkey", + "schema": "public", + "table": "instances", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "login_tokens_pkey", + "schema": "public", + "table": "login_tokens", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "sessions_pkey", + "schema": "public", + "table": "sessions", + "entityType": "pks" + }, + { + "nameExplicit": false, + "columns": ["email"], + "nullsNotDistinct": false, + "name": "accounts_email_key", + "schema": "public", + "table": "accounts", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["slug"], + "nullsNotDistinct": false, + "name": "instances_slug_key", + "schema": "public", + "table": "instances", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["tokenHash"], + "nullsNotDistinct": false, + "name": "login_tokens_tokenHash_key", + "schema": "public", + "table": "login_tokens", + "entityType": "uniques" + }, + { + "nameExplicit": false, + "columns": ["tokenHash"], + "nullsNotDistinct": false, + "name": "sessions_tokenHash_key", + "schema": "public", + "table": "sessions", + "entityType": "uniques" + }, + { + "value": "\"email\" ~ '^[^@]+@[^@]+\\.[^@]+$'", + "name": "accounts_email_check", + "entityType": "checks", + "schema": "public", + "table": "accounts" + }, + { + "value": "trim(both from \"name\") <> ''", + "name": "accounts_name_check", + "entityType": "checks", + "schema": "public", + "table": "accounts" + }, + { + "value": "\"slug\" ~ '^[a-z0-9-]{4,100}$'", + "name": "instances_slug_check", + "entityType": "checks", + "schema": "public", + "table": "instances" + }, + { + "value": "\"expires\" < (\"created\" + INTERVAL '1 year')", + "name": "instances_expires_check", + "entityType": "checks", + "schema": "public", + "table": "instances" + } + ], + "renames": [] +} diff --git a/packages/models/src/relations.ts b/packages/models/src/relations.ts index f730edd..c1848df 100644 --- a/packages/models/src/relations.ts +++ b/packages/models/src/relations.ts @@ -17,6 +17,7 @@ import { defineRelations } from "drizzle-orm"; import * as schema from "./schema.ts"; +// oxlint-disable-next-line eslint/max-lines-per-function export const relations = defineRelations(schema, (r) => ({ accounts: { instances: r.many.instances({ @@ -35,6 +36,14 @@ export const relations = defineRelations(schema, (r) => ({ accepted: { isNotNull: true }, }, }), + sessions: r.many.sessions({ + from: r.accounts.id, + to: r.sessions.accountId, + }), + loginTokens: r.many.loginTokens({ + from: r.accounts.id, + to: r.loginTokens.accountId, + }), }, instanceMembers: { account: r.one.accounts({ @@ -64,6 +73,20 @@ export const relations = defineRelations(schema, (r) => ({ }, }), }, + sessions: { + account: r.one.accounts({ + from: r.sessions.accountId, + to: r.accounts.id, + optional: false, + }), + }, + loginTokens: { + account: r.one.accounts({ + from: r.loginTokens.accountId, + to: r.accounts.id, + optional: false, + }), + }, })); export default relations; diff --git a/packages/models/src/schema.ts b/packages/models/src/schema.ts index 38ba83c..d2ba8af 100644 --- a/packages/models/src/schema.ts +++ b/packages/models/src/schema.ts @@ -109,3 +109,49 @@ export const instanceMembers = pgTable( export type InstanceMember = typeof instanceMembers.$inferSelect; export type NewInstanceMember = typeof instanceMembers.$inferInsert; + +/** + * Tokens for email login. `tokenHash` and `codeHash` store SHA-256 hex digests, + * not the raw secrets. The `tokenHash` field is used for lookup and + * the `codeHash` field is the hash of the raw code that is sent to the user's + * email. + */ +export const loginTokens = pgTable("login_tokens", { + id: uuid().primaryKey(), + accountId: uuid() + .notNull() + .references(() => accounts.id, { onDelete: "cascade" }), + tokenHash: varchar({ length: 64 }).notNull().unique(), + codeHash: varchar({ length: 64 }).notNull(), + created: timestamp({ withTimezone: true }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + expires: timestamp({ withTimezone: true }) + .notNull() + .default(sql`CURRENT_TIMESTAMP + INTERVAL '15 minutes'`), + consumed: timestamp({ withTimezone: true }), +}); + +export type LoginToken = typeof loginTokens.$inferSelect; +export type NewLoginToken = typeof loginTokens.$inferInsert; + +/** + * Authenticated sessions. The `id` field is used to revoke a session, and + * the `tokenHash` is the hash of the bearer access token. + */ +export const sessions = pgTable("sessions", { + id: uuid().primaryKey(), + accountId: uuid() + .notNull() + .references(() => accounts.id, { onDelete: "cascade" }), + tokenHash: varchar({ length: 64 }).notNull().unique(), + created: timestamp({ withTimezone: true }) + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + expires: timestamp({ withTimezone: true }) + .notNull() + .default(sql`CURRENT_TIMESTAMP + INTERVAL '1 month'`), +}); + +export type Session = typeof sessions.$inferSelect; +export type NewSession = typeof sessions.$inferInsert; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39919fd..f1c944a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: '@optique/run': specifier: ^1.1.0 version: 1.1.0 + '@upyo/smtp': + specifier: ^0.5.0 + version: 0.5.0(@upyo/core@0.5.0) drizzle-orm: specifier: 'catalog:' version: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@types/pg@8.20.0)(pg@8.21.0) @@ -102,6 +105,9 @@ importers: '@drfed/models': specifier: workspace:* version: link:../models + '@fedify/uri-template': + specifier: ^2.3.1 + version: 2.3.1 '@logtape/graphql-yoga': specifier: 'catalog:' version: 2.3.0-dev.840(@logtape/logtape@2.3.0-dev.840)(graphql-yoga@5.21.2(graphql@16.14.2))(graphql@16.14.2) @@ -114,9 +120,15 @@ importers: '@pothos/plugin-drizzle': specifier: ^0.17.4 version: 0.17.4(@pothos/core@4.13.0(graphql@16.14.2))(drizzle-orm@1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@types/pg@8.20.0)(pg@8.21.0))(graphql@16.14.2) + '@pothos/plugin-errors': + specifier: ^4.9.1 + version: 4.9.1(@pothos/core@4.13.0(graphql@16.14.2))(graphql@16.14.2) '@pothos/plugin-relay': specifier: ^4.7.0 version: 4.7.0(@pothos/core@4.13.0(graphql@16.14.2))(graphql@16.14.2) + '@upyo/core': + specifier: ^0.5.0 + version: 0.5.0 drizzle-orm: specifier: 'catalog:' version: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@types/pg@8.20.0)(pg@8.21.0) @@ -139,6 +151,9 @@ importers: '@types/node': specifier: 'catalog:' version: 26.0.0 + '@upyo/mock': + specifier: ^0.5.0 + version: 0.5.0(@upyo/core@0.5.0) tsdown: specifier: 'catalog:' version: 0.22.3(typescript@6.0.3) @@ -749,6 +764,10 @@ packages: '@fastify/busboy@3.2.0': resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} + '@fedify/uri-template@2.3.1': + resolution: {integrity: sha512-322xch1WhasP/vjH4yDPA1RnYwI6tlG72aH7fCpdFge8IC845urKEMET9LzJ2G5HfeCKAXPAcaZZEx9FXnTNrg==} + engines: {bun: '>=1.1.0', deno: '>=2.0.0', node: '>=22.0.0'} + '@graphql-tools/executor@1.5.3': resolution: {integrity: sha512-mgBFC0bsrZPZLu9EnydpMnAuQ8Iiq0CEbUcsmvXsm2/iYektGHDN/+bmb7hicA6dWZtdPfklYJmr21WD0GnOfA==} engines: {node: '>=16.0.0'} @@ -1042,6 +1061,12 @@ packages: drizzle-orm: '>=1.0.0-beta.2' graphql: ^16.10.0 + '@pothos/plugin-errors@4.9.1': + resolution: {integrity: sha512-nD8ZUKWlu2yzma28zQIrcs0ihfPmx/unXwmAKt+irWWzl6+/s2tkD+qO6wn4aH8swcxKIDibNa3HSTecCguWHw==} + peerDependencies: + '@pothos/core': '*' + graphql: ^16.10.0 || ^17.0.0 + '@pothos/plugin-relay@4.7.0': resolution: {integrity: sha512-IQ7f7WLu7uXxiZBcJgUTdspjIVzX3bgvd8XjnzxGJUbn/uE5HlVyCFmvhJffHIIS2OKSHhOOJRMwIwVwauwnSg==} peerDependencies: @@ -1517,6 +1542,22 @@ packages: '@ungap/structured-clone@1.3.2': resolution: {integrity: sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==} + '@upyo/core@0.5.0': + resolution: {integrity: sha512-i7uwJtiQiUQ4cYx2BGy4hA3iM9Fh7XHEb6s2BxBDbNj8LAkcQYWF2o1rca/mIZP/rgbeGhCVNPY9tKYEBNUV+g==} + engines: {bun: '>=1.2.0', deno: '>=2.3.0', node: '>=20.0.0'} + + '@upyo/mock@0.5.0': + resolution: {integrity: sha512-qkNzIvnbxrtoKLLtD1eV7vfUpznppXX1l6wlBkW5W9F1pHTTnM8GjFbyCAG/vrXX1bOavAz3YJGyt7ZnqiGf1w==} + engines: {bun: '>=1.2.0', deno: '>=2.3.0', node: '>=20.0.0'} + peerDependencies: + '@upyo/core': 0.5.0 + + '@upyo/smtp@0.5.0': + resolution: {integrity: sha512-NjRHQN8n9AnyKYLQFOTe/c1ODd58Vv++423RAWnj6GbhGweqM1RatxvwJF4GZCwSM/Z/L0Ef7bmmLFb+gqjjZQ==} + engines: {bun: '>=1.2.0', deno: '>=2.3.0', node: '>=20.0.0'} + peerDependencies: + '@upyo/core': 0.5.0 + '@vercel/nft@1.10.2': resolution: {integrity: sha512-w+WyX5Ulmj4dtTZrxaulqrjaLZHSbnPzx75SJsTNYmotKsqn1JlLnDJa+lz5hn90HJofhl/2MAtw0mCrgM3qYw==} engines: {node: '>=20'} @@ -4157,6 +4198,8 @@ snapshots: '@fastify/busboy@3.2.0': {} + '@fedify/uri-template@2.3.1': {} + '@graphql-tools/executor@1.5.3(graphql@16.14.2)': dependencies: '@graphql-tools/utils': 11.1.0(graphql@16.14.2) @@ -4433,6 +4476,11 @@ snapshots: drizzle-orm: 1.0.0-beta.22(@electric-sql/pglite@0.5.3)(@types/pg@8.20.0)(pg@8.21.0) graphql: 16.14.2 + '@pothos/plugin-errors@4.9.1(@pothos/core@4.13.0(graphql@16.14.2))(graphql@16.14.2)': + dependencies: + '@pothos/core': 4.13.0(graphql@16.14.2) + graphql: 16.14.2 + '@pothos/plugin-relay@4.7.0(@pothos/core@4.13.0(graphql@16.14.2))(graphql@16.14.2)': dependencies: '@pothos/core': 4.13.0(graphql@16.14.2) @@ -4915,6 +4963,16 @@ snapshots: '@ungap/structured-clone@1.3.2': {} + '@upyo/core@0.5.0': {} + + '@upyo/mock@0.5.0(@upyo/core@0.5.0)': + dependencies: + '@upyo/core': 0.5.0 + + '@upyo/smtp@0.5.0(@upyo/core@0.5.0)': + dependencies: + '@upyo/core': 0.5.0 + '@vercel/nft@1.10.2(rollup@4.62.2)': dependencies: '@mapbox/node-pre-gyp': 2.0.3 diff --git a/scripts/dev.mts b/scripts/dev.mts index c6ded27..d101893 100644 --- a/scripts/dev.mts +++ b/scripts/dev.mts @@ -13,6 +13,7 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . + // oxlint-disable no-console no-magic-numbers eslin/max-lines import { type ChildProcess, spawn } from "node:child_process"; import { existsSync } from "node:fs"; @@ -154,6 +155,7 @@ async function shutdown( forceKill(serverProcess); forceKill(webProcess); forceKill(buildProcess); + forceKill(mailpitProcess); process.exit(exitCode); } shuttingDown = true; @@ -164,6 +166,7 @@ async function shutdown( : terminate(serverProcess, signal === "SIGINT" ? "SIGINT" : "SIGTERM"), terminate(webProcess, "SIGTERM"), terminate(buildProcess, "SIGTERM"), + terminate(mailpitProcess, "SIGTERM"), ]); process.exit(exitCode); } @@ -266,6 +269,7 @@ function sleep(ms: number): Promise { let buildProcess: ChildProcess | undefined; let serverProcess: ChildProcess | undefined; let webProcess: ChildProcess | undefined; +let mailpitProcess: ChildProcess | undefined; let shuttingDown = false; process.on("SIGINT", () => { @@ -299,12 +303,20 @@ try { await waitForBuilds(buildExit); + mailpitProcess = spawnManaged( + "mailpit", + ["--smtp", "localhost:1025", "--listen", "localhost:8025"], + root, + ); + const serverArgs: string[] = [ "--watch", "bin/drfed-server.mjs", "--pglite-data-path", "../../.pgdata", "--listen=0.0.0.0:8888", + "--smtp-url=smtp://localhost:1025", + "--seed", ]; const logLevel = process.env.usage_log_level; @@ -340,7 +352,12 @@ try { result: await waitForExit(webProcess), }))(); - const firstExit = await Promise.race([serverExit, webExit]); + const mailpitExit = (async () => ({ + name: "mailpit" as const, + result: await waitForExit(mailpitProcess), + }))(); + + const firstExit = await Promise.race([serverExit, webExit, mailpitExit]); if (firstExit.result.error != null) { throw firstExit.result.error; diff --git a/tsconfig.json b/tsconfig.json index fb0d88c..118cd9d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,7 @@ "exactOptionalPropertyTypes": true, "forceConsistentCasingInFileNames": true, "isolatedModules": true, - "lib": ["ES2024"], + "lib": ["ESNEXT"], "module": "NodeNext", "moduleResolution": "NodeNext", "noEmit": true,