Files
suaveplan/openspec/archive/2026-04-24-enterprise-auth/proposal.md
2026-05-27 23:29:53 +01:00

12 KiB

OpenSpec Change: enterprise-auth

  • Change ID: enterprise-auth
  • Package: @suaveplan/auth
  • Category: universal
  • Target path: universal/auth/auth
  • Status: PENDING
  • Date: 2026-04-24
  • Reference spec: /mnt/s/web/suaveplan/enterprise/auth.md

Summary

Deliver @suaveplan/auth on the genesis branch from scratch at enterprise quality. The package provides a framework-agnostic authentication layer: JWT token lifecycle (issue, verify, refresh, revoke), API key generation and validation, session management, and request middleware for permission checking. The main-branch implementation was tightly coupled to Better-Auth and SurrealDB; the genesis rewrite is provider-agnostic with pluggable adapters for the underlying auth store, structured errors via @suaveplan/error, Zod option schemas, OTel instrumentation, AbortSignal cancellation on async operations, injectable clock and ID generator for determinism, and property-based tests.


Current-state audit (genesis)

The package does not exist on genesis yet — this is a fresh build. No prior source, no test results, no coverage data.


Available workspace deps on genesis

Package Role
@suaveplan/error (workspace:^) Base class for AuthError; defineCodes for AUTH.* error codes
@suaveplan/types (workspace:^) Clock (injectable for token timestamps), IdGenerator (token/session IDs), LoggerLike, StorageLike
@suaveplan/events (workspace:^) EventBusLike for auth lifecycle events (login, logout, token revoked)
@suaveplan/crypto (workspace:^) Signing tokens, HMAC for API keys, constant-time comparison
@suaveplan/crypto-keys (workspace:^) Key pair management for JWT signing
@suaveplan/logger (workspace:^) Structured logging with trace correlation
@suaveplan/retry (workspace:^) Retry with backoff on transient storage errors
@suaveplan/security-codes (workspace:^) API key entropy validation
@suaveplan/idempotency (workspace:^) Idempotency guard on token creation / API key generation
zod (catalog:forms) Zod schemas for all public option types
@opentelemetry/api (catalog:observability) Spans, counters, histograms
tslib (catalog:build) TypeScript runtime helpers

Dev deps: vitest, @vitest/coverage-istanbul, fast-check, mitata (all catalog:testing), @suaveplan/testing (workspace:^).


Preserved public API surface (from main branch)

All symbols below are carried forward. Signatures that referenced Better-Auth or SurrealDB types are replaced with framework-agnostic interfaces; existing call sites using the adapter pattern compile unchanged.

API Keys

interface ApiKey {
  id: string
  key: string
  name: string
  userId: string
  permissions: string[]
  expiresAt?: Date
  createdAt: Date
  lastUsedAt?: Date
  revokedAt?: Date
}

interface ApiKeyRequest {
  name: string
  userId: string
  permissions?: string[]
  expiresIn?: number   // ms
}

interface ApiKeyGeneration {
  apiKey: ApiKey
  rawKey: string   // returned once only; never stored in plain text
}

interface ApiKeyValidationResult {
  valid: boolean
  apiKey?: ApiKey
  error?: string
}

function createApiKey(request: ApiKeyRequest, options?: ApiKeyOptions): Promise<ApiKeyGeneration>
function validateApiKey(rawKey: string, options?: ApiKeyOptions): Promise<ApiKeyValidationResult>
function revokeApiKey(keyId: string, options?: ApiKeyOptions): Promise<void>
function listApiKeys(userId: string, options?: ApiKeyOptions): Promise<ApiKey[]>
function rotateApiKey(keyId: string, options?: ApiKeyOptions): Promise<ApiKeyGeneration>
function updateApiKeyPermissions(keyId: string, permissions: string[], options?: ApiKeyOptions): Promise<ApiKey>
function hasPermission(apiKey: ApiKey, permission: string): boolean
function validateApiKeyMiddleware(options?: ApiKeyMiddlewareOptions): MiddlewareFn

JWT

