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 { createOpenApiFetchHandler } from "trpc-to-openapi";
|
||||||
|
|
||||||
import { appRouter, createTRPCContext } from "@homarr/api";
|
import { appRouter, createTRPCContext } from "@homarr/api";
|
||||||
import type { Session } from "@homarr/auth";
|
import { API_KEY_HEADER_NAME, getSessionFromApiKeyAsync } from "@homarr/auth/api-key";
|
||||||
import { hashPasswordAsync } from "@homarr/auth";
|
import { ipAddressFromHeaders } from "@homarr/common/server";
|
||||||
import { createSessionAsync } from "@homarr/auth/server";
|
|
||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||||
import { db, eq } from "@homarr/db";
|
import { db } from "@homarr/db";
|
||||||
import { apiKeys } from "@homarr/db/schema";
|
|
||||||
|
|
||||||
const logger = createLogger({ module: "trpcOpenApiRoute" });
|
const logger = createLogger({ module: "trpcOpenApiRoute" });
|
||||||
|
|
||||||
const handlerAsync = async (req: NextRequest) => {
|
const handlerAsync = async (req: NextRequest) => {
|
||||||
const apiKeyHeaderValue = req.headers.get("ApiKey");
|
const apiKeyHeaderValue = req.headers.get(API_KEY_HEADER_NAME);
|
||||||
const ipAddress = req.headers.get("x-forwarded-for");
|
const ipAddress = ipAddressFromHeaders(req.headers);
|
||||||
const { ua } = userAgent(req);
|
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
|
// Fallback to JSON if no content type is set
|
||||||
if (!req.headers.has("Content-Type")) {
|
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 {
|
export {
|
||||||
handlerAsync as DELETE,
|
handlerAsync as DELETE,
|
||||||
handlerAsync as GET,
|
handlerAsync as GET,
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
|
import { userAgent } from "next/server";
|
||||||
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||||
|
|
||||||
import { appRouter, createTRPCContext } from "@homarr/api";
|
import { appRouter, createTRPCContext } from "@homarr/api";
|
||||||
import { trpcPath } from "@homarr/api/shared";
|
import { trpcPath } from "@homarr/api/shared";
|
||||||
|
import { API_KEY_HEADER_NAME, getSessionFromApiKeyAsync } from "@homarr/auth/api-key";
|
||||||
import { auth } from "@homarr/auth/next";
|
import { auth } from "@homarr/auth/next";
|
||||||
|
import { ipAddressFromHeaders } from "@homarr/common/server";
|
||||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
|
||||||
|
import { db } from "@homarr/db";
|
||||||
|
|
||||||
const logger = createLogger({ module: "trpcRoute" });
|
const logger = createLogger({ module: "trpcRoute" });
|
||||||
|
|
||||||
@@ -28,11 +32,20 @@ export function OPTIONS() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handler = auth(async (req) => {
|
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({
|
const response = await fetchRequestHandler({
|
||||||
endpoint: trpcPath,
|
endpoint: trpcPath,
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
req,
|
req,
|
||||||
createContext: () => createTRPCContext({ session: req.auth, headers: req.headers }),
|
createContext: () => createTRPCContext({ session, headers: req.headers }),
|
||||||
onError({ error, path, type }) {
|
onError({ error, path, type }) {
|
||||||
logger.error(new ErrorWithMetadata("tRPC Error occured", { path, type }, { cause: error }));
|
logger.error(new ErrorWithMetadata("tRPC Error occured", { path, type }, { cause: error }));
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { generateOpenApiDocument } from "trpc-to-openapi";
|
import { generateOpenApiDocument } from "trpc-to-openapi";
|
||||||
|
|
||||||
|
import { API_KEY_HEADER_NAME } from "@homarr/auth/api-key";
|
||||||
|
|
||||||
import { appRouter } from "./root";
|
import { appRouter } from "./root";
|
||||||
|
|
||||||
export const openApiDocument = (base: string) =>
|
export const openApiDocument = (base: string) =>
|
||||||
@@ -11,7 +13,7 @@ export const openApiDocument = (base: string) =>
|
|||||||
securitySchemes: {
|
securitySchemes: {
|
||||||
apikey: {
|
apikey: {
|
||||||
type: "apiKey",
|
type: "apiKey",
|
||||||
name: "ApiKey",
|
name: API_KEY_HEADER_NAME,
|
||||||
description: "API key which can be obtained in the Homarr administration dashboard",
|
description: "API key which can be obtained in the Homarr administration dashboard",
|
||||||
in: "header",
|
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",
|
".": "./index.ts",
|
||||||
"./next": "./next.ts",
|
"./next": "./next.ts",
|
||||||
"./security": "./security.ts",
|
"./security": "./security.ts",
|
||||||
|
"./api-key": "./api-key/index.ts",
|
||||||
"./client": "./client.ts",
|
"./client": "./client.ts",
|
||||||
"./server": "./server.ts",
|
"./server": "./server.ts",
|
||||||
"./shared": "./shared.ts",
|
"./shared": "./shared.ts",
|
||||||
|
|||||||
@@ -9,3 +9,7 @@ export const userAgent = (headers: Headers) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type DeviceType = "console" | "mobile" | "tablet" | "smarttv" | "wearable" | "embedded" | undefined;
|
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 "./security";
|
||||||
export * from "./encryption";
|
export * from "./encryption";
|
||||||
export * from "./user-agent";
|
export * from "./request";
|
||||||
export * from "./errors";
|
export * from "./errors";
|
||||||
|
|||||||
Reference in New Issue
Block a user