feat: household api token auth foundation

This commit is contained in:
ginnoir
2026-07-04 18:57:31 -05:00
parent 2c0c31540e
commit ea5d1d050c
11 changed files with 465 additions and 32 deletions
+3
View File
@@ -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 <token> on /api/v1/* requests
# GitHub (required for pnpm release — creates a GitHub Release)
GITHUB_TOKEN=
+26
View File
@@ -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");
+7
View File
@@ -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
}
]
}
+20
View File
@@ -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<void> {
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<void> {
const { household, role } = await getCurrentSession();
requireOwner(role);
await revokeHouseholdApiToken(household.id);
revalidatePath("/settings");
}
+55 -31
View File
@@ -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<SectionId>([
"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" && <CalendarsAndListsSection />}
{section === "appearance" && <AppearanceSection user={user} />}
{section === "data" && <DataSection />}
{section === "data" && <DataSection householdId={household.id} role={role} />}
</div>
</div>
);
@@ -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 (
<Card>
<CardHeader>
<CardTitle>Data & backups</CardTitle>
</CardHeader>
<div>
<div className="set-row">
<History className="size-4 text-[var(--ink-soft)]" />
<div className="label">
<div className="t">Auto-backup</div>
<div className="d">Daily 03:00 /var/backups/famapp/. Configured via host cron.</div>
<>
{role === "owner" && tokenStatus && (
<Card>
<CardHeader>
<CardTitle>API access</CardTitle>
<KeyRound className="size-4 text-[var(--ink-mute)]" />
</CardHeader>
<CardContent className="p-0">
<ApiTokenSettings status={tokenStatus} />
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Data & backups</CardTitle>
</CardHeader>
<div>
<div className="set-row">
<History className="size-4 text-[var(--ink-soft)]" />
<div className="label">
<div className="t">Auto-backup</div>
<div className="d">Daily 03:00 /var/backups/famapp/. Configured via host cron.</div>
</div>
</div>
<div className="set-row">
<Globe className="size-4 text-[var(--ink-soft)]" />
<div className="label">
<div className="t">Server</div>
<div className="d">Self-hosted via Docker Compose</div>
</div>
</div>
<div className="set-row" style={{ borderBottom: "0" }}>
<Mail className="size-4 text-[var(--ink-soft)]" />
<div className="label">
<div className="t">Export</div>
<div className="d">Not yet implemented coming in v0.5</div>
</div>
<Bell className="size-4 text-[var(--ink-faint)]" />
</div>
</div>
<div className="set-row">
<Globe className="size-4 text-[var(--ink-soft)]" />
<div className="label">
<div className="t">Server</div>
<div className="d">Self-hosted via Docker Compose</div>
</div>
</div>
<div className="set-row" style={{ borderBottom: "0" }}>
<Mail className="size-4 text-[var(--ink-soft)]" />
<div className="label">
<div className="t">Export</div>
<div className="d">Not yet implemented coming in v0.5</div>
</div>
<Bell className="size-4 text-[var(--ink-faint)]" />
</div>
</div>
</Card>
</Card>
</>
);
}
+118
View File
@@ -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<string | null>(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 (
<>
<div className="set-row" style={{ borderBottom: "0" }}>
<KeyRound className="size-4 text-[var(--ink-soft)]" />
<div className="label flex-1">
<div className="t">API token</div>
<div className="d">
{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</>
)}
</div>
</div>
<div className="flex gap-2 shrink-0">
{tokenStatus.hasActiveToken ? (
<Button variant="destructive" size="sm" onClick={revokeToken} disabled={isPending}>
Revoke token
</Button>
) : null}
<Button variant="outline" size="sm" onClick={generateToken} disabled={isPending}>
{tokenStatus.hasActiveToken ? "Regenerate" : "Generate token"}
</Button>
</div>
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>API token created</DialogTitle>
<DialogDescription>
Copy this token now it will not be shown again. Use it as{" "}
<code className="text-xs">Authorization: Bearer &lt;token&gt;</code> on{" "}
<code className="text-xs">/api/v1/</code> requests.
</DialogDescription>
</DialogHeader>
<div className="flex gap-2">
<Input readOnly value={rawToken ?? ""} className="font-mono text-xs" />
<Button variant="outline" size="icon" onClick={copyToken} aria-label="Copy token">
{copied ? <Check className="text-[var(--c-success)]" /> : <Copy />}
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}
+68
View File
@@ -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<ApiAuthContext | null> {
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<ApiAuthContext | null> {
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<ApiAuthContext> {
const ctx = await resolveApiAuth(request);
if (!ctx) {
throw new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
return ctx;
}
+1 -1
View File
@@ -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"];
+102
View File
@@ -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<CreateHouseholdApiTokenResult> {
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<void> {
await db
.update(householdApiTokens)
.set({ revokedAt: new Date() })
.where(
and(eq(householdApiTokens.householdId, householdId), isNull(householdApiTokens.revokedAt)),
);
}
export async function getHouseholdApiTokenStatus(
householdId: string,
): Promise<HouseholdApiTokenStatus> {
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 };
}
+25
View File
@@ -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),
],
);
+40
View File
@@ -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);
});
});