feat: household api token auth foundation
This commit is contained in:
@@ -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=
|
||||
|
||||
|
||||
@@ -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,
|
||||
"tag": "0018_container_images",
|
||||
"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";
|
||||
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
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { 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"];
|
||||
|
||||
|
||||
@@ -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 { 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),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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