-
Notifications
You must be signed in to change notification settings - Fork 0
feat(auth): shared Elysia auth middleware plugin factory (#47) #56
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<AuthContext | null> { | ||
| 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<typeof createAuthPlugin>; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<AuthContext | null> { | ||
| if (!cookie) return null; | ||
|
|
||
| const token = extractSessionToken(cookie); | ||
| if (!token) return null; | ||
|
|
||
| const cacheKey = sessionTokenCacheKey(token); | ||
| const cached = await opts.redis.get<AuthContext>(cacheKey); | ||
| if (cached) { | ||
| if (opts.requireOrg && !cached.organizationId) return null; | ||
| return cached; | ||
|
Comment on lines
+40
to
+43
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a token is already cached, this path returns the 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<void> { | ||
| const indexKey = sessionUserIndexKey(userId); | ||
| const existing = (await redis.get<string[]>(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); | ||
|
Comment on lines
+98
to
+105
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Two simultaneous logins for the same user can both read the same |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<T>(key: string): Promise<T | undefined>; | ||
| set(key: string, value: unknown, ttlSeconds?: number): Promise<void>; | ||
| delete(key: string): Promise<void>; | ||
| }; | ||
|
|
||
| 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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This shared
platformAdminmacro returns onlyAuthContext, while the current admin middleware also supplies fields such asemail,name,isPlatformAdmin,traceId, andlogger. When admin routes migrate to this macro, handlers that read those fields can receiveundefinedand behave incorrectly at runtime.