Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,338 @@
# OpenSpec Change: enterprise-jwt-tenant
- **Change ID**: enterprise-jwt-tenant
- **Package**: `@suaveplan/jwt-tenant`
- **Category**: universal
- **Target path**: `universal/auth/jwt-tenant`
- **Status**: PENDING
- **Date**: 2026-04-24
- **Reference spec**: `/mnt/s/web/suaveplan/enterprise/jwt-tenant.md`
---
## Summary
Deliver `@suaveplan/jwt-tenant` on the genesis branch from scratch at enterprise quality. The package provides cross-domain JWT with per-tenant JWKS: key pair generation and serialization, token issuance (`TokenIssuer`) with tenant claims, token verification (`TokenVerifier`) with JWKS cache, in-memory and remote JWKS endpoints, and a `TenantKeyStore` interface for pluggable key storage. The genesis rewrite removes the direct `jose` library coupling from the public interface, adds structured errors via `@suaveplan/error`, Zod option schemas, an injectable clock for deterministic token timestamps, OTel instrumentation on all async operations, `AbortSignal` cancellation on JWKS fetches, property-based tests, and mitata benchmarks.
---
## Current-state audit (genesis)
The package does not exist on genesis yet — this is a fresh build.
---
## Available workspace deps on genesis
| Package | Role |
|---|---|
| `@suaveplan/error` (`workspace:^`) | Base class for `JwtTenantError`; `defineCodes` for `JWT.*` error codes |
| `@suaveplan/types` (`workspace:^`) | `Clock` (injectable token timestamps + JWKS cache TTL), `LoggerLike` |
| `@suaveplan/crypto` (`workspace:^`) | `randomBytes`, `constantTimeEqual`, `zeroize` — used in key generation and secure comparison |
| `@suaveplan/crypto-keys` (`workspace:^`) | `generateKeyPair`, `KeychainLike`, key serialization |
| `@suaveplan/retry` (`workspace:^`) | Retry on transient JWKS fetch failures |
| `jose` (`catalog:auth`) | JWT sign/verify/JWK import/export primitives |
| `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)
### Types
```ts
type KeyAlgorithm = "ES256" | "ES384" | "ES512" | "EdDSA" | "RS256" | "RS384" | "RS512"
interface KeyPair {
keyId: string
algorithm: KeyAlgorithm
privateKey: unknown // CryptoKey | KeyLike — opaque to callers
publicKey: unknown
createdAt: Date
expiresAt?: Date
}
interface SerializedKeyPair {
keyId: string
algorithm: KeyAlgorithm
privateKeyJwk: JWK
publicKeyJwk: JWK
createdAt: string // ISO-8601
expiresAt?: string
}
interface JWK {
kty: string
kid?: string
alg?: string
use?: string
[claim: string]: unknown
}
interface JWKS {
keys: JWK[]
}
interface TenantJWTPayload {
sub: string
iat: number
exp: number
jti: string
tenant_id: string
domains?: string[]
role?: string
permissions?: string[]
session_id?: string
[claim: string]: unknown
}
interface TokenIssuerConfig {
issuer?: string
defaultExpiresIn?: string // e.g. "1h"
clock?: Clock
}
interface CreateTokenOptions {
subject: string
tenantId: string
expiresIn?: string
domains?: string[]
role?: string
permissions?: string[]
sessionId?: string
issuer?: string
additionalClaims?: Record<string, unknown>
}
interface TokenVerifierConfig {
issuer?: string
audience?: string[]
clockTolerance?: number // seconds
jwksUri?: string
cacheMaxAge?: number // ms; default 5 min
clock?: Clock
signal?: AbortSignal
}
interface VerifyTokenOptions {
requiredClaims?: string[]
requiredRole?: string
requiredPermissions?: string[]
requiredTenantId?: string
requiredDomains?: string[]
}
interface VerifyTokenResult {
valid: boolean
payload?: TenantJWTPayload
error?: string
errorCode?: string
}
interface JWKSEndpointOptions {
keyStore: TenantKeyStore
cacheMaxAge?: number
clock?: Clock
}
interface JWKSEndpointConfig {
path?: string
includeExpired?: boolean
}
```
### Key generation
```ts
function generateKeyPair(algorithm?: KeyAlgorithm, options?: KeyGenOptions): Promise<KeyPair>
function serializeKeyPair(keyPair: KeyPair): Promise<SerializedKeyPair>
function deserializeKeyPair(serialized: SerializedKeyPair): Promise<KeyPair>
function keyPairToJWK(keyPair: KeyPair): Promise<JWK>
function importPublicKeyFromJWK(jwk: JWK): Promise<unknown>
function calculateKeyExpiration(createdAt: Date, ttlMs: number): Date
function isKeyPairExpired(keyPair: KeyPair, clock?: Clock): boolean
function getAlgorithmFamily(algorithm: KeyAlgorithm): "EC" | "OKP" | "RSA"
```
### Token issuance
```ts
class TokenIssuer {
constructor(config: TokenIssuerConfig)
createToken(keyPair: KeyPair, options: CreateTokenOptions): Promise<string>
}
function createTokenIssuer(config: TokenIssuerConfig): TokenIssuer
function signToken(keyPair: KeyPair, options: CreateTokenOptions, config?: TokenIssuerConfig): Promise<string>
```
### Token verification
```ts
class TokenVerifier {
constructor(config: TokenVerifierConfig)
verifyToken(token: string, options?: VerifyTokenOptions): Promise<VerifyTokenResult>
clearCache(): void
}
function createTokenVerifier(config: TokenVerifierConfig): TokenVerifier
function verifyTokenWithKey(token: string, key: unknown, options?: VerifyTokenOptions): Promise<VerifyTokenResult>
```
### JWKS endpoint
```ts
interface TenantKeyStore {
getActiveKeys(tenantId?: string): Promise<KeyPair[]>
getKeyById(keyId: string): Promise<KeyPair | null>
addKey(keyPair: KeyPair): Promise<void>
removeKey(keyId: string): Promise<void>
}
class InMemoryKeyStore implements TenantKeyStore {
constructor(clock?: Clock)
}
class JWKSEndpoint {
constructor(options: JWKSEndpointOptions)
getJWKS(config?: JWKSEndpointConfig): Promise<JWKS>
handleRequest(request: unknown): Promise<{ status: number; body: string; headers: Record<string, string> }>
}
function createInMemoryKeyStore(clock?: Clock): InMemoryKeyStore
function createJWKSEndpoint(options: JWKSEndpointOptions): JWKSEndpoint
function formatJWKSResponse(keys: JWK[]): JWKS
```
### Error codes
```ts
type JwtTenantErrorCode =
| "JWT_INVALID_SIGNATURE"
| "JWT_EXPIRED"
| "JWT_INVALID_CLAIMS"
| "JWT_MISSING_TENANT"
| "JWT_TENANT_MISMATCH"
| "JWT_KEY_NOT_FOUND"
| "JWT_JWKS_FETCH_FAILED"
| "JWT_ALGORITHM_UNSUPPORTED"
| "JWT_SERIALIZATION_FAILED"
class JwtTenantError extends SuaveplanError<JwtTenantErrorCode>
function registerJwtTenantErrorCodes(): void
```
---
## New additions for genesis
### 1. Clock injection throughout
All `Date.now()` calls replaced by injectable `clock.now()` (from `@suaveplan/types`). Affects `TokenIssuer` (iat/exp), `TokenVerifier` (clock-tolerance check), `InMemoryKeyStore` (expiry checks), `PermissionCache`. Default uses `Date.now()` when no clock is injected.
### 2. AbortSignal on JWKS fetch
`TokenVerifier` and `JWKSEndpoint` accept an `AbortSignal` in their configs. Remote JWKS fetch passes the signal through to `fetch`. Abort triggers `JwtTenantError` with code `JWT_JWKS_FETCH_FAILED`.
### 3. Retry on JWKS fetch
`TokenVerifier` uses `@suaveplan/retry` with exponential backoff for transient JWKS fetch failures. Max 3 attempts by default; configurable via `TokenVerifierConfig.retryOptions`.
### 4. OTel instrumentation
- `jwt.sign` span + `jwt.tokens_signed_total` counter (label `algorithm`)
- `jwt.verify` span + `jwt.tokens_verified_total` counter (label `valid: bool`, `algorithm`)
- `jwt.jwks_fetch` span + `jwt.jwks_fetch_total` counter (label `cached: bool`)
- `jwt.key_generate` span + histogram `jwt.keygen_duration_ms` (label `algorithm`)
- JWKS cache: `jwt.jwks_cache_hit_total` / `jwt.jwks_cache_miss_total` counters
### 5. Zod schemas
`TokenIssuerConfigSchema`, `CreateTokenOptionsSchema`, `TokenVerifierConfigSchema`, `VerifyTokenOptionsSchema`, `JWKSEndpointOptionsSchema`, `KeyGenOptionsSchema`, `SerializedKeyPairSchema`.
### 6. Property-based tests (fast-check)
- Sign → verify round-trip: for any valid `CreateTokenOptions`, `verifyToken(signToken(kp, opts))` returns `valid === true` and payload matches input.
- Expired token detection: token with `exp` in the past always returns `valid === false`.
- Tenant claim preservation: `payload.tenant_id` always equals `options.tenantId` after round-trip.
- Serialization round-trip: `deserializeKeyPair(serializeKeyPair(kp))` yields functionally equivalent key pair (same keyId, algorithm, can sign/verify).
### 7. Benchmark suite
- `generateKeyPair` for ES256, ES384, EdDSA — 1 000 iterations each.
- `signToken` (ES256) — 10 000 iterations.
- `verifyToken` with warm in-memory key — 10 000 iterations.
- `verifyToken` with JWKS cache hit vs miss — 10 000 iterations each.
---
## File layout
```text
universal/auth/jwt-tenant/
package.json
tsconfig.json
vitest.config.ts
src/
index.ts
error-codes.ts
constants/
index.ts # DEFAULT_EXPIRES_IN, ROLE_HIERARCHY, algorithm families
types/
jwt-tenant.types.ts
jwt-tenant.types.md
jwks/
jwks-endpoint/
jwks-endpoint.ts
jwks-endpoint.md
jwks-endpoint.types.ts
jwks-endpoint.test.ts
index.ts
in-memory-key-store/
in-memory-key-store.ts
in-memory-key-store.md
in-memory-key-store.test.ts
index.ts
keys/
key-generator/
key-generator.ts
key-generator.md
key-generator.test.ts
index.ts
token/
token-issuer/
token-issuer.ts
token-issuer.md
token-issuer.types.ts
token-issuer.test.ts
index.ts
token-verifier/
token-verifier.ts
token-verifier.md
token-verifier.types.ts
token-verifier.test.ts
index.ts
schemas/
jwt-tenant.schemas.ts
jwt-tenant.schemas.md
jwt-tenant.schemas.test.ts
__benchmarks__/
jwt-tenant.bench.ts
tests/
integration.test.ts
property.test.ts
```
---
## Impact
**Downstream packages**: `@suaveplan/auth` may use `TokenVerifier` and `TokenIssuer` for session token management.
**Breaking changes from main-branch API**: None. All main-branch exported symbols are preserved. `jose` types in public interface positions are replaced with opaque `unknown` wrapped in documented generic aliases (same as main branch pattern).
**Workspace dep prerequisites**: `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/crypto`, `@suaveplan/crypto-keys`, `@suaveplan/retry` must be published to Verdaccio before this package can be built or tested.