From 0dc61a88b8d12c61fd436adf0693c9fa5087a8c0 Mon Sep 17 00:00:00 2001 From: Matti Airas Date: Mon, 5 Jan 2026 19:43:17 +0200 Subject: [PATCH] feat(auth): extend API key authentication to tRPC endpoints (#4732) Co-authored-by: Claude Opus 4.5 --- apps/nextjs/src/app/api/[...trpc]/route.ts | 80 ++--------- apps/nextjs/src/app/api/trpc/[trpc]/route.ts | 15 +- packages/api/src/open-api.ts | 4 +- packages/auth/api-key/constants.ts | 1 + packages/auth/api-key/get-api-key-session.ts | 76 ++++++++++ packages/auth/api-key/index.ts | 2 + .../api-key/test/get-api-key-session.spec.ts | 133 ++++++++++++++++++ packages/auth/package.json | 1 + .../common/src/{user-agent.ts => request.ts} | 4 + packages/common/src/server.ts | 2 +- 10 files changed, 246 insertions(+), 72 deletions(-) create mode 100644 packages/auth/api-key/constants.ts create mode 100644 packages/auth/api-key/get-api-key-session.ts create mode 100644 packages/auth/api-key/index.ts create mode 100644 packages/auth/api-key/test/get-api-key-session.spec.ts rename packages/common/src/{user-agent.ts => request.ts} (79%) diff --git a/apps/nextjs/src/app/api/[...trpc]/route.ts b/apps/nextjs/src/app/api/[...trpc]/route.ts index fa5ced6fc..2980900a8 100644 --- a/apps/nextjs/src/app/api/[...trpc]/route.ts +++ b/apps/nextjs/src/app/api/[...trpc]/route.ts @@ -3,21 +3,24 @@ import { userAgent } from "next/server"; import { createOpenApiFetchHandler } from "trpc-to-openapi"; import { appRouter, createTRPCContext } from "@homarr/api"; -import type { Session } from "@homarr/auth"; -import { hashPasswordAsync } from "@homarr/auth"; -import { createSessionAsync } from "@homarr/auth/server"; +import { API_KEY_HEADER_NAME, getSessionFromApiKeyAsync } from "@homarr/auth/api-key"; +import { ipAddressFromHeaders } from "@homarr/common/server"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; -import { db, eq } from "@homarr/db"; -import { apiKeys } from "@homarr/db/schema"; +import { db } from "@homarr/db"; const logger = createLogger({ module: "trpcOpenApiRoute" }); const handlerAsync = async (req: NextRequest) => { - const apiKeyHeaderValue = req.headers.get("ApiKey"); - const ipAddress = req.headers.get("x-forwarded-for"); + const apiKeyHeaderValue = req.headers.get(API_KEY_HEADER_NAME); + const ipAddress = ipAddressFromHeaders(req.headers); const { ua } = userAgent(req); - const session: Session | null = await getSessionOrDefaultFromHeadersAsync(apiKeyHeaderValue, ipAddress, ua); + + logger.info( + `Creating OpenAPI fetch handler for user ${apiKeyHeaderValue ? "with an api key" : "without an api key"}`, + ); + + const session = await getSessionFromApiKeyAsync(db, apiKeyHeaderValue, ipAddress, ua); // Fallback to JSON if no content type is set if (!req.headers.has("Content-Type")) { @@ -35,67 +38,6 @@ const handlerAsync = async (req: NextRequest) => { }); }; -const getSessionOrDefaultFromHeadersAsync = async ( - apiKeyHeaderValue: string | null, - ipAdress: string | null, - userAgent: string, -): Promise => { - logger.info( - `Creating OpenAPI fetch handler for user ${apiKeyHeaderValue ? "with an api key" : "without an api key"}`, - ); - - if (apiKeyHeaderValue === null) { - return null; - } - - const [apiKeyId, apiKey] = apiKeyHeaderValue.split("."); - - if (!apiKeyId || !apiKey) { - logger.warn("An attempt to authenticate over API has failed due to invalid API key format", { - ipAdress, - userAgent, - }); - return null; - } - - const apiKeyFromDb = await db.query.apiKeys.findFirst({ - where: eq(apiKeys.id, apiKeyId), - columns: { - id: true, - apiKey: true, - salt: true, - }, - with: { - user: { - columns: { - id: true, - name: true, - email: true, - emailVerified: true, - }, - }, - }, - }); - - if (!apiKeyFromDb) { - logger.warn("An attempt to authenticate over API has failed", { ipAdress, userAgent }); - return null; - } - - const hashedApiKey = await hashPasswordAsync(apiKey, apiKeyFromDb.salt); - - if (apiKeyFromDb.apiKey !== hashedApiKey) { - logger.warn("An attempt to authenticate over API has failed", { ipAdress, userAgent }); - return null; - } - - logger.info("Read session from API request and found user", { - name: apiKeyFromDb.user.name, - id: apiKeyFromDb.user.id, - }); - return await createSessionAsync(db, apiKeyFromDb.user); -}; - export { handlerAsync as DELETE, handlerAsync as GET, diff --git a/apps/nextjs/src/app/api/trpc/[trpc]/route.ts b/apps/nextjs/src/app/api/trpc/[trpc]/route.ts index 65ebb353d..8ac6c166d 100644 --- a/apps/nextjs/src/app/api/trpc/[trpc]/route.ts +++ b/apps/nextjs/src/app/api/trpc/[trpc]/route.ts @@ -1,10 +1,14 @@ +import { userAgent } from "next/server"; import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import { appRouter, createTRPCContext } from "@homarr/api"; import { trpcPath } from "@homarr/api/shared"; +import { API_KEY_HEADER_NAME, getSessionFromApiKeyAsync } from "@homarr/auth/api-key"; import { auth } from "@homarr/auth/next"; +import { ipAddressFromHeaders } from "@homarr/common/server"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; +import { db } from "@homarr/db"; const logger = createLogger({ module: "trpcRoute" }); @@ -28,11 +32,20 @@ export function OPTIONS() { } const handler = auth(async (req) => { + // Try API key auth first, fall back to session cookie + const apiKeyHeader = req.headers.get(API_KEY_HEADER_NAME); + const ipAddress = ipAddressFromHeaders(req.headers); + + const { ua } = userAgent(req); + + const apiKeySession = await getSessionFromApiKeyAsync(db, apiKeyHeader, ipAddress, ua); + const session = apiKeySession ?? req.auth; + const response = await fetchRequestHandler({ endpoint: trpcPath, router: appRouter, req, - createContext: () => createTRPCContext({ session: req.auth, headers: req.headers }), + createContext: () => createTRPCContext({ session, headers: req.headers }), onError({ error, path, type }) { logger.error(new ErrorWithMetadata("tRPC Error occured", { path, type }, { cause: error })); }, diff --git a/packages/api/src/open-api.ts b/packages/api/src/open-api.ts index 5620df23a..b4827871c 100644 --- a/packages/api/src/open-api.ts +++ b/packages/api/src/open-api.ts @@ -1,5 +1,7 @@ import { generateOpenApiDocument } from "trpc-to-openapi"; +import { API_KEY_HEADER_NAME } from "@homarr/auth/api-key"; + import { appRouter } from "./root"; export const openApiDocument = (base: string) => @@ -11,7 +13,7 @@ export const openApiDocument = (base: string) => securitySchemes: { apikey: { type: "apiKey", - name: "ApiKey", + name: API_KEY_HEADER_NAME, description: "API key which can be obtained in the Homarr administration dashboard", in: "header", }, diff --git a/packages/auth/api-key/constants.ts b/packages/auth/api-key/constants.ts new file mode 100644 index 000000000..591d43410 --- /dev/null +++ b/packages/auth/api-key/constants.ts @@ -0,0 +1 @@ +export const API_KEY_HEADER_NAME = "ApiKey"; diff --git a/packages/auth/api-key/get-api-key-session.ts b/packages/auth/api-key/get-api-key-session.ts new file mode 100644 index 000000000..6fbaec972 --- /dev/null +++ b/packages/auth/api-key/get-api-key-session.ts @@ -0,0 +1,76 @@ +import type { Session } from "next-auth"; + +import { createLogger } from "@homarr/core/infrastructure/logs"; +import type { Database } from "@homarr/db"; +import { eq } from "@homarr/db"; +import { apiKeys } from "@homarr/db/schema"; + +import { hashPasswordAsync } from "../security"; +import { createSessionAsync } from "../server"; + +const logger = createLogger({ module: "apiKeyAuth" }); + +/** + * Validate an API key from the request header and return a session if valid. + * + * @param db - The database instance + * @param apiKeyHeaderValue - The value of the ApiKey header (format: "id.token") + * @param ipAddress - The IP address of the request (for logging) + * @param userAgent - The user agent of the request (for logging) + * @returns A session if the API key is valid, null otherwise + */ +export const getSessionFromApiKeyAsync = async ( + db: Database, + apiKeyHeaderValue: string | null, + ipAddress: string | null, + userAgent: string, +): Promise => { + if (apiKeyHeaderValue === null) { + return null; + } + + const [apiKeyId, apiKey] = apiKeyHeaderValue.split("."); + + if (!apiKeyId || !apiKey) { + logger.warn("Failed to authenticate with api-key", { ipAddress, userAgent, reason: "API_KEY_INVALID_FORMAT" }); + return null; + } + + const apiKeyFromDb = await db.query.apiKeys.findFirst({ + where: eq(apiKeys.id, apiKeyId), + columns: { + id: true, + apiKey: true, + salt: true, + }, + with: { + user: { + columns: { + id: true, + name: true, + email: true, + emailVerified: true, + }, + }, + }, + }); + + if (!apiKeyFromDb) { + logger.warn("Failed to authenticate with api-key", { ipAddress, userAgent, reason: "API_KEY_NOT_FOUND" }); + return null; + } + + const hashedApiKey = await hashPasswordAsync(apiKey, apiKeyFromDb.salt); + + if (apiKeyFromDb.apiKey !== hashedApiKey) { + logger.warn("Failed to authenticate with api-key", { ipAddress, userAgent, reason: "API_KEY_MISMATCH" }); + return null; + } + + logger.info("Successfully authenticated with api-key", { + name: apiKeyFromDb.user.name, + id: apiKeyFromDb.user.id, + }); + + return await createSessionAsync(db, apiKeyFromDb.user); +}; diff --git a/packages/auth/api-key/index.ts b/packages/auth/api-key/index.ts new file mode 100644 index 000000000..979243dd4 --- /dev/null +++ b/packages/auth/api-key/index.ts @@ -0,0 +1,2 @@ +export { getSessionFromApiKeyAsync } from "./get-api-key-session"; +export { API_KEY_HEADER_NAME } from "./constants"; diff --git a/packages/auth/api-key/test/get-api-key-session.spec.ts b/packages/auth/api-key/test/get-api-key-session.spec.ts new file mode 100644 index 000000000..5040ea441 --- /dev/null +++ b/packages/auth/api-key/test/get-api-key-session.spec.ts @@ -0,0 +1,133 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { describe, expect, test, vi } from "vitest"; + +import { createId } from "@homarr/common"; +import { apiKeys, users } from "@homarr/db/schema"; +import { createDb } from "@homarr/db/test"; + +import { createSaltAsync, hashPasswordAsync } from "../../security"; +import { getSessionFromApiKeyAsync } from "../get-api-key-session"; + +// Mock the logger to avoid console output during tests +vi.mock("@homarr/core/infrastructure/logs", () => ({ + createLogger: () => ({ + warn: vi.fn(), + info: vi.fn(), + }), +})); + +const defaultUserId = createId(); +const defaultUsername = "testuser"; +const defaultApiKeyId = createId(); +const defaultIpAddress = "127.0.0.1"; +const defaultUserAgent = "test-agent"; +const defaultLogParams = [defaultIpAddress, defaultUserAgent] as const; + +describe("getSessionFromApiKeyAsync", () => { + test("should return null when api key header is null", async () => { + // Arrange + const { db } = await setupAsync(); + const apiKey = null; + + // Act + const result = await getSessionFromApiKeyAsync(db, apiKey, ...defaultLogParams); + + // Assert + expect(result).toBeNull(); + }); + + test.each([ + ["invalidformat", "no dot"], + ["keyid.", "missing token"], + [".token", "missing id"], + ])("should return null when api key format is invalid key=%s reason=%s", async (apiKey) => { + // Arrange + const { db } = await setupAsync(); + + // Act + const result = await getSessionFromApiKeyAsync(db, apiKey, ...defaultLogParams); + + // Assert + expect(result).toBeNull(); + }); + + test("should return null when api key is not found in database", async () => { + // Arrange + const { db } = await setupAsync(); + + // Act + const result = await getSessionFromApiKeyAsync(db, "nonexistent.token", ...defaultLogParams); + + // Assert + expect(result).toBeNull(); + }); + + test("should return null when api key token does not match", async () => { + // Arrange + const { db } = await setupAsync({ token: "correcttoken" }); + + // Act + const result = await getSessionFromApiKeyAsync(db, `${defaultApiKeyId}.wrongtoken`, ...defaultLogParams); + + // Assert + expect(result).toBeNull(); + }); + + test("should return session when api key is valid", async () => { + // Arrange + const token = "validtesttoken123"; + const { db } = await setupAsync({ token }); + + // Act + const result = await getSessionFromApiKeyAsync(db, `${defaultApiKeyId}.${token}`, ...defaultLogParams); + + // Assert + expect(result).not.toBeNull(); + expect(result!.user.id).toEqual(defaultUserId); + expect(result!.user.name).toEqual(defaultUsername); + }); + + test("should work with null ip address", async () => { + // Arrange + const token = "validtesttoken456"; + const { db } = await setupAsync({ token }); + + // Act + const result = await getSessionFromApiKeyAsync(db, `${defaultApiKeyId}.${token}`, null, defaultUserAgent); + + // Assert + expect(result).not.toBeNull(); + expect(result!.user.id).toEqual(defaultUserId); + }); +}); + +interface SetupOptions { + /** + * If provided, inserts an API key into the database for testing. + */ + token?: string; +} + +const setupAsync = async (options?: SetupOptions) => { + const db = createDb(); + + await db.insert(users).values({ + id: defaultUserId, + name: defaultUsername, + email: "test@example.com", + }); + + if (options?.token) { + const salt = await createSaltAsync(); + await db.insert(apiKeys).values({ + id: defaultApiKeyId, + apiKey: await hashPasswordAsync(options.token, salt), + salt, + userId: defaultUserId, + }); + } + + return { + db, + }; +}; diff --git a/packages/auth/package.json b/packages/auth/package.json index 1ba1e31d8..aaac1811b 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -8,6 +8,7 @@ ".": "./index.ts", "./next": "./next.ts", "./security": "./security.ts", + "./api-key": "./api-key/index.ts", "./client": "./client.ts", "./server": "./server.ts", "./shared": "./shared.ts", diff --git a/packages/common/src/user-agent.ts b/packages/common/src/request.ts similarity index 79% rename from packages/common/src/user-agent.ts rename to packages/common/src/request.ts index b4a90dfb3..f90d91945 100644 --- a/packages/common/src/user-agent.ts +++ b/packages/common/src/request.ts @@ -9,3 +9,7 @@ export const userAgent = (headers: Headers) => { }; export type DeviceType = "console" | "mobile" | "tablet" | "smarttv" | "wearable" | "embedded" | undefined; + +export const ipAddressFromHeaders = (headers: Headers): string | null => { + return headers.get("x-forwarded-for"); +}; diff --git a/packages/common/src/server.ts b/packages/common/src/server.ts index fd23f7404..d6e62d5b9 100644 --- a/packages/common/src/server.ts +++ b/packages/common/src/server.ts @@ -1,4 +1,4 @@ export * from "./security"; export * from "./encryption"; -export * from "./user-agent"; +export * from "./request"; export * from "./errors";