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.issuespan +auth.tokens_issued_totalcounterauth.token.verifyspan +auth.token_verify_totalcounter (labelvalid: bool)auth.apikey.createspan +auth.apikeys_created_totalcounterauth.apikey.validatespan +auth.apikey_validate_totalcounter (labelvalid: bool)auth.session.create/auth.session.deletecountersauth.permission.checkhistogram (duration_ms)
6. Zod schemas
ApiKeyRequestSchema, JWTOptionsSchema, ApiKeyOptionsSchema, MiddlewareOptionsSchema, AuthAdapterConfigSchema.
7. Property-based tests (fast-check)
- API key round-trip:
createApiKey→validateApiKey→valid === truefor any validApiKeyRequest. - Token blacklist monotonicity: once a
jtiis blacklisted it is always blacklisted. - Permission check associativity:
requireAllPermissions([a, b])iffcheckPermission(user, a).allowed && checkPermission(user, b).allowed.
8. Benchmark suite
createApiKeythroughput (cold + warm adapter path)validateApiKeythroughput against a MemoryAdapter with 1 000 stored keysvalidateSessionTokenwith 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.