feat(auth): extend API key authentication to tRPC endpoints (#4732)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Matti Airas
2026-01-05 19:43:17 +02:00
committed by GitHub
parent c8ecf5f0a1
commit 0dc61a88b8
10 changed files with 246 additions and 72 deletions

View File

@@ -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<Session | null> => {
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,

View File

@@ -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 }));
},

View File

@@ -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",
},

View File

@@ -0,0 +1 @@
export const API_KEY_HEADER_NAME = "ApiKey";

View File

@@ -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<Session | null> => {
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);
};

View File

@@ -0,0 +1,2 @@
export { getSessionFromApiKeyAsync } from "./get-api-key-session";
export { API_KEY_HEADER_NAME } from "./constants";

View File

@@ -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,
};
};

View File

@@ -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",

View File

@@ -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");
};

View File

@@ -1,4 +1,4 @@
export * from "./security";
export * from "./encryption";
export * from "./user-agent";
export * from "./request";
export * from "./errors";