interface JWTPayload {
  sub: string
  iat: number
  exp: number
  jti: string
  sessionId?: string
}

interface AuthenticatedJWTRequest {
  user: JWTPayload
}

interface TokenValidationResult {
  valid: boolean
  payload?: JWTPayload
  error?: string
}

function validateSessionToken(token: string, options?: JWTOptions): Promise<TokenValidationResult>
function refreshToken(refreshToken: string, options?: JWTOptions): Promise<{ accessToken: string; refreshToken: string }>
function revokeToken(jti: string, options?: JWTOptions): Promise<void>
function revokeAllUserTokens(userId: string, options?: JWTOptions): Promise<void>
function isTokenBlacklisted(jti: string, options?: JWTOptions): Promise<boolean>
function getUserSessions(userId: string, options?: JWTOptions): Promise<SessionInfo[]>
function verifyTokenMiddleware(options?: JWTOptions): MiddlewareFn
function optionalTokenMiddleware(options?: JWTOptions): MiddlewareFn

Middleware

interface UserContext {
  userId: string
  sessionId?: string
  permissions: string[]
  metadata?: Record<string, unknown>
}

interface AuthenticatedRequest {
  user: UserContext
}

interface PermissionCheckResult {
  allowed: boolean
  reason?: string
}

function getUserFromSession(sessionToken: string, options?: MiddlewareOptions): Promise<UserContext | null>
function checkPermission(user: UserContext, permission: string): PermissionCheckResult
function requirePermission(permission: string, options?: MiddlewareOptions): MiddlewareFn
function requireAllPermissions(permissions: string[], options?: MiddlewareOptions): MiddlewareFn
function requireAnyPermission(permissions: string[], options?: MiddlewareOptions): MiddlewareFn
function withPermissions(permissions: string[], options?: MiddlewareOptions): MiddlewareFn

Adapter interface

interface AuthAdapter {
  // API key storage
  storeApiKey(apiKey: ApiKey): Promise<void>
  findApiKeyByHash(hash: string): Promise<ApiKey | null>
  revokeApiKey(keyId: string): Promise<void>
  listApiKeysByUser(userId: string): Promise<ApiKey[]>
  // Token blacklist
  blacklistToken(jti: string, expiresAt: Date): Promise<void>
  isBlacklisted(jti: string): Promise<boolean>
  // Sessions
  createSession(session: SessionInfo): Promise<void>
  findSession(sessionId: string): Promise<SessionInfo | null>
  deleteSession(sessionId: string): Promise<void>
  deleteUserSessions(userId: string): Promise<void>
  listUserSessions(userId: string): Promise<SessionInfo[]>
}

interface SessionInfo {
  sessionId: string
  userId: string
  createdAt: Date
  expiresAt: Date
  userAgent?: string
  ip?: string
}

// In-memory adapter (no external storage dependency)
class MemoryAuthAdapter implements AuthAdapter

Error codes

type AuthErrorCode =
  | "AUTH_TOKEN_INVALID"
  | "AUTH_TOKEN_EXPIRED"
  | "AUTH_TOKEN_BLACKLISTED"
  | "AUTH_SESSION_NOT_FOUND"
  | "AUTH_SESSION_EXPIRED"
  | "AUTH_API_KEY_INVALID"
  | "AUTH_API_KEY_EXPIRED"
  | "AUTH_API_KEY_REVOKED"
  | "AUTH_PERMISSION_DENIED"
  | "AUTH_STORAGE_ERROR"

class AuthError extends SuaveplanError<AuthErrorCode>
function registerAuthErrorCodes(): void

New additions for genesis

1. Provider-agnostic design

The genesis rewrite removes all direct Better-Auth and SurrealDB dependencies. The AuthAdapter interface (above) is the single integration point. A MemoryAuthAdapter ships in the package for use in tests and single-node deployments. Framework-specific adapters (SurrealDB, Redis, PostgreSQL) live in separate packages.

2. Pluggable middleware interface

MiddlewareFn is a framework-agnostic (req, res, next) => void | Promise<void> interface with a createMiddlewareAdapter(framework) bridge. Hono and plain Node adapters ship out of the box.

