Skip to content

Consolidate auth middleware into one @reloop/auth plugin & collapse the duplicate Better Auth configs #43

Description

@pranavp10

This spec was generated by AI (to-spec) from a grilling session.

Consolidate auth middleware into one @reloop/auth plugin & collapse the duplicate Better Auth configs

Triage: ready-for-agent

Problem Statement

As a backend engineer working in the reloop monorepo, I keep running into the same auth code copied into every service, and it has quietly drifted apart. Each of the ~17 backend Elysia services carries its own hand-maintained cookie-auth / api-key-auth / auth middleware. What looks like copy-paste is actually behavioural drift: the copies disagree on whether they cache session lookups, whether they check the HTTP response status, whether an active organization is required, what shape they return, what the authType string is, and whether they understand platform admins.

On top of that there are two separate betterAuth() configurations — the real runtime instance in the auth service and a second, lighter instance used purely for type inference in the shared auth package — and their plugin lists have already diverged. Keeping them in sync is manual and error-prone.

The concrete pains:

  • Fixing an auth bug means editing up to 17 near-identical-but-subtly-different files, and it's easy to miss one or "fix" it inconsistently.
  • Only one service (contacts) caches session validation; the other services do a fresh HTTP round-trip to the auth service's get-session on every request — inconsistent latency and unnecessary load on the auth service.
  • The drift is a correctness/authorization risk: some validators fail closed when there's no active organization, others don't; some don't check the HTTP status at all.
  • The duplicated Better Auth config means the client's inferred types can silently fall out of step with the server's real capabilities.
  • There is no test coverage anywhere for auth, so all of this is maintained blind.

Solution

As a backend engineer, I want one auth surface I can trust and reason about:

  • A single shared Elysia plugin factory (published from the shared auth package) that every backend service mounts. It registers the auth guard macros, validates both session cookies and API keys, caches results, and returns one canonical shape. Per-service configuration (base URL, Redis client, cache TTL) is injected at the call site so nothing is hardcoded and the drift cannot silently return.
  • A single source-of-truth Better Auth configuration in the shared auth package, exposed through separate export paths so server-only runtime dependencies never leak into browser or lightweight consumers. There is exactly one plugin list.
  • A single, consolidated API-key story: the separate api-key package is retired; validation moves into the middleware plugin and the pure key-generation/hashing helpers move to a dependency-light export of the shared auth package.
  • Consistent caching with correct revocation: every service caches validated sessions with a short TTL, and the auth service centrally evicts cached sessions on logout / password-change / organization-switch events so revocation is near-instant rather than purely TTL-bound.
  • A characterization test tripwire written before any code moves, so the unification is provably behaviour-preserving on the stable contracts.

The rollout is incremental — one service per pull request — starting with a low-risk pilot service and saving the trickiest services for last.

