feat: household api token auth foundation
This commit is contained in:
@@ -31,6 +31,9 @@ NTFY_TOPIC=
|
|||||||
# Logging
|
# Logging
|
||||||
LOG_LEVEL=info
|
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 (required for pnpm release — creates a GitHub Release)
|
||||||
GITHUB_TOKEN=
|
GITHUB_TOKEN=
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -134,6 +134,13 @@
|
|||||||
"when": 1748995200000,
|
"when": 1748995200000,
|
||||||
"tag": "0018_container_images",
|
"tag": "0018_container_images",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 19,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1751664000000,
|
||||||
|
"tag": "0019_household_api_tokens",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -24,6 +24,7 @@ import type {
|
|||||||
} from "@/modules/_core/themes";
|
} from "@/modules/_core/themes";
|
||||||
import { getCurrentSession } from "@/lib/session";
|
import { getCurrentSession } from "@/lib/session";
|
||||||
import { revokeShareLink } from "@/modules/_core/share";
|
import { revokeShareLink } from "@/modules/_core/share";
|
||||||
|
import { createHouseholdApiToken, revokeHouseholdApiToken } from "@/modules/_core/api-token";
|
||||||
|
|
||||||
export interface ThemePatch {
|
export interface ThemePatch {
|
||||||
palette?: Palette;
|
palette?: Palette;
|
||||||
@@ -88,3 +89,22 @@ export async function revokeShareLinkAction(formData: FormData): Promise<void> {
|
|||||||
await revokeShareLink(id);
|
await revokeShareLink(id);
|
||||||
revalidatePath("/settings");
|
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
@@ -11,11 +11,13 @@ import { ResponsiveSidebar } from "@/components/settings-section";
|
|||||||
import type { SectionId } from "@/components/settings-section";
|
import type { SectionId } from "@/components/settings-section";
|
||||||
import { AvatarFallbackWithName } from "@/components/avatar-fallback";
|
import { AvatarFallbackWithName } from "@/components/avatar-fallback";
|
||||||
import { revokeShareLinkAction } from "./actions";
|
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 { listCalendars } from "@/modules/calendar/server/queries";
|
||||||
import { listLists } from "@/modules/lists/server/queries";
|
import { listLists } from "@/modules/lists/server/queries";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { NavIcon } from "@/components/nav-icon";
|
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>([
|
const VALID_SECTIONS = new Set<SectionId>([
|
||||||
"household",
|
"household",
|
||||||
@@ -36,7 +38,7 @@ export default async function SettingsPage({
|
|||||||
? (sp.s as SectionId)
|
? (sp.s as SectionId)
|
||||||
: "household";
|
: "household";
|
||||||
|
|
||||||
const { user, household } = await getCurrentSession();
|
const { user, household, role } = await getCurrentSession();
|
||||||
const ntfyConfigured = !!(process.env["NTFY_URL"] && process.env["NTFY_TOPIC"]);
|
const ntfyConfigured = !!(process.env["NTFY_URL"] && process.env["NTFY_TOPIC"]);
|
||||||
const vapidKey = process.env["VAPID_PUBLIC_KEY"] ?? "";
|
const vapidKey = process.env["VAPID_PUBLIC_KEY"] ?? "";
|
||||||
|
|
||||||
@@ -51,7 +53,7 @@ export default async function SettingsPage({
|
|||||||
)}
|
)}
|
||||||
{section === "calendars" && <CalendarsAndListsSection />}
|
{section === "calendars" && <CalendarsAndListsSection />}
|
||||||
{section === "appearance" && <AppearanceSection user={user} />}
|
{section === "appearance" && <AppearanceSection user={user} />}
|
||||||
{section === "data" && <DataSection />}
|
{section === "data" && <DataSection householdId={household.id} role={role} />}
|
||||||
</div>
|
</div>
|
||||||
</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 (
|
return (
|
||||||
<Card>
|
<>
|
||||||
<CardHeader>
|
{role === "owner" && tokenStatus && (
|
||||||
<CardTitle>Data & backups</CardTitle>
|
<Card>
|
||||||
</CardHeader>
|
<CardHeader>
|
||||||
<div>
|
<CardTitle>API access</CardTitle>
|
||||||
<div className="set-row">
|
<KeyRound className="size-4 text-[var(--ink-mute)]" />
|
||||||
<History className="size-4 text-[var(--ink-soft)]" />
|
</CardHeader>
|
||||||
<div className="label">
|
<CardContent className="p-0">
|
||||||
<div className="t">Auto-backup</div>
|
<ApiTokenSettings status={tokenStatus} />
|
||||||
<div className="d">Daily 03:00 → /var/backups/famapp/. Configured via host cron.</div>
|
</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>
|
</div>
|
||||||
<div className="set-row">
|
</Card>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <token></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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse, type NextRequest } from "next/server";
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
import { consume } from "@/lib/rate-limit";
|
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 PUBLIC_PATHS = new Set(["/login", "/manifest.webmanifest", "/offline.html"]);
|
||||||
const SESSION_COOKIE_NAMES = ["authjs.session-token", "__Secure-authjs.session-token"];
|
const SESSION_COOKIE_NAMES = ["authjs.session-token", "__Secure-authjs.session-token"];
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { AdapterAccountType } from "@auth/core/adapters";
|
import type { AdapterAccountType } from "@auth/core/adapters";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
boolean,
|
boolean,
|
||||||
index,
|
index,
|
||||||
@@ -209,3 +210,27 @@ export const notifications = pgTable(
|
|||||||
},
|
},
|
||||||
(t) => [index("notifications_user_read_idx").on(t.userId, t.readAt)],
|
(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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user