3. Clock and IdGenerator injection

All token/session factories accept optional { clock?: Clock; idGenerator?: IdGenerator } so timestamps and IDs are deterministic in tests. No bare Date.now() or crypto.randomUUID() at module scope.

4. EventBus integration

Lifecycle events emitted on an optional EventBusLike (from @suaveplan/types):

Event Payload
auth:token:issued { userId, jti, expiresAt }
auth:token:revoked { jti, reason }
auth:session:created { sessionId, userId }
auth:session:deleted { sessionId, userId }
auth:apikey:created { keyId, userId }
auth:apikey:revoked { keyId, userId }

5. OTel instrumentation

  • auth.token.issue span + auth.tokens_issued_total counter
  • auth.token.verify span + auth.token_verify_total counter (label valid: bool)
  • auth.apikey.create span + auth.apikeys_created_total counter
  • auth.apikey.validate span + auth.apikey_validate_total counter (label valid: bool)
  • auth.session.create / auth.session.delete counters
  • auth.permission.check histogram (duration_ms)

6. Zod schemas

ApiKeyRequestSchema, JWTOptionsSchema, ApiKeyOptionsSchema, MiddlewareOptionsSchema, AuthAdapterConfigSchema.

7. Property-based tests (fast-check)

  • API key round-trip: createApiKey → validateApiKey → valid === true for any valid ApiKeyRequest.
  • Token blacklist monotonicity: once a jti is blacklisted it is always blacklisted.
  • Permission check associativity: requireAllPermissions([a, b]) iff checkPermission(user, a).allowed && checkPermission(user, b).allowed.

8. Benchmark suite

  • createApiKey throughput (cold + warm adapter path)
  • validateApiKey throughput against a MemoryAdapter with 1 000 stored keys
  • validateSessionToken with HMAC verification

File layout

universal/auth/auth/
  package.json
  tsconfig.json
  vitest.config.ts
  src/
    index.ts                              # Public barrel
    error-codes.ts                        # AUTH.* codes + registerAuthErrorCodes
    constants/
      index.ts
    types/
      auth.types.ts                       # All public interfaces and type aliases
      auth.types.md
    adapters/
      auth-adapter/
        auth-adapter.ts                   # AuthAdapter interface
        auth-adapter.md
        auth-adapter.types.ts
        index.ts
      memory-adapter/
        memory-adapter.ts                 # MemoryAuthAdapter
        memory-adapter.md
        memory-adapter.test.ts
        index.ts
    api-keys/
      api-keys/
        api-keys.ts                       # createApiKey, validateApiKey, etc.
        api-keys.md
        api-keys.types.ts
        api-keys.test.ts
        index.ts
    jwt/
      token-lifecycle/
        token-lifecycle.ts                # validateSessionToken, refreshToken, revokeToken, etc.
        token-lifecycle.md
        token-lifecycle.types.ts
        token-lifecycle.test.ts
        index.ts
    middleware/
      auth-middleware/
        auth-middleware.ts                # requirePermission, withPermissions, etc.
        auth-middleware.md
        auth-middleware.types.ts
        auth-middleware.test.ts
        index.ts
    schemas/
      auth.schemas.ts                     # Zod option schemas
      auth.schemas.md
      auth.schemas.test.ts
    __benchmarks__/
      auth.bench.ts
  tests/
    integration.test.ts
    property.test.ts

Impact

Downstream packages: @suaveplan/permissions consumes UserContext from this package. Future API plugin packages will depend on the middleware types.

Breaking changes from main-branch API: The SurrealDB-specific surrealAdapter export is removed; callers must use a dedicated @suaveplan/auth-adapter-surrealdb package (not in scope for this change). All other exported symbols are preserved or have equivalent replacements. AuthAdapter is a new addition.

Workspace dep prerequisites: @suaveplan/error, @suaveplan/types, @suaveplan/events, @suaveplan/crypto, @suaveplan/crypto-keys, @suaveplan/logger, @suaveplan/retry, @suaveplan/security-codes, @suaveplan/idempotency must all be published to Verdaccio before this package can be built or tested.