Initial Commit
This commit is contained in:
325
openspec/archive/2026-04-24-enterprise-auth/proposal.md
Normal file
325
openspec/archive/2026-04-24-enterprise-auth/proposal.md
Normal file
@@ -0,0 +1,325 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user