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

326 lines
12 KiB
Markdown

# 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
```ts
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
```ts
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
```ts
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
```ts
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
```ts
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
```text
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.