diff --git a/apps/backend/api-key/package.json b/apps/backend/api-key/package.json index 58e7ae32a..f17817cb3 100644 --- a/apps/backend/api-key/package.json +++ b/apps/backend/api-key/package.json @@ -10,7 +10,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", diff --git a/apps/backend/api-key/src/middleware/api-key-auth.ts b/apps/backend/api-key/src/middleware/api-key-auth.ts deleted file mode 100644 index d921d4d4d..000000000 --- a/apps/backend/api-key/src/middleware/api-key-auth.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { redis } from "@reloop/api-key/utils/loader"; -import { validateApiKey as validateApiKeyShared } from "@reloop/apikey"; - -export async function validateApiKey(apiKey: string | null | undefined) { - return validateApiKeyShared(apiKey, redis); -} diff --git a/apps/backend/api-key/src/middleware/auth.ts b/apps/backend/api-key/src/middleware/auth.ts index aed57f1ab..b54395568 100644 --- a/apps/backend/api-key/src/middleware/auth.ts +++ b/apps/backend/api-key/src/middleware/auth.ts @@ -1,121 +1,26 @@ -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 { log } from "evlog"; -import { useLogger } from "evlog/elysia"; -import { validateApiKey } from "./api-key-auth"; -import { validateSession } from "./cookie-auth"; +import { apiKeyConfig } from "../api-key.config"; -if (process.env.NODE_ENV !== "production") { +if (apiKeyConfig.NODE_ENV !== "production") { process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; } -export const authMiddleware = new Elysia({ name: "auth-middleware" }).macro({ - cookieAuth: { - async resolve({ status, request: { headers } }) { - try { - const log = useLogger(); - const cookie = headers.get("cookie"); - const sessionResult = await validateSession(cookie); +const sessionRedis = new RedisCache( + SESSION_CACHE_REDIS_PREFIX, + 5, + apiKeyConfig.REDIS_URL, +); - if (sessionResult) { - const traceId = `req_${createId()}`; - log.set({ - traceId, - user: sessionResult.userId, - organizationId: sessionResult.organizationId, - }); - return { ...sessionResult, traceId }; - } - return status(401, { message: "Authentication required" }); - } catch (e) { - log.error({ - ...{ - error: e instanceof Error ? e.message : "Unknown error", - stack: e instanceof Error ? e.stack : undefined, - }, - message: "Authentication error", - }); - return status(401, { message: "Authentication failed" }); - } - }, - }, - apiKeyAuth: { - async resolve({ status, request: { headers } }) { - try { - const log = useLogger(); - const apiKey = headers.get("x-api-key"); - const apiKeyResult = await validateApiKey(apiKey); - if (apiKeyResult) { - const traceId = `req_${createId()}`; - log.set({ - traceId, - user: apiKeyResult.userId, - organizationId: apiKeyResult.organizationId, - }); - return { ...apiKeyResult, traceId }; - } - return status(401, { message: "Authentication required" }); - } catch (e) { - log.error({ - ...{ - error: e instanceof Error ? e.message : "Unknown error", - stack: e instanceof Error ? e.stack : undefined, - }, - message: "Authentication error", - }); - return status(401, { message: "Authentication failed" }); - } - }, - detail: { - security: [{ apiKey: [] }], - }, - }, - auth: { - async resolve({ status, request: { headers } }) { - try { - const apiKey = - headers.get("x-api-key") || - headers.get("authorization")?.replace("Bearer ", ""); - const cookie = headers.get("cookie"); - const log = useLogger(); - const apiKeyResult = await validateApiKey(apiKey); - if (apiKeyResult) { - const traceId = `req_${createId()}`; - log.set({ - traceId, - service: "api-key", - user: apiKeyResult.userId, - organizationId: apiKeyResult.organizationId, - }); - log.info("API key authentication successful"); - return { ...apiKeyResult, traceId }; - } - const sessionResult = await validateSession(cookie); - if (sessionResult) { - const traceId = `req_${createId()}`; - log.set({ - traceId, - service: "api-key", - user: sessionResult.userId, - organizationId: sessionResult.organizationId, - }); - log.info("Session authentication successful"); - return { ...sessionResult, traceId }; - } - return status(401, { message: "Authentication required" }); - } catch (e) { - log.error({ - ...{ - error: e instanceof Error ? e.message : "Unknown error", - stack: e instanceof Error ? e.stack : undefined, - }, - message: "Authentication error", - }); - return status(401, { message: "Authentication failed" }); - } - }, - detail: { - security: [{ apiKey: [] }], - }, - }, -}); +/** Batch B migration: shared auth plugin (fail-closed org via `auth`). */ +export const authMiddleware = new Elysia({ name: "auth-middleware" }).use( + createAuthPlugin({ + baseUrl: apiKeyConfig.BASE_URL, + redis: sessionRedis, + ttl: 5, + }), +); diff --git a/apps/backend/api-key/src/middleware/cookie-auth.ts b/apps/backend/api-key/src/middleware/cookie-auth.ts deleted file mode 100644 index edafc0103..000000000 --- a/apps/backend/api-key/src/middleware/cookie-auth.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { createHash } from "node:crypto"; -import { redis } from "@reloop/api-key/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( - `${process.env.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, - }; - // Cache for 30 seconds — short enough to pick up logouts quickly - await redis.set(cacheKey, result, 30); - return result; - } - - return null; -} diff --git a/apps/backend/api-key/test/auth-smoke.test.ts b/apps/backend/api-key/test/auth-smoke.test.ts new file mode 100644 index 000000000..a664e7ddc --- /dev/null +++ b/apps/backend/api-key/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("api-key batch-B 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/api-key/tests/setup.ts b/apps/backend/api-key/tests/setup.ts new file mode 100644 index 000000000..5d693f6b6 --- /dev/null +++ b/apps/backend/api-key/tests/setup.ts @@ -0,0 +1 @@ +// Test preload placeholder (was referenced by bunfig.toml). diff --git a/apps/backend/logs/package.json b/apps/backend/logs/package.json index fa055ba14..01d72b851 100644 --- a/apps/backend/logs/package.json +++ b/apps/backend/logs/package.json @@ -11,7 +11,8 @@ "cleanup": "bun run scripts/cleanup.ts", "lint": "biome check .", "fix-lint": "biome check --fix .", - "format": "biome format ." + "format": "biome format .", + "test": "bun test test/" }, "dependencies": { "@clickhouse/client": "catalog:backend", @@ -38,7 +39,7 @@ "pg": "catalog:db", "@reloop/db": "workspace:*", "@reloop/cache": "workspace:*", - "@reloop/apikey": "workspace:*", + "@reloop/auth": "workspace:*", "@reloop/bus": "workspace:*", "@paralleldrive/cuid2": "catalog:backend", "evlog": "catalog:backend" diff --git a/apps/backend/logs/src/middleware/api-key-auth.ts b/apps/backend/logs/src/middleware/api-key-auth.ts deleted file mode 100644 index d50c60900..000000000 --- a/apps/backend/logs/src/middleware/api-key-auth.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { validateApiKey as validateApiKeyShared } from "@reloop/apikey"; -import { redis } from "@reloop/logs/utils/loader"; - -export async function validateApiKey(apiKey: string | null | undefined) { - return validateApiKeyShared(apiKey, redis); -} diff --git a/apps/backend/logs/src/middleware/auth.ts b/apps/backend/logs/src/middleware/auth.ts index b506ab319..315884153 100644 --- a/apps/backend/logs/src/middleware/auth.ts +++ b/apps/backend/logs/src/middleware/auth.ts @@ -1,116 +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 { logsConfig } from "@reloop/logs/logs.config"; import { Elysia } from "elysia"; import { evlog } from "evlog/elysia"; -import { validateApiKey } from "./api-key-auth"; -import { validateSession } from "./cookie-auth"; if (logsConfig.NODE_ENV !== "production") { process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; } +const sessionRedis = new RedisCache( + SESSION_CACHE_REDIS_PREFIX, + 5, + logsConfig.REDIS_URL, +); + +/** Batch B migration: shared auth plugin (fail-closed org via `auth`). */ export const authMiddleware = new Elysia({ name: "auth-middleware" }) .use(evlog()) - .macro({ - insertAuth: { - async resolve({ status, request: { headers }, log }) { - try { - const apiKey = - headers.get("x-api-key") || - headers.get("authorization")?.replace("Bearer ", ""); - const cookie = headers.get("cookie"); - const traceId = `req_${createId()}`; - log.set({ traceId, service: "logs" }); - - const apiKeyResult = await validateApiKey(apiKey); - if (apiKeyResult) { - log.set({ - user: apiKeyResult.userId, - organizationId: apiKeyResult.organizationId, - }); - log.info("API key authentication successful"); - return { - ...apiKeyResult, - organizationId: apiKeyResult.organizationId, - traceId, - }; - } - - const sessionResult = await validateSession(cookie); - if (sessionResult) { - log.set({ - user: sessionResult.userId, - organizationId: sessionResult.organizationId, - }); - log.info("Session authentication successful"); - return { - ...sessionResult, - organizationId: sessionResult.organizationId, - 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" }); - } - }, - }, - auth: { - async resolve({ status, request: { headers }, log }) { - try { - const apiKey = - headers.get("x-api-key") || - headers.get("authorization")?.replace("Bearer ", ""); - const cookie = headers.get("cookie"); - const traceId = `req_${createId()}`; - log.set({ traceId, service: "logs" }); - - const apiKeyResult = await validateApiKey(apiKey); - if (apiKeyResult) { - log.set({ - user: apiKeyResult.userId, - organizationId: apiKeyResult.organizationId, - }); - log.info("API key authentication successful"); - return { - ...apiKeyResult, - organizationId: apiKeyResult.organizationId, - traceId, - logger: log, - }; - } - - const sessionResult = await validateSession(cookie); - if (sessionResult) { - log.set({ - user: sessionResult.userId, - organizationId: sessionResult.organizationId, - }); - log.info("Session authentication successful"); - return { - ...sessionResult, - organizationId: sessionResult.organizationId, - 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: logsConfig.BASE_URL, + redis: sessionRedis, + ttl: 5, + }), + ); diff --git a/apps/backend/logs/src/middleware/cookie-auth.ts b/apps/backend/logs/src/middleware/cookie-auth.ts deleted file mode 100644 index a6c99e66e..000000000 --- a/apps/backend/logs/src/middleware/cookie-auth.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { logsConfig } from "@reloop/logs/logs.config"; - -export async function validateSession(cookie: string | null) { - const response = await fetch( - `${logsConfig.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, - activeOrganizationId: session.user.activeOrganizationId, - authType: "auth" as const, - }; - } - - return null; -} diff --git a/apps/backend/logs/test/auth-smoke.test.ts b/apps/backend/logs/test/auth-smoke.test.ts new file mode 100644 index 000000000..1676bd74d --- /dev/null +++ b/apps/backend/logs/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("logs batch-B 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/template/package.json b/apps/backend/template/package.json index c462f974e..29efa5849 100644 --- a/apps/backend/template/package.json +++ b/apps/backend/template/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" }, @@ -40,7 +41,6 @@ "@reloop/db": "workspace:*", "@reloop/cache": "workspace:*", "@reloop/auth": "workspace:*", - "@reloop/apikey": "workspace:*", "@reloop/bus": "workspace:*", "@elysiajs/openapi": "catalog:backend", "evlog": "catalog:backend" diff --git a/apps/backend/template/src/middleware/api-key-auth.ts b/apps/backend/template/src/middleware/api-key-auth.ts deleted file mode 100644 index aca265d8b..000000000 --- a/apps/backend/template/src/middleware/api-key-auth.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { redis } from "@be/template/utils/redis"; -import { validateApiKey as validateApiKeyShared } from "@reloop/apikey"; - -export async function validateApiKey(apiKey: string | null | undefined) { - return validateApiKeyShared(apiKey, redis); -} diff --git a/apps/backend/template/src/middleware/auth.ts b/apps/backend/template/src/middleware/auth.ts index f96c33aa0..dd32d912f 100644 --- a/apps/backend/template/src/middleware/auth.ts +++ b/apps/backend/template/src/middleware/auth.ts @@ -1,131 +1,111 @@ import { AuthErrors } from "@be/template/error/template.error"; -import { createId } from "@paralleldrive/cuid2"; +import { templateConfig } from "@be/template/template.config"; +import { + createAuthPlugin, + resolveSession, + SESSION_CACHE_REDIS_PREFIX, +} from "@reloop/auth/middleware"; +import { validateApiKey } from "@reloop/auth/apikey/validate"; +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"; +if (templateConfig.NODE_ENV !== "production") { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; +} + +const sessionRedis = new RedisCache( + SESSION_CACHE_REDIS_PREFIX, + 5, + templateConfig.REDIS_URL, +); + +/** + * Batch B migration: shared plugin + collabAuth for collaboration websocket + * (needs email/name/image from get-session for presence). + */ export const authMiddleware = new Elysia({ name: "better-auth" }) .use(evlog()) + .use( + createAuthPlugin({ + baseUrl: templateConfig.BASE_URL, + redis: sessionRedis, + ttl: 5, + }), + ) .macro({ - cookieAuth: { - async resolve({ request: { headers }, log }) { - const cookie = headers.get("cookie"); - const sessionResult = await validateSession(cookie); - if (sessionResult) { - const traceId = `req_${createId()}`; - log.set({ - traceId, - service: "template", - user: sessionResult.userId, - organizationId: sessionResult.organizationId, - }); - log.info("Session authentication successful"); - return { - userId: sessionResult.userId, - organizationId: sessionResult.organizationId, - userEmail: sessionResult.email, - userName: sessionResult.name, - userImage: sessionResult.image, - authType: "auth" as const, - traceId, - }; - } - throw AuthErrors.unauthorized(); - }, - }, - apiKeyAuth: { - async resolve({ request: { headers }, log }) { + /** + * Session or API key with fail-closed org, plus optional profile fields + * from get-session for the collab presence channel. + */ + collabAuth: { + async resolve({ request: { headers } }) { const apiKey = headers.get("x-api-key"); - const apiKeyResult = await validateApiKey(apiKey); - if (apiKeyResult) { - const traceId = `req_${createId()}`; - log.set({ - traceId, - service: "template", - user: apiKeyResult.userId, - organizationId: apiKeyResult.organizationId, - }); - log.info("API key authentication successful"); - return { - userId: apiKeyResult.userId, - organizationId: apiKeyResult.organizationId, - authType: apiKeyResult.authType, - apiKeyId: apiKeyResult.apiKeyId, - traceId, - }; - } - throw AuthErrors.unauthorized(); - }, - detail: { - security: [{ apiKey: [] }], - }, - }, - auth: { - async resolve({ request: { headers }, log }) { - const apiKey = headers.get("x-api-key"); - const cookie = headers.get("cookie"); - if (apiKey) { - try { - const apiKeyResult = await validateApiKey(apiKey); - if (apiKeyResult) { - const traceId = `req_${createId()}`; - log.set({ - traceId, - service: "template", - user: apiKeyResult.userId, - organizationId: apiKeyResult.organizationId, - }); - log.info("API key authentication successful"); - return { - userId: apiKeyResult.userId, - organizationId: apiKeyResult.organizationId, - authType: apiKeyResult.authType, - apiKeyId: apiKeyResult.apiKeyId, - traceId, - }; - } - } catch (error) { - log.error("API key validation error", { - error: error instanceof Error ? error.message : "Unknown error", - }); + const result = await validateApiKey(apiKey, sessionRedis); + if (result?.organizationId) { + return { + userId: result.userId, + organizationId: result.organizationId, + role: null as string | null, + authType: "apikey" as const, + apiKeyId: result.apiKeyId, + userEmail: undefined as string | undefined, + userName: undefined as string | undefined, + userImage: undefined as string | undefined, + }; } } - if (cookie) { - try { - const sessionResult = await validateSession(cookie); - if (sessionResult) { - const traceId = `req_${createId()}`; - log.set({ - traceId, - service: "template", - user: sessionResult.userId, - organizationId: sessionResult.organizationId, - }); - log.info("Session authentication successful"); - return { - userId: sessionResult.userId, - organizationId: sessionResult.organizationId, - userEmail: sessionResult.email, - userName: sessionResult.name, - userImage: sessionResult.image, - authType: "auth" as const, - traceId, - }; - } - } catch (error) { - log.error("Session validation error", { - error: error instanceof Error ? error.message : "Unknown error", - }); - } + const cookie = headers.get("cookie"); + if (!cookie) throw AuthErrors.unauthorized(); + + // Full get-session for profile fields (email/name/image). + const response = await fetch( + `${templateConfig.BASE_URL.replace(/\/$/, "")}/api/auth/v1/get-session`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + Cookie: cookie, + }, + }, + ); + if (!response.ok) throw AuthErrors.unauthorized(); + + const body = (await response.json()) as { + user?: { + id: string; + email?: string; + name?: string; + image?: string; + activeOrganizationId?: string | null; + role?: string | null; + }; + } | null; + + const user = body?.user; + if (!user?.id || !user.activeOrganizationId) { + throw AuthErrors.unauthorized(); } - throw AuthErrors.unauthorized(); - }, - detail: { - security: [{ apiKey: [] }], + // Populate shared session cache so subsequent `auth` hits are fast. + await resolveSession(cookie, { + baseUrl: templateConfig.BASE_URL, + redis: sessionRedis, + ttl: 5, + requireOrg: true, + }).catch(() => null); + + return { + userId: user.id, + organizationId: user.activeOrganizationId, + role: user.role ?? null, + authType: "session" as const, + userEmail: user.email, + userName: user.name, + userImage: user.image, + }; }, }, }); diff --git a/apps/backend/template/src/middleware/cookie-auth.ts b/apps/backend/template/src/middleware/cookie-auth.ts deleted file mode 100644 index 5b8eaa06d..000000000 --- a/apps/backend/template/src/middleware/cookie-auth.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { createHash } from "node:crypto"; -import { templateConfig } from "@be/template/template.config"; -import { redis } from "@be/template/utils/redis"; - -export type SessionResult = { - userId: string; - organizationId: string; - authType: "auth"; - email: string; - name?: string; - image?: string; -}; - -export async function validateSession( - cookie: string | null | undefined, -): Promise { - if (!cookie) return null; - - const cacheKey = `session:${createHash("sha256").update(cookie).digest("hex")}`; - - try { - const cached = await redis.get(cacheKey); - if (cached) return cached; - } catch { - // Ignore redis get failure - } - - const response = await fetch( - `${templateConfig.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; - email: string; - name?: string; - image?: string; - activeOrganizationId?: string; - }; - }; - - if (session?.user?.activeOrganizationId) { - const result: SessionResult = { - userId: session.user.id, - organizationId: session.user.activeOrganizationId, - authType: "auth" as const, - email: session.user.email, - name: session.user.name, - image: session.user.image, - }; - try { - await redis.set(cacheKey, result, 30); - } catch { - // Ignore redis set failure - } - return result; - } - - return null; -} diff --git a/apps/backend/template/src/routes/template/collaboration/collaboration.route.ts b/apps/backend/template/src/routes/template/collaboration/collaboration.route.ts index e9cdb7134..64350a331 100644 --- a/apps/backend/template/src/routes/template/collaboration/collaboration.route.ts +++ b/apps/backend/template/src/routes/template/collaboration/collaboration.route.ts @@ -19,7 +19,7 @@ export const collaborationRoute = new Elysia({ }) .use(authMiddleware) .ws("/collab/:roomName", { - auth: true, + collabAuth: true, async open(ws) { const { userId, organizationId, userEmail, userName, userImage } = ws.data; diff --git a/apps/backend/template/test/auth-smoke.test.ts b/apps/backend/template/test/auth-smoke.test.ts new file mode 100644 index 000000000..52e083068 --- /dev/null +++ b/apps/backend/template/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("template batch-B 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 c5c97a949..7fc71db5f 100644 --- a/bun.lock +++ b/bun.lock @@ -345,7 +345,7 @@ "@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:*", "@reloop/db": "workspace:*", @@ -429,7 +429,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:*",