User Stories

  1. As a backend engineer, I want a single shared middleware plugin for auth, so that I fix auth logic in one place instead of ~17.
  2. As a backend engineer, I want the plugin exposed as a factory that takes injected config (base URL, Redis client, TTL), so that per-service differences are explicit parameters rather than divergent copies.
  3. As a backend engineer, I want the plugin to register a default auth guard macro, so that guarding a route is one line and behaves identically everywhere.
  4. As a backend engineer, I want an authNoOrg macro, so that routes that legitimately serve users without an active organization can opt out of the org requirement explicitly.
  5. As a backend engineer, I want an apiKeyAuth macro, so that machine-to-machine routes authenticate via API key using the same shared logic.
  6. As a backend engineer, I want a platformAdmin macro, so that platform-admin-only endpoints are guarded consistently without each service reimplementing the role check.
  7. As a service consumer of the auth context, I want one canonical AuthContext shape, so that I never again guess whether the field is organizationId or activeOrganizationId or whether authType is "auth" or "session".
  8. As a security-conscious engineer, I want the default auth macro to fail closed when there is no active organization, so that forgetting a guard locks users out loudly instead of silently exposing an org-scoped route.
  9. As an engineer auditing the system, I want every existing route classified as org-required or org-optional during migration, so that the fail-closed default is applied deliberately and nothing regresses.
  10. As a platform operator, I want session validation cached with a short TTL in every service, so that request latency is consistent and the auth service isn't hit on every request.
  11. As a security-conscious engineer, I want cached sessions keyed by session token (not by a hash of the whole cookie header), so that individual sessions can be located and evicted.
  12. As a security-conscious engineer, I want a per-user index of session tokens maintained in the cache, so that all of a user's sessions can be evicted at once on password change or organization switch.
  13. As a user who logs out, I want my session cache entry evicted immediately, so that my token stops working right away rather than up to the TTL later.
  14. As a user who changes my password, I want all my sessions invalidated near-instantly, so that a compromised session cannot linger.
  15. As a user who switches organizations, I want stale cached sessions cleared, so that requests reflect my current active organization promptly.
  16. As a backend engineer, I want cache eviction centralized in the auth service (the owner of the relevant events), so that individual services don't each run duplicate bus subscribers racing to evict the same shared-Redis keys.
  17. As a backend engineer, I want a single source-of-truth Better Auth configuration, so that the client's inferred types cannot drift from the server's real plugin list.
  18. As a frontend engineer, I want server-only auth dependencies kept out of the browser bundle, so that consuming shared auth types/client never pulls in database, Redis, or email code.
  19. As a backend engineer, I want the shared auth package split into clear export paths (runtime server, browser client, type-only, middleware, api-key helpers), so that each consumer imports only what it needs.
  20. As a backend engineer minting API keys, I want lightweight generation/hashing helpers that don't drag in Elysia or session-middleware machinery, so that the api-key service and the email onboarding flow stay decoupled from request middleware.
  21. As a backend engineer, I want API-key validation to live in the middleware plugin and reuse the existing hashed-lookup-with-cache behaviour, so that key auth is unified with session auth under one plugin.
  22. As a backend engineer, I want an API-key request's organization derived from the key's owning organization, so that the fail-closed org rule still holds for machine callers.
  23. As the maintainer, I want the standalone api-key package retired and all its importers repointed, so that there is one fewer package and one fewer place for logic to diverge.
  24. As an engineer refactoring with no safety net, I want characterization tests for the stable contracts (get-session and API-key validation) written before any code moves, so that the unification is provably behaviour-preserving.
  25. As an engineer, I want those tests to run against ephemeral Postgres and Redis via the repo's test runner, so that they exercise real behaviour rather than mocks that would only test themselves.
  26. As an engineer, I want a package-level test suite for the unified plugin (mounted on a throwaway Elysia app), so that session auth, API-key auth, caching, fail-closed org logic, and all four macros are verified in one place rather than 17 times.
  27. As an engineer, I want tests that assert central eviction actually removes the session-token key and the per-user index entries on the relevant events, so that revocation correctness is guaranteed.
  28. As a reviewer, I want the rollout to proceed one service per pull request, so that each behaviour-affecting migration is small, reviewable, and easy to revert.
  29. As a reviewer, I want a low-risk service piloted first, so that the plugin API and test harness are shaken out before the risky services migrate.
  30. As a reviewer, I want the trickiest services (the one that already caches, and the platform-admin services) migrated last, so that the pattern is proven before the hardest cases.
  31. As an engineer migrating a service, I want the old per-service middleware file deleted as part of that service's pull request, so that dead, drift-prone code doesn't linger.
  32. As an engineer migrating a platform-admin service, I want a decision on how it obtains the user's email/name (which the lean AuthContext omits) for its audit logging, so that observability isn't silently lost.

Implementation Decisions

Modules built / modified

  • Shared auth package becomes the single source of truth, reorganized into distinct export paths:
    • a server export — the one real runtime Better Auth instance (database adapter, Redis secondary storage, email, hooks, full plugin list), served by the thin auth service.
    • a client export — the browser Better Auth client (unchanged in purpose).
    • a types export — type-only surface for consumers that just need User / Session / context types.
    • a middleware export — the new Elysia plugin factory (session + API-key validation, caching, macros).
    • an apikey export — dependency-light key generation/hashing helpers (no Elysia, no Redis).
  • Auth service — keeps only the thin HTTP server that mounts the shared runtime instance; additionally becomes the owner of central cache eviction, subscribing to the relevant lifecycle events and evicting the shared-Redis session entries and per-user index.
  • The ~17 backend services — each drops its bespoke cookie-auth / api-key-auth / auth middleware files and instead mounts the shared plugin factory, migrating its route handlers to the canonical AuthContext.
  • Standalone api-key package — retired; validation absorbed into the middleware export, generation/hashing helpers absorbed into the apikey export, all importers repointed.
  • The second, type-inference-only betterAuth() instance is deleted; its role is served by the single source-of-truth config via the type-only export.

Interfaces / contracts

  • Plugin factory: an Elysia plugin registered per service, taking injected configuration — the auth service base URL, a Redis client, and a cache TTL (default 5 seconds). It registers four guard macros: auth (default, fail-closed on missing active organization), authNoOrg (org optional), apiKeyAuth (API-key path), and platformAdmin (requires platform-admin role).
  • Canonical AuthContext (the one allowed decision-encoding snippet; from the grilling session):
