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:
@@ -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,
|
||||
|
||||
@@ -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 }));
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
1
packages/auth/api-key/constants.ts
Normal file
1
packages/auth/api-key/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const API_KEY_HEADER_NAME = "ApiKey";
|
||||
76
packages/auth/api-key/get-api-key-session.ts
Normal file
76
packages/auth/api-key/get-api-key-session.ts
Normal 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);
|
||||
};
|
||||
2
packages/auth/api-key/index.ts
Normal file
2
packages/auth/api-key/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { getSessionFromApiKeyAsync } from "./get-api-key-session";
|
||||
export { API_KEY_HEADER_NAME } from "./constants";
|
||||
133
packages/auth/api-key/test/get-api-key-session.spec.ts
Normal file
133
packages/auth/api-key/test/get-api-key-session.spec.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from "./security";
|
||||
export * from "./encryption";
|
||||
export * from "./user-agent";
|
||||
export * from "./request";
|
||||
export * from "./errors";
|
||||
|
||||
Reference in New Issue
Block a user