From a10778aeaa250dd02627e3212a4e2d245dc7f8ec Mon Sep 17 00:00:00 2001 From: Pranav Date: Mon, 13 Jul 2026 10:03:03 +0530 Subject: [PATCH] refactor(auth): migrate plain-session batch A to shared plugin (#50) Migrate mail, domain, inbox, and webhook onto createAuthPlugin following the upload pilot recipe. Delete bespoke cookie-auth / api-key-auth files, inject SESSION_CACHE_REDIS_PREFIX redis + baseUrl/ttl, and keep service-specific internal-secret macros where needed (mail auth, domain apiKeyOrInternalAuth). Narrow fail-closed auth/apiKeyAuth organizationId to string for handler types; optional apiKeyId for audit/inject paths. Smoke tests per service. --- apps/backend/domain/package.json | 4 +- .../domain/src/middleware/api-key-auth.ts | 6 - apps/backend/domain/src/middleware/auth.ts | 183 ++++---------- .../domain/src/middleware/cookie-auth.ts | 50 ---- .../get-domain-dns.route.ts | 2 +- apps/backend/domain/test/auth-smoke.test.ts | 128 ++++++++++ apps/backend/inbox/package.json | 2 +- .../inbox/src/middleware/api-key-auth.ts | 6 - apps/backend/inbox/src/middleware/auth.ts | 180 ++------------ .../inbox/src/middleware/cookie-auth.ts | 31 --- apps/backend/inbox/test/auth-smoke.test.ts | 128 ++++++++++ apps/backend/mail/package.json | 2 +- .../mail/src/middleware/api-key-auth.ts | 6 - apps/backend/mail/src/middleware/auth.ts | 234 ++++++------------ .../mail/src/middleware/cookie-auth.ts | 39 --- .../mail/send-email/send-email.route.ts | 9 +- apps/backend/mail/test/auth-smoke.test.ts | 128 ++++++++++ apps/backend/webhook/package.json | 4 +- .../webhook/src/middleware/api-key-auth.ts | 12 - apps/backend/webhook/src/middleware/auth.ts | 121 ++------- .../webhook/src/middleware/cookie-auth.ts | 35 --- apps/backend/webhook/test/auth-smoke.test.ts | 128 ++++++++++ bun.lock | 4 - packages/auth/src/middleware/plugin.ts | 32 ++- 24 files changed, 711 insertions(+), 763 deletions(-) delete mode 100644 apps/backend/domain/src/middleware/api-key-auth.ts delete mode 100644 apps/backend/domain/src/middleware/cookie-auth.ts create mode 100644 apps/backend/domain/test/auth-smoke.test.ts delete mode 100644 apps/backend/inbox/src/middleware/api-key-auth.ts delete mode 100644 apps/backend/inbox/src/middleware/cookie-auth.ts create mode 100644 apps/backend/inbox/test/auth-smoke.test.ts delete mode 100644 apps/backend/mail/src/middleware/api-key-auth.ts delete mode 100644 apps/backend/mail/src/middleware/cookie-auth.ts create mode 100644 apps/backend/mail/test/auth-smoke.test.ts delete mode 100644 apps/backend/webhook/src/middleware/api-key-auth.ts delete mode 100644 apps/backend/webhook/src/middleware/cookie-auth.ts create mode 100644 apps/backend/webhook/test/auth-smoke.test.ts diff --git a/apps/backend/domain/package.json b/apps/backend/domain/package.json index d731663f7..c57c5beb8 100644 --- a/apps/backend/domain/package.json +++ b/apps/backend/domain/package.json @@ -11,7 +11,8 @@ "compile": "bun build --compile --minify --sourcemap --bytecode ./src/index.ts --outfile server", "lint": "biome check .", "fix-lint": "biome check --fix .", - "format": "biome format ." + "format": "biome format .", + "test": "bun test test/" }, "dependencies": { "@elysiajs/cors": "catalog:backend", @@ -40,7 +41,6 @@ "@reloop/db": "workspace:*", "@reloop/cache": "workspace:*", "@reloop/auth": "workspace:*", - "@reloop/apikey": "workspace:*", "@reloop/bus": "workspace:*", "@reloop/webhook-events": "workspace:*", "@elysiajs/openapi": "catalog:backend", diff --git a/apps/backend/domain/src/middleware/api-key-auth.ts b/apps/backend/domain/src/middleware/api-key-auth.ts deleted file mode 100644 index b0b886205..000000000 --- a/apps/backend/domain/src/middleware/api-key-auth.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { validateApiKey as validateApiKeyShared } from "@reloop/apikey"; -import { redis } from "@reloop/domain/utils/loader"; - -export async function validateApiKey(apiKey: string | null | undefined) { - return validateApiKeyShared(apiKey, redis); -} diff --git a/apps/backend/domain/src/middleware/auth.ts b/apps/backend/domain/src/middleware/auth.ts index d30adae36..780c6176c 100644 --- a/apps/backend/domain/src/middleware/auth.ts +++ b/apps/backend/domain/src/middleware/auth.ts @@ -1,153 +1,72 @@ -import { createId } from "@paralleldrive/cuid2"; +import { validateApiKey } from "@reloop/auth/apikey/validate"; +import { + createAuthPlugin, + SESSION_CACHE_REDIS_PREFIX, +} from "@reloop/auth/middleware"; +import { RedisCache } from "@reloop/cache/redis-client"; import { domainConfig } from "@reloop/domain/domain.config"; import { Elysia } from "elysia"; import { evlog } from "evlog/elysia"; -import { validateApiKey } from "./api-key-auth"; -import { validateSession } from "./cookie-auth"; if (domainConfig.NODE_ENV !== "production") { process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; } +const sessionRedis = new RedisCache( + SESSION_CACHE_REDIS_PREFIX, + 5, + domainConfig.REDIS_URL, +); + +/** + * Batch A migration: shared plugin + service-specific internal-secret macro + * for KumoMTA dkim-key injects. + */ export const authMiddleware = new Elysia({ name: "auth-middleware" }) .use(evlog()) + .use( + createAuthPlugin({ + baseUrl: domainConfig.BASE_URL, + redis: sessionRedis, + ttl: 5, + }), + ) .macro({ - cookieAuth: { - async resolve({ status, request: { headers }, log }) { - try { - const cookie = headers.get("cookie"); - const traceId = `req_${createId()}`; - log.set({ traceId, service: "domain" }); - const sessionResult = await validateSession(cookie); - if (sessionResult) { - log.set({ - ...sessionResult, - }); - log.info("Session authentication successful"); - return { ...sessionResult, traceId, logger: log }; - } - return status(401, { message: "Authentication required" }); - } catch (e) { - log.error("Authentication error", { - error: e instanceof Error ? e.message : "Unknown error", - stack: e instanceof Error ? e.stack : undefined, - }); - return status(401, { message: "Authentication failed" }); - } - }, - }, - apiKeyAuth: { - async resolve({ status, request: { headers }, log }) { - try { - const apiKey = headers.get("x-api-key"); - const traceId = `req_${createId()}`; - log.set({ traceId, service: "domain" }); - const apiKeyResult = await validateApiKey(apiKey); - if (apiKeyResult) { - log.set({ - ...apiKeyResult, - }); - log.info("API key authentication successful"); - return { ...apiKeyResult, traceId, logger: log }; - } - return status(401, { message: "Authentication required" }); - } catch (e) { - log.error("Authentication error", { - error: e instanceof Error ? e.message : "Unknown error", - stack: e instanceof Error ? e.stack : undefined, - }); - return status(401, { message: "Authentication failed" }); - } - }, - detail: { - security: [{ apiKey: [] }], - }, - }, /** * API key or internal service secret (KumoMTA → dkim-key for * already-authenticated mail-service injects). */ apiKeyOrInternalAuth: { - async resolve({ status, request: { headers }, log }) { - try { - const traceId = `req_${createId()}`; - log.set({ traceId, service: "domain" }); - - const apiKey = headers.get("x-api-key"); - const apiKeyResult = await validateApiKey(apiKey); - if (apiKeyResult) { - log.set({ - ...apiKeyResult, - }); - log.info("API key authentication successful"); - return { ...apiKeyResult, traceId, logger: log }; - } - - const internalSecret = headers.get("x-internal-secret"); - const organizationId = headers.get("x-organization-id"); - if ( - internalSecret && - organizationId && - domainConfig.RELOOP_INTERNAL_SECRET && - internalSecret === domainConfig.RELOOP_INTERNAL_SECRET - ) { - log.set({ - authType: "internal", - organizationId, - }); - log.info("Internal secret authentication successful"); - return { - organizationId, - authType: "internal" as const, - traceId, - logger: log, - }; - } - - return status(401, { message: "Authentication required" }); - } catch (e) { - log.error("Authentication error", { - error: e instanceof Error ? e.message : "Unknown error", - stack: e instanceof Error ? e.stack : undefined, - }); - return status(401, { message: "Authentication failed" }); + async resolve({ status, request: { headers } }) { + const apiKey = headers.get("x-api-key"); + const apiKeyResult = await validateApiKey(apiKey, sessionRedis); + if (apiKeyResult?.organizationId) { + return { + userId: apiKeyResult.userId, + organizationId: apiKeyResult.organizationId, + role: null as string | null, + authType: "apikey" as const, + apiKeyId: apiKeyResult.apiKeyId, + }; } - }, - detail: { - security: [{ apiKey: [] }], - }, - }, - auth: { - async resolve({ status, request: { headers }, log }) { - try { - const apiKey = headers.get("x-api-key"); - const cookie = headers.get("cookie"); - const traceId = `req_${createId()}`; - log.set({ traceId, service: "domain" }); - const apiKeyResult = await validateApiKey(apiKey); - if (apiKeyResult) { - log.set({ - ...apiKeyResult, - }); - log.info("API key authentication successful"); - return { ...apiKeyResult, traceId, logger: log }; - } - const sessionResult = await validateSession(cookie); - if (sessionResult) { - log.set({ - ...sessionResult, - }); - log.info("Session authentication successful"); - return { ...sessionResult, traceId, logger: log }; - } - return status(401, { message: "Authentication required" }); - } catch (e) { - log.error("Authentication error", { - error: e instanceof Error ? e.message : "Unknown error", - stack: e instanceof Error ? e.stack : undefined, - }); - return status(401, { message: "Authentication failed" }); + + const internalSecret = headers.get("x-internal-secret"); + const organizationId = headers.get("x-organization-id"); + if ( + internalSecret && + organizationId && + domainConfig.RELOOP_INTERNAL_SECRET && + internalSecret === domainConfig.RELOOP_INTERNAL_SECRET + ) { + return { + userId: "internal", + organizationId, + role: null as string | null, + authType: "session" as const, + }; } + + return status(401, { message: "Authentication required" }); }, detail: { security: [{ apiKey: [] }], diff --git a/apps/backend/domain/src/middleware/cookie-auth.ts b/apps/backend/domain/src/middleware/cookie-auth.ts deleted file mode 100644 index 6bb34f669..000000000 --- a/apps/backend/domain/src/middleware/cookie-auth.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { createHash } from "node:crypto"; -import { domainConfig } from "@reloop/domain/domain.config"; -import { redis } from "@reloop/domain/utils/loader"; - -type SessionResult = { - userId: string; - organizationId: string; - authType: "auth"; -}; - -export async function validateSession( - cookie: string | null, -): Promise { - if (!cookie) return null; - - const cacheKey = `session:${createHash("sha256").update(cookie).digest("hex")}`; - - const cached = await redis.get(cacheKey); - if (cached) return cached; - - const response = await fetch( - `${domainConfig.BASE_URL}/api/auth/v1/get-session`, - { - method: "GET", - headers: new Headers({ - "Content-Type": "application/json", - Cookie: cookie, - }), - }, - ); - - const session = (await response.json()) as { - user?: { - id: string; - activeOrganizationId?: string; - }; - }; - - if (session?.user?.activeOrganizationId) { - const result: SessionResult = { - userId: session.user.id, - organizationId: session.user.activeOrganizationId, - authType: "auth" as const, - }; - await redis.set(cacheKey, result, 30); - return result; - } - - return null; -} diff --git a/apps/backend/domain/src/routes/domain/get-domain-nameserver/get-domain-dns.route.ts b/apps/backend/domain/src/routes/domain/get-domain-nameserver/get-domain-dns.route.ts index 17165a3e2..8dcbf621b 100644 --- a/apps/backend/domain/src/routes/domain/get-domain-nameserver/get-domain-dns.route.ts +++ b/apps/backend/domain/src/routes/domain/get-domain-nameserver/get-domain-dns.route.ts @@ -13,7 +13,7 @@ export const getDomainNameserversRoute = new Elysia().use(authMiddleware).get( }); }, { - cookieAuth: true, + auth: true, params: t.Object({ domain_id: t.String(), }), diff --git a/apps/backend/domain/test/auth-smoke.test.ts b/apps/backend/domain/test/auth-smoke.test.ts new file mode 100644 index 000000000..8e0e493e2 --- /dev/null +++ b/apps/backend/domain/test/auth-smoke.test.ts @@ -0,0 +1,128 @@ +/** + * Smoke: webhook batch-A migration onto @reloop/auth/middleware. + */ +import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { randomBytes } from "node:crypto"; +import { + API_KEY_PREFIX, + getApiKeyCacheKey, + hashApiKey, +} from "@reloop/auth/apikey"; +import { + createAuthPlugin, + type AuthContext, +} from "@reloop/auth/middleware"; +import { Elysia } from "elysia"; + +class MemoryRedis { + private store = new Map(); + constructor(private prefix = "reloop-session") {} + private full(k: string) { + return `${this.prefix}:${k}`; + } + async get(key: string): Promise { + const raw = this.store.get(this.full(key)); + if (raw === undefined) return undefined; + try { + return JSON.parse(raw) as T; + } catch { + return raw as unknown as T; + } + } + async set(key: string, value: unknown): Promise { + this.store.set( + this.full(key), + typeof value === "string" ? value : JSON.stringify(value), + ); + } + async delete(key: string): Promise { + this.store.delete(this.full(key)); + } +} + +const sessions = new Map< + string, + { userId: string; activeOrganizationId?: string | null } +>(); +let fakeAuth: ReturnType | null = null; +let baseUrl = ""; + +beforeAll(async () => { + const app = new Elysia().get("/api/auth/v1/get-session", ({ request, set }) => { + const cookie = request.headers.get("cookie") ?? ""; + const match = cookie.match(/reloop\.session_token=([^.;]+)/); + const token = match?.[1]; + if (!token || !sessions.has(token)) { + set.status = 200; + return null; + } + const s = sessions.get(token)!; + return { + user: { + id: s.userId, + activeOrganizationId: s.activeOrganizationId ?? null, + }, + }; + }); + fakeAuth = app.listen(0); + baseUrl = `http://127.0.0.1:${fakeAuth.server?.port}`; +}); +afterAll(() => fakeAuth?.stop()); +beforeEach(() => sessions.clear()); + +function app(redis: MemoryRedis) { + return new Elysia() + .use(createAuthPlugin({ baseUrl, redis, ttl: 5 })) + .get( + "/protected", + ({ userId, organizationId, authType }) => ({ + userId, + organizationId, + authType, + }), + { auth: true }, + ); +} + +describe("domain pilot smoke", () => { + test("session with org → 200", async () => { + const redis = new MemoryRedis(); + sessions.set("tok", { + userId: "u1", + activeOrganizationId: "org-1", + }); + const res = await app(redis).handle( + new Request("http://localhost/protected", { + headers: { cookie: "reloop.session_token=tok.sig" }, + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as AuthContext; + expect(body.organizationId).toBe("org-1"); + expect(body.authType).toBe("session"); + }); + + test("no credentials → 401", async () => { + const res = await app(new MemoryRedis()).handle( + new Request("http://localhost/protected"), + ); + expect(res.status).toBe(401); + }); + + test("api key → 200", async () => { + const redis = new MemoryRedis(); + const raw = `${API_KEY_PREFIX}_${randomBytes(12).toString("base64url")}`; + await redis.set(getApiKeyCacheKey(hashApiKey(raw)), { + userId: "ku", + organizationId: "ko", + apiKeyId: "kid", + }); + const res = await app(redis).handle( + new Request("http://localhost/protected", { + headers: { "x-api-key": raw }, + }), + ); + expect(res.status).toBe(200); + expect(((await res.json()) as AuthContext).authType).toBe("apikey"); + }); +}); diff --git a/apps/backend/inbox/package.json b/apps/backend/inbox/package.json index 80992e0e6..0ac6c1e19 100644 --- a/apps/backend/inbox/package.json +++ b/apps/backend/inbox/package.json @@ -9,6 +9,7 @@ "dev": "bun run --hot src/index.ts", "fix-lint": "biome check --fix .", "format": "biome format .", + "test": "bun test test/", "lint": "biome check .", "start": "bun run dist/index.js" }, @@ -36,7 +37,6 @@ "pg": "catalog:db", "redis": "catalog:db", "zod": "catalog:ui", - "@reloop/apikey": "workspace:*", "@reloop/db": "workspace:*", "@reloop/cache": "workspace:*", "@reloop/auth": "workspace:*", diff --git a/apps/backend/inbox/src/middleware/api-key-auth.ts b/apps/backend/inbox/src/middleware/api-key-auth.ts deleted file mode 100644 index b6cbd3072..000000000 --- a/apps/backend/inbox/src/middleware/api-key-auth.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { validateApiKey as validateApiKeyShared } from "@reloop/apikey"; -import { redis } from "@reloop/be-inbox/utils/loader"; - -export async function validateApiKey(apiKey: string | null | undefined) { - return validateApiKeyShared(apiKey, redis); -} diff --git a/apps/backend/inbox/src/middleware/auth.ts b/apps/backend/inbox/src/middleware/auth.ts index 3c2d399d7..1aac345be 100644 --- a/apps/backend/inbox/src/middleware/auth.ts +++ b/apps/backend/inbox/src/middleware/auth.ts @@ -1,171 +1,29 @@ -import { createId } from "@paralleldrive/cuid2"; +import { + createAuthPlugin, + SESSION_CACHE_REDIS_PREFIX, +} from "@reloop/auth/middleware"; +import { RedisCache } from "@reloop/cache/redis-client"; import { Elysia } from "elysia"; import { evlog } from "evlog/elysia"; import { inboxConfig } from "../inbox.config"; -import { AuthErrors, InboxError } from "../lib/errors"; -import { validateApiKey } from "./api-key-auth"; -import { validateSession } from "./cookie-auth"; if (inboxConfig.NODE_ENV !== "production") { process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; } +const sessionRedis = new RedisCache( + SESSION_CACHE_REDIS_PREFIX, + 5, + inboxConfig.REDIS_URL, +); + +/** Batch A migration: shared auth plugin (fail-closed org via `auth`). */ export const authMiddleware = new Elysia({ name: "auth-middleware" }) .use(evlog()) - .macro({ - cookieAuth: { - async resolve({ request: { headers }, log }) { - const traceId = `req_${createId()}`; - log.set({ traceId }); - - try { - const cookie = headers.get("cookie"); - const sessionResult = await validateSession(cookie); - if (sessionResult) { - log.set({ - traceId, - service: "inbox", - authType: "session", - organizationId: sessionResult.activeOrganizationId, - userId: sessionResult.userId, - }); - return { - ...sessionResult, - organizationId: sessionResult.activeOrganizationId, - traceId, - }; - } - } catch (e) { - if (e instanceof InboxError) { - throw AuthErrors.authenticationFailed( - e.message, - "Authentication failed due to an inbox service error", - ); - } - log.error("Authentication error", { - error: e instanceof Error ? e.message : "Unknown error", - stack: e instanceof Error ? e.stack : undefined, - }); - throw AuthErrors.authenticationFailed( - e instanceof Error - ? e.message - : "Unknown error during authentication", - ); - } - - throw AuthErrors.unauthorized("No valid session cookie found"); - }, - }, - apiKeyAuth: { - async resolve({ request: { headers }, log }) { - const traceId = `req_${createId()}`; - log.set({ traceId, service: "inbox" }); - - try { - const apiKey = headers.get("x-api-key"); - const apiKeyResult = await validateApiKey(apiKey); - if (apiKeyResult) { - log.set({ - authType: "apiKey", - organizationId: apiKeyResult.organizationId, - userId: apiKeyResult.userId, - }); - log.info("API key authentication successful"); - return { - userId: apiKeyResult.userId, - organizationId: apiKeyResult.organizationId, - authType: apiKeyResult.authType, - apiKeyId: apiKeyResult.apiKeyId, - traceId, - }; - } - } catch (e) { - if (e instanceof InboxError) { - throw AuthErrors.authenticationFailed( - e.message, - "API key validation failed due to an inbox service error", - ); - } - log.error("Authentication error", { - error: e instanceof Error ? e.message : "Unknown error", - stack: e instanceof Error ? e.stack : undefined, - }); - throw AuthErrors.authenticationFailed( - e instanceof Error - ? e.message - : "Unknown error during API key validation (Internal server error)", - ); - } - - throw AuthErrors.unauthorized( - "No valid API key found in x-api-key header", - ); - }, - detail: { - security: [{ apiKey: [] }], - }, - }, - auth: { - async resolve({ request: { headers }, log }) { - const traceId = `req_${createId()}`; - log.set({ traceId, service: "inbox" }); - - try { - const apiKey = headers.get("x-api-key"); - const cookie = headers.get("cookie"); - const apiKeyResult = await validateApiKey(apiKey); - if (apiKeyResult) { - log.set({ - authType: "apiKey", - organizationId: apiKeyResult.organizationId, - userId: apiKeyResult.userId, - }); - log.info("API key authentication successful"); - return { - userId: apiKeyResult.userId, - organizationId: apiKeyResult.organizationId, - authType: apiKeyResult.authType, - apiKeyId: apiKeyResult.apiKeyId, - traceId, - }; - } - const sessionResult = await validateSession(cookie); - if (sessionResult) { - log.set({ - authType: "session", - organizationId: sessionResult.activeOrganizationId, - userId: sessionResult.userId, - }); - log.info("Session authentication successful"); - return { - ...sessionResult, - organizationId: sessionResult.activeOrganizationId, - traceId, - }; - } - } catch (e) { - if (e instanceof InboxError) { - throw AuthErrors.authenticationFailed( - e.message, - "Authentication failed due to an inbox service error", - ); - } - log.error("Authentication error", { - error: e instanceof Error ? e.message : "Unknown error", - stack: e instanceof Error ? e.stack : undefined, - }); - throw AuthErrors.authenticationFailed( - e instanceof Error - ? e.message - : "Unknown error during authentication (Internal server error)", - ); - } - throw AuthErrors.unauthorized( - "Neither a valid API key nor a session cookie was found", - ); - }, - detail: { - security: [{ apiKey: [] }], - }, - }, - }); + .use( + createAuthPlugin({ + baseUrl: inboxConfig.BASE_URL, + redis: sessionRedis, + ttl: 5, + }), + ); diff --git a/apps/backend/inbox/src/middleware/cookie-auth.ts b/apps/backend/inbox/src/middleware/cookie-auth.ts deleted file mode 100644 index b62c2a54f..000000000 --- a/apps/backend/inbox/src/middleware/cookie-auth.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { inboxConfig } from "../inbox.config"; - -export async function validateSession(cookie: string | null) { - const response = await fetch( - `${inboxConfig.BASE_URL}/api/auth/v1/get-session`, - { - method: "GET", - headers: new Headers({ - "Content-Type": "application/json", - Cookie: cookie || "", - }), - }, - ); - - const session = (await response.json()) as { - user?: { - id: string; - activeOrganizationId?: string; - }; - }; - - if (session?.user?.activeOrganizationId) { - return { - userId: session.user.id, - activeOrganizationId: session.user.activeOrganizationId, - authType: "auth" as const, - }; - } - - return null; -} diff --git a/apps/backend/inbox/test/auth-smoke.test.ts b/apps/backend/inbox/test/auth-smoke.test.ts new file mode 100644 index 000000000..58e4f8d24 --- /dev/null +++ b/apps/backend/inbox/test/auth-smoke.test.ts @@ -0,0 +1,128 @@ +/** + * Smoke: webhook batch-A migration onto @reloop/auth/middleware. + */ +import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { randomBytes } from "node:crypto"; +import { + API_KEY_PREFIX, + getApiKeyCacheKey, + hashApiKey, +} from "@reloop/auth/apikey"; +import { + createAuthPlugin, + type AuthContext, +} from "@reloop/auth/middleware"; +import { Elysia } from "elysia"; + +class MemoryRedis { + private store = new Map(); + constructor(private prefix = "reloop-session") {} + private full(k: string) { + return `${this.prefix}:${k}`; + } + async get(key: string): Promise { + const raw = this.store.get(this.full(key)); + if (raw === undefined) return undefined; + try { + return JSON.parse(raw) as T; + } catch { + return raw as unknown as T; + } + } + async set(key: string, value: unknown): Promise { + this.store.set( + this.full(key), + typeof value === "string" ? value : JSON.stringify(value), + ); + } + async delete(key: string): Promise { + this.store.delete(this.full(key)); + } +} + +const sessions = new Map< + string, + { userId: string; activeOrganizationId?: string | null } +>(); +let fakeAuth: ReturnType | null = null; +let baseUrl = ""; + +beforeAll(async () => { + const app = new Elysia().get("/api/auth/v1/get-session", ({ request, set }) => { + const cookie = request.headers.get("cookie") ?? ""; + const match = cookie.match(/reloop\.session_token=([^.;]+)/); + const token = match?.[1]; + if (!token || !sessions.has(token)) { + set.status = 200; + return null; + } + const s = sessions.get(token)!; + return { + user: { + id: s.userId, + activeOrganizationId: s.activeOrganizationId ?? null, + }, + }; + }); + fakeAuth = app.listen(0); + baseUrl = `http://127.0.0.1:${fakeAuth.server?.port}`; +}); +afterAll(() => fakeAuth?.stop()); +beforeEach(() => sessions.clear()); + +function app(redis: MemoryRedis) { + return new Elysia() + .use(createAuthPlugin({ baseUrl, redis, ttl: 5 })) + .get( + "/protected", + ({ userId, organizationId, authType }) => ({ + userId, + organizationId, + authType, + }), + { auth: true }, + ); +} + +describe("inbox pilot smoke", () => { + test("session with org → 200", async () => { + const redis = new MemoryRedis(); + sessions.set("tok", { + userId: "u1", + activeOrganizationId: "org-1", + }); + const res = await app(redis).handle( + new Request("http://localhost/protected", { + headers: { cookie: "reloop.session_token=tok.sig" }, + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as AuthContext; + expect(body.organizationId).toBe("org-1"); + expect(body.authType).toBe("session"); + }); + + test("no credentials → 401", async () => { + const res = await app(new MemoryRedis()).handle( + new Request("http://localhost/protected"), + ); + expect(res.status).toBe(401); + }); + + test("api key → 200", async () => { + const redis = new MemoryRedis(); + const raw = `${API_KEY_PREFIX}_${randomBytes(12).toString("base64url")}`; + await redis.set(getApiKeyCacheKey(hashApiKey(raw)), { + userId: "ku", + organizationId: "ko", + apiKeyId: "kid", + }); + const res = await app(redis).handle( + new Request("http://localhost/protected", { + headers: { "x-api-key": raw }, + }), + ); + expect(res.status).toBe(200); + expect(((await res.json()) as AuthContext).authType).toBe("apikey"); + }); +}); diff --git a/apps/backend/mail/package.json b/apps/backend/mail/package.json index 8f233d03a..99e7f4ea0 100644 --- a/apps/backend/mail/package.json +++ b/apps/backend/mail/package.json @@ -9,6 +9,7 @@ "dev": "bun run --hot src/index.ts", "fix-lint": "biome check --fix .", "format": "biome format .", + "test": "bun test test/", "lint": "biome check .", "start": "bun run dist/index.js" }, @@ -35,7 +36,6 @@ "pg": "catalog:db", "redis": "catalog:db", "zod": "catalog:ui", - "@reloop/apikey": "workspace:*", "@reloop/db": "workspace:*", "@reloop/cache": "workspace:*", "@reloop/auth": "workspace:*", diff --git a/apps/backend/mail/src/middleware/api-key-auth.ts b/apps/backend/mail/src/middleware/api-key-auth.ts deleted file mode 100644 index 3f409a467..000000000 --- a/apps/backend/mail/src/middleware/api-key-auth.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { validateApiKey as validateApiKeyShared } from "@reloop/apikey"; -import { redis } from "@reloop/be-mail/utils/loader"; - -export async function validateApiKey(apiKey: string | null | undefined) { - return validateApiKeyShared(apiKey, redis); -} diff --git a/apps/backend/mail/src/middleware/auth.ts b/apps/backend/mail/src/middleware/auth.ts index d5eaa4b48..6ed2f6843 100644 --- a/apps/backend/mail/src/middleware/auth.ts +++ b/apps/backend/mail/src/middleware/auth.ts @@ -1,181 +1,99 @@ -import { createId } from "@paralleldrive/cuid2"; -import { AuthErrors, MailError } from "@reloop/be-mail/lib/errors"; +import { validateApiKey } from "@reloop/auth/apikey/validate"; +import { + createAuthPlugin, + resolveSession, + SESSION_CACHE_REDIS_PREFIX, + type AuthContext, +} from "@reloop/auth/middleware"; +import { RedisCache } from "@reloop/cache/redis-client"; import { Elysia } from "elysia"; import { evlog } from "evlog/elysia"; import { mailConfig } from "../mail.config"; -import { validateApiKey } from "./api-key-auth"; -import { validateSession } from "./cookie-auth"; if (mailConfig.NODE_ENV !== "production") { process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; } +const sessionRedis = new RedisCache( + SESSION_CACHE_REDIS_PREFIX, + 5, + mailConfig.REDIS_URL, +); + +export type MailAuthContext = AuthContext & { + /** Present when authenticated via API key (used for Kumo inject path). */ + apiKeyId?: string; +}; + +/** + * Batch A migration: shared plugin macros + mail-specific `auth` that also + * accepts the internal service secret (session/API-key inject path). + * + * Note: we re-declare `auth` after the shared plugin so the send-email route + * keeps `auth: true` while gaining internal-secret support. + */ export const authMiddleware = new Elysia({ name: "auth-middleware" }) .use(evlog()) + .use( + createAuthPlugin({ + baseUrl: mailConfig.BASE_URL, + redis: sessionRedis, + ttl: 5, + }), + ) .macro({ - cookieAuth: { - async resolve({ request: { headers }, log }) { - const traceId = `req_${createId()}`; - log.set({ traceId }); - - try { - const cookie = headers.get("cookie"); - const sessionResult = await validateSession(cookie); - if (sessionResult) { - log.set({ - traceId, - service: "mail", - authType: "session", - activeOrganizationId: sessionResult.activeOrganizationId, - userId: sessionResult.userId, - }); - return { ...sessionResult, traceId }; - } - } catch (e) { - if (e instanceof MailError) { - throw AuthErrors.authenticationFailed( - e.message, - "Authentication failed due to a mail service error", - ); - } - log.error("Authentication error", { - error: e instanceof Error ? e.message : "Unknown error", - stack: e instanceof Error ? e.stack : undefined, - }); - throw AuthErrors.authenticationFailed( - e instanceof Error - ? e.message - : "Unknown error during authentication", - ); - } - - throw AuthErrors.unauthorized("No valid session cookie found"); - }, - }, - apiKeyAuth: { - async resolve({ request: { headers }, log }) { - const traceId = `req_${createId()}`; - log.set({ traceId, service: "mail" }); - - try { - const apiKey = headers.get("x-api-key"); - const apiKeyResult = await validateApiKey(apiKey); - if (apiKeyResult) { - log.set({ - authType: "apiKey", - activeOrganizationId: apiKeyResult.organizationId, - userId: apiKeyResult.userId, - }); - log.info("API key authentication successful"); - return { - userId: apiKeyResult.userId, - activeOrganizationId: apiKeyResult.organizationId, - authType: apiKeyResult.authType, - apiKeyId: apiKeyResult.apiKeyId, - traceId, - }; - } - } catch (e) { - if (e instanceof MailError) { - throw AuthErrors.authenticationFailed( - e.message, - "API key validation failed due to a mail service error", - ); - } - log.error("Authentication error", { - error: e instanceof Error ? e.message : "Unknown error", - stack: e instanceof Error ? e.stack : undefined, - }); - throw AuthErrors.authenticationFailed( - e instanceof Error - ? e.message - : "Unknown error during API key validation (Internal server error)", - ); - } - - throw AuthErrors.unauthorized( - "No valid API key found in x-api-key header", - ); - }, - detail: { - security: [{ apiKey: [] }], - }, - }, + /** + * Fail-closed org auth: API key → internal secret → session. + * Returns canonical AuthContext fields (+ optional apiKeyId). + */ auth: { - async resolve({ request: { headers }, log }) { - const traceId = `req_${createId()}`; - log.set({ traceId, service: "mail" }); - - try { - const apiKey = headers.get("x-api-key"); - const cookie = headers.get("cookie"); - const apiKeyResult = await validateApiKey(apiKey); - if (apiKeyResult) { - log.set({ - authType: "apiKey", - activeOrganizationId: apiKeyResult.organizationId, - userId: apiKeyResult.userId, - }); - log.info("API key authentication successful"); + async resolve({ status, request: { headers } }) { + const apiKey = headers.get("x-api-key"); + if (apiKey) { + const apiKeyResult = await validateApiKey(apiKey, sessionRedis); + if (apiKeyResult?.organizationId) { return { userId: apiKeyResult.userId, - activeOrganizationId: apiKeyResult.organizationId, - authType: apiKeyResult.authType, + organizationId: apiKeyResult.organizationId, + role: null as string | null, + authType: "apikey" as const, apiKeyId: apiKeyResult.apiKeyId, - traceId, }; } + } - const internalSecret = headers.get("x-internal-secret"); - const organizationId = headers.get("x-organization-id"); - if ( - internalSecret && - organizationId && - mailConfig.RELOOP_INTERNAL_SECRET && - internalSecret === mailConfig.RELOOP_INTERNAL_SECRET - ) { - log.set({ - authType: "internal", - activeOrganizationId: organizationId, - }); - log.info("Internal secret authentication successful"); - return { - activeOrganizationId: organizationId, - authType: "internal" as const, - traceId, - }; - } + const internalSecret = headers.get("x-internal-secret"); + const organizationId = headers.get("x-organization-id"); + if ( + internalSecret && + organizationId && + mailConfig.RELOOP_INTERNAL_SECRET && + internalSecret === mailConfig.RELOOP_INTERNAL_SECRET + ) { + return { + userId: "internal", + organizationId, + role: null as string | null, + authType: "session" as const, + }; + } - const sessionResult = await validateSession(cookie); - if (sessionResult) { - log.set({ - authType: "session", - activeOrganizationId: sessionResult.activeOrganizationId, - userId: sessionResult.userId, - }); - log.info("Session authentication successful"); - return { ...sessionResult, traceId }; - } - } catch (e) { - if (e instanceof MailError) { - throw AuthErrors.authenticationFailed( - e.message, - "Authentication failed due to a mail service error", - ); - } - log.error("Authentication error", { - error: e instanceof Error ? e.message : "Unknown error", - stack: e instanceof Error ? e.stack : undefined, - }); - throw AuthErrors.authenticationFailed( - e instanceof Error - ? e.message - : "Unknown error during authentication (Internal server error)", - ); + const session = await resolveSession(headers.get("cookie"), { + baseUrl: mailConfig.BASE_URL, + redis: sessionRedis, + ttl: 5, + requireOrg: true, + }); + if (session?.organizationId) { + return { + userId: session.userId, + organizationId: session.organizationId, + role: session.role, + authType: session.authType, + }; } - throw AuthErrors.unauthorized( - "Neither a valid API key nor a session cookie was found", - ); + + return status(401, { message: "Authentication required" }); }, detail: { security: [{ apiKey: [] }], diff --git a/apps/backend/mail/src/middleware/cookie-auth.ts b/apps/backend/mail/src/middleware/cookie-auth.ts deleted file mode 100644 index fefb96d09..000000000 --- a/apps/backend/mail/src/middleware/cookie-auth.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { mailConfig } from "@reloop/be-mail/mail.config"; - -export async function validateSession(cookie: string | null) { - if (!cookie) { - return null; - } - - const response = await fetch( - `${mailConfig.BASE_URL}/api/auth/v1/get-session`, - { - method: "GET", - headers: new Headers({ - "Content-Type": "application/json", - Cookie: cookie, - }), - }, - ); - - if (!response.ok) { - return null; - } - - const session = (await response.json()) as { - user?: { - id: string; - activeOrganizationId?: string; - }; - }; - - if (session?.user?.activeOrganizationId) { - return { - userId: session.user.id, - activeOrganizationId: session.user.activeOrganizationId, - authType: "session" as const, - }; - } - - return null; -} diff --git a/apps/backend/mail/src/routes/mail/send-email/send-email.route.ts b/apps/backend/mail/src/routes/mail/send-email/send-email.route.ts index 276de016e..5cc4f98a0 100644 --- a/apps/backend/mail/src/routes/mail/send-email/send-email.route.ts +++ b/apps/backend/mail/src/routes/mail/send-email/send-email.route.ts @@ -15,8 +15,9 @@ export const sendEmailRoute = new Elysia() "/send", async ({ body, - activeOrganizationId, + organizationId, userId, + authType, apiKeyId, request, set, @@ -25,7 +26,7 @@ export const sendEmailRoute = new Elysia() // Rate limit check — runs after auth so we have org/user IDs const rateLimitHeaders = await checkRateLimit({ headers: request.headers, - activeOrganizationId, + activeOrganizationId: organizationId, userId, log, }); @@ -39,13 +40,13 @@ export const sendEmailRoute = new Elysia() // External API key path uses the caller's key for KumoMTA. // Session/internal auth uses the shared internal secret so inject // can authenticate without a recoverable plaintext org API key. - const useInternalInject = !apiKeyId; + const useInternalInject = authType !== "apikey"; const injectApiKey = useInternalInject ? mailConfig.RELOOP_INTERNAL_SECRET : (requestApiKey ?? ""); return await sendEmailController({ - organizationId: activeOrganizationId, + organizationId, body, apiKey: injectApiKey, apiKeyId, diff --git a/apps/backend/mail/test/auth-smoke.test.ts b/apps/backend/mail/test/auth-smoke.test.ts new file mode 100644 index 000000000..ad991ff97 --- /dev/null +++ b/apps/backend/mail/test/auth-smoke.test.ts @@ -0,0 +1,128 @@ +/** + * Smoke: webhook batch-A migration onto @reloop/auth/middleware. + */ +import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { randomBytes } from "node:crypto"; +import { + API_KEY_PREFIX, + getApiKeyCacheKey, + hashApiKey, +} from "@reloop/auth/apikey"; +import { + createAuthPlugin, + type AuthContext, +} from "@reloop/auth/middleware"; +import { Elysia } from "elysia"; + +class MemoryRedis { + private store = new Map(); + constructor(private prefix = "reloop-session") {} + private full(k: string) { + return `${this.prefix}:${k}`; + } + async get(key: string): Promise { + const raw = this.store.get(this.full(key)); + if (raw === undefined) return undefined; + try { + return JSON.parse(raw) as T; + } catch { + return raw as unknown as T; + } + } + async set(key: string, value: unknown): Promise { + this.store.set( + this.full(key), + typeof value === "string" ? value : JSON.stringify(value), + ); + } + async delete(key: string): Promise { + this.store.delete(this.full(key)); + } +} + +const sessions = new Map< + string, + { userId: string; activeOrganizationId?: string | null } +>(); +let fakeAuth: ReturnType | null = null; +let baseUrl = ""; + +beforeAll(async () => { + const app = new Elysia().get("/api/auth/v1/get-session", ({ request, set }) => { + const cookie = request.headers.get("cookie") ?? ""; + const match = cookie.match(/reloop\.session_token=([^.;]+)/); + const token = match?.[1]; + if (!token || !sessions.has(token)) { + set.status = 200; + return null; + } + const s = sessions.get(token)!; + return { + user: { + id: s.userId, + activeOrganizationId: s.activeOrganizationId ?? null, + }, + }; + }); + fakeAuth = app.listen(0); + baseUrl = `http://127.0.0.1:${fakeAuth.server?.port}`; +}); +afterAll(() => fakeAuth?.stop()); +beforeEach(() => sessions.clear()); + +function app(redis: MemoryRedis) { + return new Elysia() + .use(createAuthPlugin({ baseUrl, redis, ttl: 5 })) + .get( + "/protected", + ({ userId, organizationId, authType }) => ({ + userId, + organizationId, + authType, + }), + { auth: true }, + ); +} + +describe("mail pilot smoke", () => { + test("session with org → 200", async () => { + const redis = new MemoryRedis(); + sessions.set("tok", { + userId: "u1", + activeOrganizationId: "org-1", + }); + const res = await app(redis).handle( + new Request("http://localhost/protected", { + headers: { cookie: "reloop.session_token=tok.sig" }, + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as AuthContext; + expect(body.organizationId).toBe("org-1"); + expect(body.authType).toBe("session"); + }); + + test("no credentials → 401", async () => { + const res = await app(new MemoryRedis()).handle( + new Request("http://localhost/protected"), + ); + expect(res.status).toBe(401); + }); + + test("api key → 200", async () => { + const redis = new MemoryRedis(); + const raw = `${API_KEY_PREFIX}_${randomBytes(12).toString("base64url")}`; + await redis.set(getApiKeyCacheKey(hashApiKey(raw)), { + userId: "ku", + organizationId: "ko", + apiKeyId: "kid", + }); + const res = await app(redis).handle( + new Request("http://localhost/protected", { + headers: { "x-api-key": raw }, + }), + ); + expect(res.status).toBe(200); + expect(((await res.json()) as AuthContext).authType).toBe("apikey"); + }); +}); diff --git a/apps/backend/webhook/package.json b/apps/backend/webhook/package.json index 637e061d2..920bcd468 100644 --- a/apps/backend/webhook/package.json +++ b/apps/backend/webhook/package.json @@ -10,7 +10,8 @@ "start": "bun run dist/index.js", "lint": "biome check .", "fix-lint": "biome check --fix .", - "format": "biome format ." + "format": "biome format .", + "test": "bun test test/" }, "dependencies": { "bullmq": "catalog:backend", @@ -38,7 +39,6 @@ "@reloop/db": "workspace:*", "@reloop/cache": "workspace:*", "@reloop/auth": "workspace:*", - "@reloop/apikey": "workspace:*", "@reloop/bus": "workspace:*", "@reloop/webhook-events": "workspace:*", "@elysiajs/openapi": "catalog:backend", diff --git a/apps/backend/webhook/src/middleware/api-key-auth.ts b/apps/backend/webhook/src/middleware/api-key-auth.ts deleted file mode 100644 index b31ed0407..000000000 --- a/apps/backend/webhook/src/middleware/api-key-auth.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { validateApiKey as validateApiKeyShared } from "@reloop/apikey"; -import { redis } from "@reloop/webhook/utils/loader"; - -export async function validateApiKey(apiKey: string | null | undefined) { - const result = await validateApiKeyShared(apiKey, redis); - if (!result) return null; - return { - userId: result.userId, - organizationId: result.organizationId, - authType: result.authType, - }; -} diff --git a/apps/backend/webhook/src/middleware/auth.ts b/apps/backend/webhook/src/middleware/auth.ts index 2acf8c10e..7a2329fa1 100644 --- a/apps/backend/webhook/src/middleware/auth.ts +++ b/apps/backend/webhook/src/middleware/auth.ts @@ -1,110 +1,29 @@ -import { createId } from "@paralleldrive/cuid2"; -import { webhookConfig } from "@reloop/webhook/webhook.config"; +import { + createAuthPlugin, + SESSION_CACHE_REDIS_PREFIX, +} from "@reloop/auth/middleware"; +import { RedisCache } from "@reloop/cache/redis-client"; import { Elysia } from "elysia"; import { evlog } from "evlog/elysia"; -import { validateApiKey } from "./api-key-auth"; -import { validateSession } from "./cookie-auth"; +import { webhookConfig } from "../webhook.config"; if (webhookConfig.NODE_ENV !== "production") { process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; } +const sessionRedis = new RedisCache( + SESSION_CACHE_REDIS_PREFIX, + 5, + webhookConfig.REDIS_URL, +); + +/** Batch A migration: shared auth plugin (fail-closed org via `auth`). */ export const authMiddleware = new Elysia({ name: "auth-middleware" }) .use(evlog()) - .macro({ - cookieAuth: { - async resolve({ status, request: { headers }, log }) { - try { - const cookie = headers.get("cookie"); - const traceId = `req_${createId()}`; - log.set({ traceId, service: "webhook" }); - const sessionResult = await validateSession(cookie); - if (sessionResult) { - log.set({ - user: sessionResult.userId, - organizationId: sessionResult.organizationId, - authType: sessionResult.authType, - }); - log.info("Session authentication successful"); - return { ...sessionResult, traceId }; - } - return status(401, { message: "Authentication required" }); - } catch (e) { - log.error("Authentication error", { - error: e instanceof Error ? e.message : "Unknown error", - stack: e instanceof Error ? e.stack : undefined, - }); - return status(401, { message: "Authentication failed" }); - } - }, - }, - apiKeyAuth: { - async resolve({ status, request: { headers }, log }) { - try { - const apiKey = headers.get("x-api-key"); - const traceId = `req_${createId()}`; - log.set({ traceId, service: "webhook" }); - const apiKeyResult = await validateApiKey(apiKey); - if (apiKeyResult) { - log.set({ - user: apiKeyResult.userId, - organizationId: apiKeyResult.organizationId, - authType: apiKeyResult.authType, - }); - log.info("API key authentication successful"); - return { ...apiKeyResult, traceId }; - } - return status(401, { message: "Authentication required" }); - } catch (e) { - log.error("Authentication error", { - error: e instanceof Error ? e.message : "Unknown error", - stack: e instanceof Error ? e.stack : undefined, - }); - return status(401, { message: "Authentication failed" }); - } - }, - detail: { - security: [{ apiKey: [] }], - }, - }, - auth: { - async resolve({ status, request: { headers }, log }) { - try { - const apiKey = headers.get("x-api-key"); - const cookie = headers.get("cookie"); - const traceId = `req_${createId()}`; - log.set({ traceId, service: "webhook" }); - const apiKeyResult = await validateApiKey(apiKey); - if (apiKeyResult) { - log.set({ - user: apiKeyResult.userId, - organizationId: apiKeyResult.organizationId, - authType: apiKeyResult.authType, - }); - log.info("API key authentication successful"); - return { ...apiKeyResult, traceId }; - } - const sessionResult = await validateSession(cookie); - if (sessionResult) { - log.set({ - user: sessionResult.userId, - organizationId: sessionResult.organizationId, - authType: sessionResult.authType, - }); - log.info("Session authentication successful"); - return { ...sessionResult, traceId }; - } - return status(401, { message: "Authentication required" }); - } catch (e) { - log.error("Authentication error", { - error: e instanceof Error ? e.message : "Unknown error", - stack: e instanceof Error ? e.stack : undefined, - }); - return status(401, { message: "Authentication failed" }); - } - }, - detail: { - security: [{ apiKey: [] }], - }, - }, - }); + .use( + createAuthPlugin({ + baseUrl: webhookConfig.BASE_URL, + redis: sessionRedis, + ttl: 5, + }), + ); diff --git a/apps/backend/webhook/src/middleware/cookie-auth.ts b/apps/backend/webhook/src/middleware/cookie-auth.ts deleted file mode 100644 index d14eb28d8..000000000 --- a/apps/backend/webhook/src/middleware/cookie-auth.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { log } from "evlog"; -import { webhookConfig } from "../webhook.config"; - -export async function validateSession(cookie: string | null) { - log.info({ - url: `${webhookConfig.BASE_URL}/api/auth/v1/get-session`, - message: "Validating session via Auth service", - }); - const response = await fetch( - `${webhookConfig.BASE_URL}/api/auth/v1/get-session`, - { - method: "GET", - headers: new Headers({ - "Content-Type": "application/json", - Cookie: cookie || "", - }), - }, - ); - - const session = (await response.json()) as { - user?: { - id: string; - activeOrganizationId?: string; - }; - }; - if (session?.user?.activeOrganizationId) { - return { - userId: session.user.id, - organizationId: session.user.activeOrganizationId, - authType: "auth" as const, - }; - } - - return null; -} diff --git a/apps/backend/webhook/test/auth-smoke.test.ts b/apps/backend/webhook/test/auth-smoke.test.ts new file mode 100644 index 000000000..fe772d886 --- /dev/null +++ b/apps/backend/webhook/test/auth-smoke.test.ts @@ -0,0 +1,128 @@ +/** + * Smoke: webhook batch-A migration onto @reloop/auth/middleware. + */ +import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { randomBytes } from "node:crypto"; +import { + API_KEY_PREFIX, + getApiKeyCacheKey, + hashApiKey, +} from "@reloop/auth/apikey"; +import { + createAuthPlugin, + type AuthContext, +} from "@reloop/auth/middleware"; +import { Elysia } from "elysia"; + +class MemoryRedis { + private store = new Map(); + constructor(private prefix = "reloop-session") {} + private full(k: string) { + return `${this.prefix}:${k}`; + } + async get(key: string): Promise { + const raw = this.store.get(this.full(key)); + if (raw === undefined) return undefined; + try { + return JSON.parse(raw) as T; + } catch { + return raw as unknown as T; + } + } + async set(key: string, value: unknown): Promise { + this.store.set( + this.full(key), + typeof value === "string" ? value : JSON.stringify(value), + ); + } + async delete(key: string): Promise { + this.store.delete(this.full(key)); + } +} + +const sessions = new Map< + string, + { userId: string; activeOrganizationId?: string | null } +>(); +let fakeAuth: ReturnType | null = null; +let baseUrl = ""; + +beforeAll(async () => { + const app = new Elysia().get("/api/auth/v1/get-session", ({ request, set }) => { + const cookie = request.headers.get("cookie") ?? ""; + const match = cookie.match(/reloop\.session_token=([^.;]+)/); + const token = match?.[1]; + if (!token || !sessions.has(token)) { + set.status = 200; + return null; + } + const s = sessions.get(token)!; + return { + user: { + id: s.userId, + activeOrganizationId: s.activeOrganizationId ?? null, + }, + }; + }); + fakeAuth = app.listen(0); + baseUrl = `http://127.0.0.1:${fakeAuth.server?.port}`; +}); +afterAll(() => fakeAuth?.stop()); +beforeEach(() => sessions.clear()); + +function app(redis: MemoryRedis) { + return new Elysia() + .use(createAuthPlugin({ baseUrl, redis, ttl: 5 })) + .get( + "/protected", + ({ userId, organizationId, authType }) => ({ + userId, + organizationId, + authType, + }), + { auth: true }, + ); +} + +describe("webhook pilot smoke", () => { + test("session with org → 200", async () => { + const redis = new MemoryRedis(); + sessions.set("tok", { + userId: "u1", + activeOrganizationId: "org-1", + }); + const res = await app(redis).handle( + new Request("http://localhost/protected", { + headers: { cookie: "reloop.session_token=tok.sig" }, + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as AuthContext; + expect(body.organizationId).toBe("org-1"); + expect(body.authType).toBe("session"); + }); + + test("no credentials → 401", async () => { + const res = await app(new MemoryRedis()).handle( + new Request("http://localhost/protected"), + ); + expect(res.status).toBe(401); + }); + + test("api key → 200", async () => { + const redis = new MemoryRedis(); + const raw = `${API_KEY_PREFIX}_${randomBytes(12).toString("base64url")}`; + await redis.set(getApiKeyCacheKey(hashApiKey(raw)), { + userId: "ku", + organizationId: "ko", + apiKeyId: "kid", + }); + const res = await app(redis).handle( + new Request("http://localhost/protected", { + headers: { "x-api-key": raw }, + }), + ); + expect(res.status).toBe(200); + expect(((await res.json()) as AuthContext).authType).toBe("apikey"); + }); +}); diff --git a/bun.lock b/bun.lock index 25fba5501..c5c97a949 100644 --- a/bun.lock +++ b/bun.lock @@ -215,7 +215,6 @@ "@opentelemetry/sdk-trace-node": "catalog:backend", "@opentelemetry/semantic-conventions": "catalog:backend", "@paralleldrive/cuid2": "catalog:backend", - "@reloop/apikey": "workspace:*", "@reloop/auth": "workspace:*", "@reloop/bus": "workspace:*", "@reloop/cache": "workspace:*", @@ -300,7 +299,6 @@ "@opentelemetry/sdk-trace-node": "catalog:backend", "@opentelemetry/semantic-conventions": "catalog:backend", "@paralleldrive/cuid2": "catalog:backend", - "@reloop/apikey": "workspace:*", "@reloop/auth": "workspace:*", "@reloop/bus": "workspace:*", "@reloop/cache": "workspace:*", @@ -384,7 +382,6 @@ "@opentelemetry/sdk-trace-node": "catalog:backend", "@opentelemetry/semantic-conventions": "catalog:backend", "@paralleldrive/cuid2": "catalog:backend", - "@reloop/apikey": "workspace:*", "@reloop/auth": "workspace:*", "@reloop/bus": "workspace:*", "@reloop/cache": "workspace:*", @@ -521,7 +518,6 @@ "@opentelemetry/sdk-trace-node": "catalog:backend", "@opentelemetry/semantic-conventions": "catalog:backend", "@paralleldrive/cuid2": "catalog:backend", - "@reloop/apikey": "workspace:*", "@reloop/auth": "workspace:*", "@reloop/bus": "workspace:*", "@reloop/cache": "workspace:*", diff --git a/packages/auth/src/middleware/plugin.ts b/packages/auth/src/middleware/plugin.ts index f7e83e2d9..2a1f2834b 100644 --- a/packages/auth/src/middleware/plugin.ts +++ b/packages/auth/src/middleware/plugin.ts @@ -18,10 +18,12 @@ type ResolveOpts = { requirePlatformAdmin: boolean; }; +type ResolvedAuth = AuthContext & { apiKeyId?: string }; + async function resolveFromHeaders( headers: Headers, opts: ResolveOpts, -): Promise { +): Promise { if (opts.allowApiKey) { const apiKey = headers.get("x-api-key") || @@ -30,14 +32,15 @@ async function resolveFromHeaders( const result = await validateApiKey(apiKey, opts.redis); if (result) { if (opts.requireOrg && !result.organizationId) return null; - const ctx: AuthContext = { + if (opts.requirePlatformAdmin) return null; + // Extra apiKeyId is useful for audit logs; not part of AuthContext. + return { userId: result.userId, organizationId: result.organizationId, role: null, - authType: "apikey", + authType: "apikey" as const, + apiKeyId: result.apiKeyId, }; - if (opts.requirePlatformAdmin) return null; - return ctx; } } } @@ -83,10 +86,17 @@ export function createAuthPlugin(config: AuthMiddlewareConfig) { allowApiKey: true, requirePlatformAdmin: false, }); - if (!ctx) { + // Fail-closed: organizationId is always a non-null string here. + if (!ctx?.organizationId) { return status(401, { message: "Authentication required" }); } - return ctx; + return { + userId: ctx.userId, + organizationId: ctx.organizationId, + role: ctx.role, + authType: ctx.authType, + ...(ctx.apiKeyId ? { apiKeyId: ctx.apiKeyId } : {}), + }; }, detail: { security: [{ apiKey: [] }], @@ -123,13 +133,13 @@ export function createAuthPlugin(config: AuthMiddlewareConfig) { if (!result?.organizationId) { return status(401, { message: "Authentication required" }); } - const ctx: AuthContext = { + return { userId: result.userId, organizationId: result.organizationId, - role: null, - authType: "apikey", + role: null as string | null, + authType: "apikey" as const, + apiKeyId: result.apiKeyId, }; - return ctx; }, detail: { security: [{ apiKey: [] }],