feat(auth): shared Elysia auth middleware plugin factory (#47)#56
Conversation
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.
| const cached = await opts.redis.get<AuthContext>(cacheKey); | ||
| if (cached) { | ||
| if (opts.requireOrg && !cached.organizationId) return null; | ||
| return cached; |
There was a problem hiding this comment.
Cached Sessions Skip Revocation
When a token is already cached, this path returns the cached AuthContext without calling get-session. A revoked session, removed active organization, or changed role can keep authorizing guarded routes until the Redis entry expires, even though the auth service would reject or downgrade the session.
| 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); |
There was a problem hiding this comment.
Concurrent Index Updates Lose Tokens
Two simultaneous logins for the same user can both read the same session:user:<userId> array and then write different appended arrays. The later write drops the other token from the index, so a later bulk eviction can miss that token and leave its session:token:<token> cache entry usable until TTL expiry.
| 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; | ||
| }, | ||
| }, |
There was a problem hiding this comment.
Platform Admin Context Shrinks
This shared platformAdmin macro returns only AuthContext, while the current admin middleware also supplies fields such as email, name, isPlatformAdmin, traceId, and logger. When admin routes migrate to this macro, handlers that read those fields can receive undefined and behave incorrectly at runtime.
Summary
@reloop/auth/middleware—createAuthPlugin({ baseUrl, redis, ttl })(default TTL 5s).auth(fail-closed org),authNoOrg,apiKeyAuth,platformAdmin→ canonicalAuthContext.get-sessionstatus.Stacked on #55 (#46). Closes #47.
Test plan
bun testinpackages/auth— 20 pass (helpers, isolation, middleware suite)bun run typecheckfor@reloop/authapps/backend/auth)Greptile Summary
This PR adds a shared Elysia auth middleware package entrypoint. The main changes are:
@reloop/auth/middlewareexport with an auth plugin factory.AuthContext.Confidence Score: 4/5
The session cache path needs fixes before merging.
packages/auth/src/middleware/session.ts
Security Review
The new middleware sits on the auth boundary. Cached sessions can bypass revocation checks, and concurrent index updates can leave tokens out of bulk eviction.
Important Files Changed
Sequence Diagram
%%{init: {'theme': 'neutral'}}%% sequenceDiagram participant Client participant Middleware as Auth middleware participant Redis participant Auth as get-session Client->>Middleware: Request with session cookie Middleware->>Redis: "GET session:token:<token>" alt cache hit Redis-->>Middleware: cached AuthContext Middleware-->>Client: Route runs with cached auth else cache miss Middleware->>Auth: GET /api/auth/v1/get-session Auth-->>Middleware: Current user/org/role Middleware->>Redis: "SET session:token:<token>" Middleware->>Redis: "Update session:user:<userId>" Middleware-->>Client: Route runs with fresh auth end%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant Client participant Middleware as Auth middleware participant Redis participant Auth as get-session Client->>Middleware: Request with session cookie Middleware->>Redis: "GET session:token:<token>" alt cache hit Redis-->>Middleware: cached AuthContext Middleware-->>Client: Route runs with cached auth else cache miss Middleware->>Auth: GET /api/auth/v1/get-session Auth-->>Middleware: Current user/org/role Middleware->>Redis: "SET session:token:<token>" Middleware->>Redis: "Update session:user:<userId>" Middleware-->>Client: Route runs with fresh auth endReviews (1): Last reviewed commit: "feat(auth): shared Elysia auth middlewar..." | Re-trigger Greptile