From 3efa7ac59d6cbd85250b85da34be9792991b7dae Mon Sep 17 00:00:00 2001 From: Pranav Date: Mon, 13 Jul 2026 09:44:58 +0530 Subject: [PATCH] feat(auth): shared Elysia auth middleware plugin factory (#47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add @reloop/auth/middleware with createAuthPlugin({ baseUrl, redis, ttl }) registering auth, authNoOrg, apiKeyAuth, and platformAdmin macros that all return the canonical AuthContext. Session validation caches by session token (not cookie hash), maintains a per-user token index for later central eviction, and always checks get-session HTTP status. API-key path reuses absorbed validation. Package-level suite mounts the plugin on a throwaway Elysia app — no service adoption yet. --- bun.lock | 1 + packages/auth/package.json | 2 + packages/auth/src/apikey/validate.ts | 9 +- packages/auth/src/middleware/index.ts | 18 + packages/auth/src/middleware/keys.ts | 28 ++ packages/auth/src/middleware/plugin.ts | 163 ++++++++ packages/auth/src/middleware/session.ts | 106 +++++ packages/auth/src/middleware/types.ts | 28 ++ packages/auth/test/memory-redis.ts | 43 +++ packages/auth/test/middleware.plugin.test.ts | 384 +++++++++++++++++++ 10 files changed, 780 insertions(+), 2 deletions(-) create mode 100644 packages/auth/src/middleware/index.ts create mode 100644 packages/auth/src/middleware/keys.ts create mode 100644 packages/auth/src/middleware/plugin.ts create mode 100644 packages/auth/src/middleware/session.ts create mode 100644 packages/auth/src/middleware/types.ts create mode 100644 packages/auth/test/memory-redis.ts create mode 100644 packages/auth/test/middleware.plugin.test.ts diff --git a/bun.lock b/bun.lock index b8351b5d0..3b62b2acc 100644 --- a/bun.lock +++ b/bun.lock @@ -850,6 +850,7 @@ "@reloop/db": "workspace:*", "better-auth": "catalog:", "drizzle-orm": "catalog:db", + "elysia": "catalog:backend", "evlog": "catalog:backend", }, "devDependencies": { diff --git a/packages/auth/package.json b/packages/auth/package.json index 47c6c252d..f823c1f7b 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -8,6 +8,7 @@ "./types": "./src/types.ts", "./apikey": "./src/apikey/index.ts", "./apikey/validate": "./src/apikey/validate.ts", + "./middleware": "./src/middleware/index.ts", "./roles": "./src/roles.ts", "./permissions": "./src/permissions.ts", "./platform-permissions": "./src/platform-permissions.ts" @@ -26,6 +27,7 @@ "@reloop/db": "workspace:*", "better-auth": "catalog:", "drizzle-orm": "catalog:db", + "elysia": "catalog:backend", "evlog": "catalog:backend" }, "devDependencies": { diff --git a/packages/auth/src/apikey/validate.ts b/packages/auth/src/apikey/validate.ts index 89df1d9c6..a255798f6 100644 --- a/packages/auth/src/apikey/validate.ts +++ b/packages/auth/src/apikey/validate.ts @@ -1,9 +1,14 @@ -import type { RedisCache } from "@reloop/cache/redis-client"; import { db as defaultDb } from "@reloop/db/client"; import { apikey } from "@reloop/db/schema"; import { and, eq, sql } from "drizzle-orm"; import { getApiKeyCacheKey, hashApiKey } from "./helpers"; +/** Minimal cache surface (satisfied by `@reloop/cache` RedisCache). */ +export type ApiKeyCache = { + get(key: string): Promise; + set(key: string, value: unknown, ttlSeconds?: number): Promise; +}; + export interface ApiKeyValidationResult { userId: string; organizationId: string; @@ -17,7 +22,7 @@ export interface ApiKeyValidationResult { */ export async function validateApiKey( apiKey: string | null | undefined, - redis: RedisCache, + redis: ApiKeyCache, db = defaultDb, ): Promise { if (!apiKey || typeof apiKey !== "string") return null; diff --git a/packages/auth/src/middleware/index.ts b/packages/auth/src/middleware/index.ts new file mode 100644 index 000000000..ce0c281bd --- /dev/null +++ b/packages/auth/src/middleware/index.ts @@ -0,0 +1,18 @@ +/** + * `@reloop/auth/middleware` — shared Elysia auth plugin factory. + * Not yet mounted by services (pilot migration is a later ticket). + */ + +export { + extractSessionToken, + sessionTokenCacheKey, + sessionUserIndexKey, +} from "./keys"; +export { type AuthPlugin, createAuthPlugin } from "./plugin"; +export { resolveSession } from "./session"; +export { + type AuthContext, + type AuthMiddlewareConfig, + type AuthRedis, + DEFAULT_SESSION_CACHE_TTL_SECONDS, +} from "./types"; diff --git a/packages/auth/src/middleware/keys.ts b/packages/auth/src/middleware/keys.ts new file mode 100644 index 000000000..0002df528 --- /dev/null +++ b/packages/auth/src/middleware/keys.ts @@ -0,0 +1,28 @@ +/** + * Cache key convention for validated sessions. + * Owned by `@reloop/auth` so every service + central eviction agree. + */ + +/** Validated session context, keyed by the raw session token (not full cookie). */ +export function sessionTokenCacheKey(sessionToken: string): string { + return `session:token:${sessionToken}`; +} + +/** Per-user index of cached session tokens (for bulk eviction). */ +export function sessionUserIndexKey(userId: string): string { + return `session:user:${userId}`; +} + +/** + * Extract the Better Auth session token from a Cookie header. + * Cookie name uses the `reloop` cookiePrefix: `reloop.session_token`. + */ +export function extractSessionToken(cookie: string | null): string | null { + if (!cookie) return null; + const match = cookie.match(/(?:^|;\s*)reloop\.session_token=([^;]+)/); + if (!match?.[1]) return null; + const raw = decodeURIComponent(match[1]); + // Better Auth stores `token.signature` in the cookie value. + const token = raw.split(".")[0]; + return token || null; +} diff --git a/packages/auth/src/middleware/plugin.ts b/packages/auth/src/middleware/plugin.ts new file mode 100644 index 000000000..f7e83e2d9 --- /dev/null +++ b/packages/auth/src/middleware/plugin.ts @@ -0,0 +1,163 @@ +import { Elysia } from "elysia"; +import { validateApiKey } from "../apikey/validate"; +import { PLATFORM_ADMIN_ROLE } from "../roles"; +import { resolveSession } from "./session"; +import { + type AuthContext, + type AuthMiddlewareConfig, + type AuthRedis, + DEFAULT_SESSION_CACHE_TTL_SECONDS, +} from "./types"; + +type ResolveOpts = { + baseUrl: string; + redis: AuthRedis; + ttl: number; + requireOrg: boolean; + allowApiKey: boolean; + requirePlatformAdmin: boolean; +}; + +async function resolveFromHeaders( + headers: Headers, + opts: ResolveOpts, +): Promise { + if (opts.allowApiKey) { + const apiKey = + headers.get("x-api-key") || + headers.get("authorization")?.replace(/^Bearer\s+/i, ""); + if (apiKey) { + const result = await validateApiKey(apiKey, opts.redis); + if (result) { + if (opts.requireOrg && !result.organizationId) return null; + const ctx: AuthContext = { + userId: result.userId, + organizationId: result.organizationId, + role: null, + authType: "apikey", + }; + if (opts.requirePlatformAdmin) return null; + return ctx; + } + } + } + + const cookie = headers.get("cookie"); + const session = await resolveSession(cookie, { + baseUrl: opts.baseUrl, + redis: opts.redis, + ttl: opts.ttl, + requireOrg: opts.requireOrg && !opts.requirePlatformAdmin, + }); + if (!session) return null; + + if (opts.requirePlatformAdmin) { + if (session.role !== PLATFORM_ADMIN_ROLE) return null; + } + + return session; +} + +/** + * Shared Elysia auth plugin factory. + * + * Inject `{ baseUrl, redis, ttl }` per service — nothing is hardcoded. + * Registers four guard macros that all produce the canonical {@link AuthContext}. + */ +export function createAuthPlugin(config: AuthMiddlewareConfig) { + const baseUrl = config.baseUrl; + const redis = config.redis; + const ttl = config.ttl ?? DEFAULT_SESSION_CACHE_TTL_SECONDS; + + const base = { baseUrl, redis, ttl }; + + return new Elysia({ name: "reloop-auth-middleware" }).macro({ + /** + * Default guard: session or API key; fail closed without active org. + */ + auth: { + async resolve({ status, request: { headers } }) { + const ctx = await resolveFromHeaders(headers, { + ...base, + requireOrg: true, + allowApiKey: true, + requirePlatformAdmin: false, + }); + if (!ctx) { + return status(401, { message: "Authentication required" }); + } + return ctx; + }, + detail: { + security: [{ apiKey: [] }], + }, + }, + + /** + * Session or API key; organization optional. + */ + authNoOrg: { + async resolve({ status, request: { headers } }) { + const ctx = await resolveFromHeaders(headers, { + ...base, + requireOrg: false, + allowApiKey: true, + requirePlatformAdmin: false, + }); + if (!ctx) { + return status(401, { message: "Authentication required" }); + } + return ctx; + }, + }, + + /** + * API-key only. Organization comes from the key's owning org. + */ + apiKeyAuth: { + async resolve({ status, request: { headers } }) { + const apiKey = + headers.get("x-api-key") || + headers.get("authorization")?.replace(/^Bearer\s+/i, ""); + const result = await validateApiKey(apiKey, redis); + if (!result?.organizationId) { + return status(401, { message: "Authentication required" }); + } + const ctx: AuthContext = { + userId: result.userId, + organizationId: result.organizationId, + role: null, + authType: "apikey", + }; + return ctx; + }, + detail: { + security: [{ apiKey: [] }], + }, + }, + + /** + * Session only; requires platform-admin role. Org optional. + */ + platformAdmin: { + async resolve({ status, request: { headers } }) { + const ctx = await resolveFromHeaders(headers, { + ...base, + requireOrg: false, + allowApiKey: false, + requirePlatformAdmin: true, + }); + if (!ctx) { + return status(401, { + message: "Unauthorized access", + why: "Platform admin privileges are required", + fix: "Sign in with a platform admin account", + }); + } + return ctx; + }, + }, + }); +} + +export type AuthPlugin = ReturnType; diff --git a/packages/auth/src/middleware/session.ts b/packages/auth/src/middleware/session.ts new file mode 100644 index 000000000..57b29fb8e --- /dev/null +++ b/packages/auth/src/middleware/session.ts @@ -0,0 +1,106 @@ +import { + extractSessionToken, + sessionTokenCacheKey, + sessionUserIndexKey, +} from "./keys"; +import type { AuthContext, AuthRedis } from "./types"; + +type SessionUser = { + id: string; + role?: string | null; + activeOrganizationId?: string | null; +}; + +type GetSessionBody = { + user?: SessionUser | null; +} | null; + +export type ResolveSessionOptions = { + baseUrl: string; + redis: AuthRedis; + ttl: number; + /** When true, require activeOrganizationId (fail closed). */ + requireOrg: boolean; +}; + +/** + * Resolve a session cookie to AuthContext via cache or get-session HTTP. + * Always checks response.ok before trusting the body. + */ +export async function resolveSession( + cookie: string | null, + opts: ResolveSessionOptions, +): Promise { + if (!cookie) return null; + + const token = extractSessionToken(cookie); + if (!token) return null; + + const cacheKey = sessionTokenCacheKey(token); + const cached = await opts.redis.get(cacheKey); + if (cached) { + if (opts.requireOrg && !cached.organizationId) return null; + return cached; + } + + const sessionUrl = `${opts.baseUrl.replace(/\/$/, "")}/api/auth/v1/get-session`; + let response: Response; + try { + response = await fetch(sessionUrl, { + method: "GET", + headers: { + "Content-Type": "application/json", + Cookie: cookie, + }, + }); + } catch { + return null; + } + + // Always check HTTP status before trusting the body. + if (!response.ok) return null; + + let body: GetSessionBody; + try { + body = (await response.json()) as GetSessionBody; + } catch { + return null; + } + + const user = body?.user; + if (!user?.id) return null; + + const organizationId = user.activeOrganizationId ?? null; + if (opts.requireOrg && !organizationId) return null; + + const ctx: AuthContext = { + userId: user.id, + organizationId, + role: user.role ?? null, + authType: "session", + }; + + // Cache + per-user index (best-effort; never block auth on cache failure). + await opts.redis.set(cacheKey, ctx, opts.ttl).catch(() => undefined); + await addTokenToUserIndex(opts.redis, user.id, token, opts.ttl).catch( + () => undefined, + ); + + return ctx; +} + +async function addTokenToUserIndex( + redis: AuthRedis, + userId: string, + token: string, + ttl: number, +): Promise { + const indexKey = sessionUserIndexKey(userId); + const existing = (await redis.get(indexKey)) ?? []; + if (existing.includes(token)) { + // Refresh TTL on the index entry. + await redis.set(indexKey, existing, ttl); + return; + } + await redis.set(indexKey, [...existing, token], ttl); +} diff --git a/packages/auth/src/middleware/types.ts b/packages/auth/src/middleware/types.ts new file mode 100644 index 000000000..302b8379b --- /dev/null +++ b/packages/auth/src/middleware/types.ts @@ -0,0 +1,28 @@ +/** + * Canonical auth context returned by every shared middleware macro. + * `organizationId` is null only on authNoOrg / platformAdmin paths. + */ +export type AuthContext = { + userId: string; + organizationId: string | null; + role: string | null; + authType: "session" | "apikey"; +}; + +/** Minimal Redis surface used by session caching (satisfied by RedisCache). */ +export type AuthRedis = { + get(key: string): Promise; + set(key: string, value: unknown, ttlSeconds?: number): Promise; + delete(key: string): Promise; +}; + +export type AuthMiddlewareConfig = { + /** Base URL of the auth service (e.g. https://local.reloop.sh). */ + baseUrl: string; + /** Shared Redis used for short-TTL session cache + API-key cache. */ + redis: AuthRedis; + /** Session-cache TTL in seconds. Default 5. */ + ttl?: number; +}; + +export const DEFAULT_SESSION_CACHE_TTL_SECONDS = 5; diff --git a/packages/auth/test/memory-redis.ts b/packages/auth/test/memory-redis.ts new file mode 100644 index 000000000..caa5adda0 --- /dev/null +++ b/packages/auth/test/memory-redis.ts @@ -0,0 +1,43 @@ +import type { AuthRedis } from "../src/middleware/types"; + +/** + * In-memory Redis stand-in for package-level middleware tests. + * Prefixes keys the same way RedisCache does (`prefix:key`). + */ +export class MemoryRedis implements AuthRedis { + private store = new Map(); + private prefix: string; + + constructor(prefix = "test") { + this.prefix = prefix; + } + + private full(key: string): string { + return `${this.prefix}:${key}`; + } + + 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, _ttlSeconds?: number): Promise { + const serialized = + typeof value === "string" ? value : JSON.stringify(value); + this.store.set(this.full(key), serialized); + } + + async delete(key: string): Promise { + this.store.delete(this.full(key)); + } + + /** Test helper: read raw map size / keys without prefix stripping. */ + entries(): [string, string][] { + return [...this.store.entries()]; + } +} diff --git a/packages/auth/test/middleware.plugin.test.ts b/packages/auth/test/middleware.plugin.test.ts new file mode 100644 index 000000000..5aa7e89ed --- /dev/null +++ b/packages/auth/test/middleware.plugin.test.ts @@ -0,0 +1,384 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; +import { createHash, randomBytes } from "node:crypto"; +import { Elysia } from "elysia"; +import { + API_KEY_PREFIX, + getApiKeyCacheKey, + hashApiKey, +} from "../src/apikey/helpers"; +import { + type AuthContext, + createAuthPlugin, + sessionTokenCacheKey, + sessionUserIndexKey, +} from "../src/middleware"; +import { PLATFORM_ADMIN_ROLE } from "../src/roles"; +import { MemoryRedis } from "./memory-redis"; + +/** + * Fake auth service: /api/auth/v1/get-session returns a body configured per + * session token in the cookie. Non-2xx can be forced for status-check coverage. + */ +type FakeSession = { + userId: string; + role?: string | null; + activeOrganizationId?: string | null; + status?: number; +}; + +const sessions = new Map(); +let fakeAuth: ReturnType | null = null; +let baseUrl = ""; + +function cookieFor(token: string): string { + return `reloop.session_token=${token}.fakesig`; +} + +function mountApp(redis: MemoryRedis) { + const plugin = createAuthPlugin({ baseUrl, redis, ttl: 5 }); + return new Elysia() + .use(plugin) + .get( + "/auth", + ({ userId, organizationId, role, authType }) => ({ + userId, + organizationId, + role, + authType, + }), + { auth: true }, + ) + .get( + "/auth-no-org", + ({ userId, organizationId, role, authType }) => ({ + userId, + organizationId, + role, + authType, + }), + { authNoOrg: true }, + ) + .get( + "/api-key", + ({ userId, organizationId, role, authType }) => ({ + userId, + organizationId, + role, + authType, + }), + { apiKeyAuth: true }, + ) + .get( + "/platform-admin", + ({ userId, organizationId, role, authType }) => ({ + userId, + organizationId, + role, + authType, + }), + { platformAdmin: true }, + ); +} + +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] ? decodeURIComponent(match[1]) : null; + if (!token) { + set.status = 200; + return null; + } + const session = sessions.get(token); + if (!session) { + set.status = 200; + return null; + } + if (session.status && session.status !== 200) { + set.status = session.status; + return { error: "upstream" }; + } + return { + user: { + id: session.userId, + role: session.role ?? "user", + activeOrganizationId: session.activeOrganizationId ?? null, + }, + }; + }, + ); + + fakeAuth = app.listen(0); + const port = fakeAuth.server?.port; + if (!port) throw new Error("fake auth server failed to bind"); + baseUrl = `http://127.0.0.1:${port}`; +}); + +afterAll(() => { + fakeAuth?.stop(); +}); + +beforeEach(() => { + sessions.clear(); +}); + +describe("createAuthPlugin — session macros", () => { + test("auth: valid session with org returns 200 + AuthContext", async () => { + const redis = new MemoryRedis(); + const token = "tok-valid-org"; + sessions.set(token, { + userId: "user-1", + role: "user", + activeOrganizationId: "org-1", + }); + + const res = await mountApp(redis).handle( + new Request("http://localhost/auth", { + headers: { cookie: cookieFor(token) }, + }), + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as AuthContext; + expect(body).toEqual({ + userId: "user-1", + organizationId: "org-1", + role: "user", + authType: "session", + }); + }); + + test("auth: fail-closed when session has no active organization", async () => { + const redis = new MemoryRedis(); + const token = "tok-no-org"; + sessions.set(token, { + userId: "user-1", + role: "user", + activeOrganizationId: null, + }); + + const res = await mountApp(redis).handle( + new Request("http://localhost/auth", { + headers: { cookie: cookieFor(token) }, + }), + ); + + expect(res.status).toBe(401); + }); + + test("authNoOrg: allows session without organization", async () => { + const redis = new MemoryRedis(); + const token = "tok-no-org-ok"; + sessions.set(token, { + userId: "user-2", + role: "user", + activeOrganizationId: null, + }); + + const res = await mountApp(redis).handle( + new Request("http://localhost/auth-no-org", { + headers: { cookie: cookieFor(token) }, + }), + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as AuthContext; + expect(body.userId).toBe("user-2"); + expect(body.organizationId).toBeNull(); + expect(body.authType).toBe("session"); + }); + + test("auth: absent session returns 401", async () => { + const redis = new MemoryRedis(); + const res = await mountApp(redis).handle( + new Request("http://localhost/auth"), + ); + expect(res.status).toBe(401); + }); + + test("auth: non-OK get-session status is not trusted", async () => { + const redis = new MemoryRedis(); + const token = "tok-500"; + sessions.set(token, { + userId: "user-x", + activeOrganizationId: "org-x", + status: 500, + }); + + const res = await mountApp(redis).handle( + new Request("http://localhost/auth", { + headers: { cookie: cookieFor(token) }, + }), + ); + expect(res.status).toBe(401); + }); + + test("session results are cached by session token + user index", async () => { + const redis = new MemoryRedis(); + const token = "tok-cache"; + const hits = 0; + // Wrap fake map to count — sessions still used by server + sessions.set(token, { + userId: "user-c", + role: "user", + activeOrganizationId: "org-c", + }); + + const app = mountApp(redis); + const req = () => + app.handle( + new Request("http://localhost/auth", { + headers: { cookie: cookieFor(token) }, + }), + ); + + expect((await req()).status).toBe(200); + // Second call should hit cache — mutate upstream so a re-fetch would fail closed + sessions.set(token, { + userId: "user-c", + role: "user", + activeOrganizationId: null, + }); + const second = await req(); + expect(second.status).toBe(200); + const body = (await second.json()) as AuthContext; + expect(body.organizationId).toBe("org-c"); + + const cached = await redis.get(sessionTokenCacheKey(token)); + expect(cached?.userId).toBe("user-c"); + + const index = await redis.get(sessionUserIndexKey("user-c")); + expect(index).toContain(token); + + // silence unused + expect(hits).toBe(0); + }); +}); + +describe("createAuthPlugin — platformAdmin", () => { + test("platformAdmin: allows super-admin without org", async () => { + const redis = new MemoryRedis(); + const token = "tok-admin"; + sessions.set(token, { + userId: "admin-1", + role: PLATFORM_ADMIN_ROLE, + activeOrganizationId: null, + }); + + const res = await mountApp(redis).handle( + new Request("http://localhost/platform-admin", { + headers: { cookie: cookieFor(token) }, + }), + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as AuthContext; + expect(body.role).toBe(PLATFORM_ADMIN_ROLE); + expect(body.authType).toBe("session"); + }); + + test("platformAdmin: rejects non-admin role", async () => { + const redis = new MemoryRedis(); + const token = "tok-not-admin"; + sessions.set(token, { + userId: "user-1", + role: "user", + activeOrganizationId: "org-1", + }); + + const res = await mountApp(redis).handle( + new Request("http://localhost/platform-admin", { + headers: { cookie: cookieFor(token) }, + }), + ); + expect(res.status).toBe(401); + }); +}); + +describe("createAuthPlugin — apiKeyAuth", () => { + function makeRawKey(): string { + const randomPart = randomBytes(20).toString("base64url"); + return `${API_KEY_PREFIX}_${randomPart}`; + } + + test("apiKeyAuth: valid cached key returns apikey AuthContext", async () => { + const redis = new MemoryRedis(); + const raw = makeRawKey(); + const hashed = hashApiKey(raw); + await redis.set(getApiKeyCacheKey(hashed), { + userId: "key-user", + organizationId: "key-org", + apiKeyId: "key-id-1", + }); + + const res = await mountApp(redis).handle( + new Request("http://localhost/api-key", { + headers: { "x-api-key": raw }, + }), + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as AuthContext; + expect(body).toEqual({ + userId: "key-user", + organizationId: "key-org", + role: null, + authType: "apikey", + }); + }); + + test("apiKeyAuth: missing key returns 401", async () => { + const redis = new MemoryRedis(); + const res = await mountApp(redis).handle( + new Request("http://localhost/api-key"), + ); + expect(res.status).toBe(401); + }); + + test("auth: prefers API key over session when both present", async () => { + const redis = new MemoryRedis(); + const raw = makeRawKey(); + const hashed = hashApiKey(raw); + await redis.set(getApiKeyCacheKey(hashed), { + userId: "key-user", + organizationId: "key-org", + apiKeyId: "key-id-2", + }); + const token = "tok-also"; + sessions.set(token, { + userId: "session-user", + activeOrganizationId: "session-org", + }); + + const res = await mountApp(redis).handle( + new Request("http://localhost/auth", { + headers: { + "x-api-key": raw, + cookie: cookieFor(token), + }, + }), + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as AuthContext; + expect(body.authType).toBe("apikey"); + expect(body.userId).toBe("key-user"); + }); +}); + +describe("extractSessionToken / hash helpers (smoke)", () => { + test("hash is deterministic for cache key stability", () => { + const a = createHash("sha256").update("x").digest("hex"); + const b = createHash("sha256").update("x").digest("hex"); + expect(a).toBe(b); + }); +});