type AuthContext = {
  userId: string;
  organizationId: string | null;   // null only reachable via authNoOrg / platformAdmin routes
  role: string | null;
  authType: "session" | "apikey";  // standardized; the legacy "auth" literal is removed
};
  • Caching contract: validated sessions are cached under a key derived from the session token (extracted from the cookie), not from a hash of the entire cookie header. A per-user index maps a user to the set of that user's cached session tokens. On a cache miss the plugin writes both the session-token entry and the user-index membership.
  • Eviction contract (centralized in the auth service): on logout, evict that single session-token entry; on password-change or organization-switch, read the user index and evict the whole set. There is one shared Redis across all services, so central eviction covers every service; the key convention is owned by the shared package so it stays consistent.
  • API-key contract: the apiKeyAuth path validates via the absorbed hashed-lookup-with-cache logic and normalizes into AuthContext with authType: "apikey", deriving organizationId from the key's owning organization so the fail-closed rule holds.

Architectural decisions

  • Fail-closed is the default org posture; opting out is explicit and per-route.
  • One Better Auth plugin list, structurally enforced by there being one instance.
  • Server-only dependencies are quarantined behind export paths so they cannot leak into browser or lightweight consumers.
  • Rollout is incremental (one service per PR), pilot-first, risky-services-last.

Testing Decisions

  • What makes a good test here: it asserts externally observable behaviour — HTTP status codes, the returned AuthContext, and cache/eviction side effects observable in Redis — never internal implementation details of the middleware. Tests should survive the internal refactor unchanged.
  • Highest / primary seam — the plugin factory. Mount the plugin on a throwaway Elysia app with injected { baseUrl, redis, ttl }, exercise guarded routes, and assert status (401 vs 200) and the resulting AuthContext. This single seam covers session validation, API-key validation, caching, fail-closed org logic, and all four macros. It is tested once at the package level, not replicated per service.
  • Pre-refactor characterization tripwire — existing stable contracts. Before moving code, pin current behaviour of the two seams everything depends on: the auth service's get-session HTTP endpoint (valid / invalid / expired session) and API-key validation (valid / invalid / revoked key). The drifted per-service middleware is intentionally being deleted, so its variants are not pinned.
  • Eviction seam. Drive the auth service's lifecycle-event handlers (logout / password-change / organization-switch) and assert the session-token key and per-user index entries are removed from Redis.
  • Generation utils seam. Trivial function-level unit tests for key generation, hashing, and key-start extraction.
  • Infrastructure: the repo's built-in test runner, run against ephemeral Postgres and Redis (containerized), so tests exercise real Better Auth session logic rather than mocks. Mocking the database/Redis is explicitly rejected for auth — it would test the mocks, not reality.
  • Per-service work gets only a smoke check after migration (login, logout, one protected endpoint, one API-key call), not its own dedicated seam.
  • Prior art: none — there are currently no tests in the repo. This spec establishes the first test seams; the plugin-factory suite is intended to be the reference pattern for future service tests.

Out of Scope

  • All security hardening is explicitly deferred and is not part of this refactor: the committed fallback signing secret, the default that disables TLS certificate verification, the default database/Redis credentials, and the wildcard CORS / trusted-origins settings. These were reviewed and consciously excluded from this work.
    • Operational caveat (not a code change in this spec): the exposed signing secret should be rotated in production out-of-band, since deferring the code fix otherwise leaves forgeable sessions possible.
  • No new authentication providers, SSO/SAML, or passkeys.
  • No change to the fundamental cross-service validation architecture beyond making caching consistent (the HTTP get-session model is retained).
  • No change to the session lifetime / refresh policy.
  • No change to the organization / membership / invitation model itself.

Further Notes

  • Drift evidence (motivating the "not a lift-and-shift" framing): across services the copies disagree on caching (only one caches), HTTP-status checking, org-required behaviour, return field names (organizationId vs activeOrganizationId), the authType string ("auth" vs "session"), and platform-admin support. Unifying therefore forces choosing canonical behaviour, which is why the characterization tripwire is written first.
  • Known open detail to resolve during migration: the lean AuthContext omits email / name that the admin service's current middleware returns for audit logging (isPlatformAdmin is derivable from role). Those consumers need either a separate lookup or a scoped richer context, decided in that service's PR.
  • One shared Redis across all services is confirmed, which is what makes central eviction viable.
  • Suggested follow-up: the tiny-commit sequencing for this refactor can be planned with the request-refactor-plan skill once this spec is accepted.

Metadata

Metadata

Assignees

No one assigned

    Labels

    ready-for-agentFully specified, ready for an AFK agent

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions