326 lines
12 KiB
Markdown
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.
|