diff --git a/.env.example b/.env.example index 4d3f867..ff4b685 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,9 @@ NTFY_TOPIC= # Logging LOG_LEVEL=info +# Household API token (generated in Settings → Data & backups; stored hashed in DB) +# Clients send: Authorization: Bearer on /api/v1/* requests + # GitHub (required for pnpm release — creates a GitHub Release) GITHUB_TOKEN= diff --git a/drizzle/0019_household_api_tokens.sql b/drizzle/0019_household_api_tokens.sql new file mode 100644 index 0000000..a97426b --- /dev/null +++ b/drizzle/0019_household_api_tokens.sql @@ -0,0 +1,26 @@ +CREATE TABLE "household_api_tokens" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "household_id" uuid NOT NULL, + "token_hash" text NOT NULL, + "name" text DEFAULT 'default' NOT NULL, + "created_by" uuid NOT NULL, + "last_used_at" timestamp with time zone, + "revoked_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "household_api_tokens" ADD CONSTRAINT "household_api_tokens_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "household_api_tokens" ADD CONSTRAINT "household_api_tokens_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE UNIQUE INDEX "household_api_tokens_active_household_uq" ON "household_api_tokens" USING btree ("household_id") WHERE "revoked_at" IS NULL; +--> statement-breakpoint +CREATE INDEX "household_api_tokens_hash_idx" ON "household_api_tokens" USING btree ("token_hash"); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 5d45a6d..04e01b3 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -134,6 +134,13 @@ "when": 1748995200000, "tag": "0018_container_images", "breakpoints": true + }, + { + "idx": 19, + "version": "7", + "when": 1751664000000, + "tag": "0019_household_api_tokens", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/settings/actions.ts b/src/app/settings/actions.ts index 613043b..beb0c1d 100644 --- a/src/app/settings/actions.ts +++ b/src/app/settings/actions.ts @@ -24,6 +24,7 @@ import type { } from "@/modules/_core/themes"; import { getCurrentSession } from "@/lib/session"; import { revokeShareLink } from "@/modules/_core/share"; +import { createHouseholdApiToken, revokeHouseholdApiToken } from "@/modules/_core/api-token"; export interface ThemePatch { palette?: Palette; @@ -88,3 +89,22 @@ export async function revokeShareLinkAction(formData: FormData): Promise { await revokeShareLink(id); revalidatePath("/settings"); } + +function requireOwner(role: "owner" | "member") { + if (role !== "owner") throw new Error("Only household owners can manage API tokens"); +} + +export async function createApiTokenAction(): Promise<{ token: string }> { + const { household, role, user } = await getCurrentSession(); + requireOwner(role); + const result = await createHouseholdApiToken(household.id, user.id); + revalidatePath("/settings"); + return result; +} + +export async function revokeApiTokenAction(): Promise { + const { household, role } = await getCurrentSession(); + requireOwner(role); + await revokeHouseholdApiToken(household.id); + revalidatePath("/settings"); +} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index cdb20e6..cbf9da2 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -11,11 +11,13 @@ import { ResponsiveSidebar } from "@/components/settings-section"; import type { SectionId } from "@/components/settings-section"; import { AvatarFallbackWithName } from "@/components/avatar-fallback"; import { revokeShareLinkAction } from "./actions"; +import { getHouseholdApiTokenStatus } from "@/modules/_core/api-token"; +import { ApiTokenSettings } from "@/components/api-token-settings"; import { listCalendars } from "@/modules/calendar/server/queries"; import { listLists } from "@/modules/lists/server/queries"; import Link from "next/link"; import { NavIcon } from "@/components/nav-icon"; -import { Mail, Globe, History, Sun, Bell, Pencil, Lock, Plus } from "lucide-react"; +import { Mail, Globe, History, Sun, Bell, Pencil, Lock, Plus, KeyRound } from "lucide-react"; const VALID_SECTIONS = new Set([ "household", @@ -36,7 +38,7 @@ export default async function SettingsPage({ ? (sp.s as SectionId) : "household"; - const { user, household } = await getCurrentSession(); + const { user, household, role } = await getCurrentSession(); const ntfyConfigured = !!(process.env["NTFY_URL"] && process.env["NTFY_TOPIC"]); const vapidKey = process.env["VAPID_PUBLIC_KEY"] ?? ""; @@ -51,7 +53,7 @@ export default async function SettingsPage({ )} {section === "calendars" && } {section === "appearance" && } - {section === "data" && } + {section === "data" && } ); @@ -315,36 +317,58 @@ function AppearanceSection({ ); } -function DataSection() { +async function DataSection({ + householdId, + role, +}: { + householdId: string; + role: "owner" | "member"; +}) { + const tokenStatus = role === "owner" ? await getHouseholdApiTokenStatus(householdId) : null; + return ( - - - Data & backups - -
-
- -
-
Auto-backup
-
Daily 03:00 → /var/backups/famapp/. Configured via host cron.
+ <> + {role === "owner" && tokenStatus && ( + + + API access + + + + + + + )} + + + + Data & backups + +
+
+ +
+
Auto-backup
+
Daily 03:00 → /var/backups/famapp/. Configured via host cron.
+
+
+
+ +
+
Server
+
Self-hosted via Docker Compose
+
+
+
+ +
+
Export
+
Not yet implemented — coming in v0.5
+
+
-
- -
-
Server
-
Self-hosted via Docker Compose
-
-
-
- -
-
Export
-
Not yet implemented — coming in v0.5
-
- -
-
- + + ); } diff --git a/src/components/api-token-settings.tsx b/src/components/api-token-settings.tsx new file mode 100644 index 0000000..d2a76ed --- /dev/null +++ b/src/components/api-token-settings.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { Check, Copy, KeyRound } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { createApiTokenAction, revokeApiTokenAction } from "@/app/settings/actions"; +import type { HouseholdApiTokenStatus } from "@/modules/_core/api-token"; + +export function ApiTokenSettings({ status }: { status: HouseholdApiTokenStatus }) { + const [tokenStatus, setTokenStatus] = useState(status); + const [rawToken, setRawToken] = useState(null); + const [dialogOpen, setDialogOpen] = useState(false); + const [copied, setCopied] = useState(false); + const [isPending, startTransition] = useTransition(); + + function generateToken() { + startTransition(async () => { + try { + const result = await createApiTokenAction(); + setRawToken(result.token); + setDialogOpen(true); + setTokenStatus({ + hasActiveToken: true, + lastUsedAt: null, + createdAt: new Date(), + }); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to generate token"); + } + }); + } + + function revokeToken() { + startTransition(async () => { + try { + await revokeApiTokenAction(); + setTokenStatus({ hasActiveToken: false, lastUsedAt: null, createdAt: null }); + toast.success("API token revoked"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to revoke token"); + } + }); + } + + function copyToken() { + if (!rawToken) return; + navigator.clipboard.writeText(rawToken).then(() => { + setCopied(true); + toast.success("Copied to clipboard"); + setTimeout(() => setCopied(false), 2000); + }); + } + + return ( + <> +
+ +
+
API token
+
+ {tokenStatus.hasActiveToken ? ( + <> + Active + {tokenStatus.createdAt && ( + <> · created {tokenStatus.createdAt.toLocaleDateString()} + )} + {tokenStatus.lastUsedAt && ( + <> · last used {tokenStatus.lastUsedAt.toLocaleString()} + )} + {!tokenStatus.lastUsedAt && <> · never used} + + ) : ( + <>No active token — generate one for scripts and automations + )} +
+
+
+ {tokenStatus.hasActiveToken ? ( + + ) : null} + +
+
+ + + + + API token created + + Copy this token now — it will not be shown again. Use it as{" "} + Authorization: Bearer <token> on{" "} + /api/v1/ requests. + + +
+ + +
+
+
+ + ); +} diff --git a/src/lib/api-auth.ts b/src/lib/api-auth.ts new file mode 100644 index 0000000..34dc85b --- /dev/null +++ b/src/lib/api-auth.ts @@ -0,0 +1,68 @@ +import { eq } from "drizzle-orm"; +import { auth } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { resolveBearerToken } from "@/modules/_core/api-token"; +import { householdMembers } from "@/modules/_core/schema"; + +export type ApiAuthContext = { + householdId: string; + userId: string | null; + role: "owner" | "member" | null; +}; + +function parseBearerToken(request: Request): string | null { + const header = request.headers.get("Authorization"); + if (!header?.startsWith("Bearer ")) return null; + const token = header.slice("Bearer ".length).trim(); + return token.length > 0 ? token : null; +} + +async function resolveSessionAuth(): Promise { + const session = await auth(); + if (!session?.user?.id) return null; + + const [row] = await db + .select({ + householdId: householdMembers.householdId, + role: householdMembers.role, + }) + .from(householdMembers) + .where(eq(householdMembers.userId, session.user.id)) + .limit(1); + + if (!row) return null; + + return { + householdId: row.householdId, + userId: session.user.id, + role: row.role, + }; +} + +export async function resolveApiAuth(request: Request): Promise { + const sessionAuth = await resolveSessionAuth(); + if (sessionAuth) return sessionAuth; + + const rawToken = parseBearerToken(request); + if (!rawToken) return null; + + const bearer = await resolveBearerToken(rawToken); + if (!bearer) return null; + + return { + householdId: bearer.householdId, + userId: null, + role: null, + }; +} + +export async function requireApiAuth(request: Request): Promise { + const ctx = await resolveApiAuth(request); + if (!ctx) { + throw new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + return ctx; +} diff --git a/src/middleware.ts b/src/middleware.ts index 6fb1281..69d6e8d 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,7 +1,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { consume } from "@/lib/rate-limit"; -const PUBLIC_PREFIXES = ["/api/auth/", "/s/", "/icon-", "/favicon"]; +const PUBLIC_PREFIXES = ["/api/auth/", "/api/v1/", "/s/", "/icon-", "/favicon"]; const PUBLIC_PATHS = new Set(["/login", "/manifest.webmanifest", "/offline.html"]); const SESSION_COOKIE_NAMES = ["authjs.session-token", "__Secure-authjs.session-token"]; diff --git a/src/modules/_core/api-token.ts b/src/modules/_core/api-token.ts new file mode 100644 index 0000000..97699a8 --- /dev/null +++ b/src/modules/_core/api-token.ts @@ -0,0 +1,102 @@ +import { createHash, randomBytes } from "crypto"; +import { and, eq, isNull } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { householdApiTokens } from "./schema"; + +export type HouseholdApiTokenStatus = { + hasActiveToken: boolean; + lastUsedAt: Date | null; + createdAt: Date | null; +}; + +export type CreateHouseholdApiTokenResult = { + token: string; +}; + +export function hashApiToken(raw: string): string { + return createHash("sha256").update(raw).digest("hex"); +} + +function generateRawToken(): string { + return randomBytes(32).toString("base64url"); +} + +export async function createHouseholdApiToken( + householdId: string, + createdByUserId: string, +): Promise { + await db + .update(householdApiTokens) + .set({ revokedAt: new Date() }) + .where( + and(eq(householdApiTokens.householdId, householdId), isNull(householdApiTokens.revokedAt)), + ); + + const rawToken = generateRawToken(); + const tokenHash = hashApiToken(rawToken); + + await db.insert(householdApiTokens).values({ + householdId, + tokenHash, + createdBy: createdByUserId, + }); + + return { token: rawToken }; +} + +export async function revokeHouseholdApiToken(householdId: string): Promise { + await db + .update(householdApiTokens) + .set({ revokedAt: new Date() }) + .where( + and(eq(householdApiTokens.householdId, householdId), isNull(householdApiTokens.revokedAt)), + ); +} + +export async function getHouseholdApiTokenStatus( + householdId: string, +): Promise { + const [row] = await db + .select({ + lastUsedAt: householdApiTokens.lastUsedAt, + createdAt: householdApiTokens.createdAt, + }) + .from(householdApiTokens) + .where( + and(eq(householdApiTokens.householdId, householdId), isNull(householdApiTokens.revokedAt)), + ) + .limit(1); + + if (!row) { + return { hasActiveToken: false, lastUsedAt: null, createdAt: null }; + } + + return { + hasActiveToken: true, + lastUsedAt: row.lastUsedAt, + createdAt: row.createdAt, + }; +} + +export async function resolveBearerToken( + rawToken: string, +): Promise<{ householdId: string; userId: null } | null> { + if (!rawToken) return null; + + const tokenHash = hashApiToken(rawToken); + + const [row] = await db + .select({ id: householdApiTokens.id, householdId: householdApiTokens.householdId }) + .from(householdApiTokens) + .where(and(eq(householdApiTokens.tokenHash, tokenHash), isNull(householdApiTokens.revokedAt))) + .limit(1); + + if (!row) return null; + + void db + .update(householdApiTokens) + .set({ lastUsedAt: new Date() }) + .where(eq(householdApiTokens.id, row.id)); + + return { householdId: row.householdId, userId: null }; +} diff --git a/src/modules/_core/schema.ts b/src/modules/_core/schema.ts index 573703b..b666b1e 100644 --- a/src/modules/_core/schema.ts +++ b/src/modules/_core/schema.ts @@ -1,4 +1,5 @@ import type { AdapterAccountType } from "@auth/core/adapters"; +import { sql } from "drizzle-orm"; import { boolean, index, @@ -209,3 +210,27 @@ export const notifications = pgTable( }, (t) => [index("notifications_user_read_idx").on(t.userId, t.readAt)], ); + +export const householdApiTokens = pgTable( + "household_api_tokens", + { + id: uuid("id").primaryKey().defaultRandom(), + householdId: uuid("household_id") + .notNull() + .references(() => households.id, { onDelete: "cascade" }), + tokenHash: text("token_hash").notNull(), + name: text("name").notNull().default("default"), + createdBy: uuid("created_by") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + lastUsedAt: timestamp("last_used_at", { withTimezone: true }), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + uniqueIndex("household_api_tokens_active_household_uq") + .on(t.householdId) + .where(sql`${t.revokedAt} IS NULL`), + index("household_api_tokens_hash_idx").on(t.tokenHash), + ], +); diff --git a/tests/unit/api-auth.test.ts b/tests/unit/api-auth.test.ts new file mode 100644 index 0000000..81be4ef --- /dev/null +++ b/tests/unit/api-auth.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { describe, it } from "node:test"; +import { hashApiToken } from "../../src/modules/_core/api-token"; + +function parseBearerToken(header: string | null): string | null { + if (!header?.startsWith("Bearer ")) return null; + const token = header.slice("Bearer ".length).trim(); + return token.length > 0 ? token : null; +} + +describe("hashApiToken", () => { + it("returns SHA-256 hex digest matching Node crypto", () => { + const raw = "test-token-value"; + const expected = createHash("sha256").update(raw).digest("hex"); + assert.equal(hashApiToken(raw), expected); + }); + + it("produces distinct hashes for different inputs", () => { + assert.notEqual(hashApiToken("token-a"), hashApiToken("token-b")); + }); + + it("is deterministic for the same input", () => { + const raw = "famapp-api-token-abc123"; + assert.equal(hashApiToken(raw), hashApiToken(raw)); + }); +}); + +describe("parseBearerToken", () => { + it("extracts token from a valid Authorization header", () => { + assert.equal(parseBearerToken("Bearer abc.def-ghi"), "abc.def-ghi"); + }); + + it("returns null for missing or malformed headers", () => { + assert.equal(parseBearerToken(null), null); + assert.equal(parseBearerToken("Basic dXNlcjpwYXNz"), null); + assert.equal(parseBearerToken("Bearer "), null); + assert.equal(parseBearerToken("Bearer"), null); + }); +});