Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,91 @@
{
"name": "@suaveplan/jwt-tenant",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "JWT-based multi-tenant token issuance and verification with Ed25519/ECDSA signing, JWKS rotation, and injectable clock",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/jwt-tenant-error.d.ts",
"default": "./dist/errors/jwt-tenant-error.js"
}
},
"./keys/key-generator": {
"import": {
"types": "./dist/keys/key-generator/key-generator.d.ts",
"default": "./dist/keys/key-generator/key-generator.js"
}
},
"./jwks/in-memory-key-store": {
"import": {
"types": "./dist/jwks/in-memory-key-store/in-memory-key-store.d.ts",
"default": "./dist/jwks/in-memory-key-store/in-memory-key-store.js"
}
},
"./jwks/jwks-endpoint": {
"import": {
"types": "./dist/jwks/jwks-endpoint/jwks-endpoint.d.ts",
"default": "./dist/jwks/jwks-endpoint/jwks-endpoint.js"
}
},
"./token/token-issuer": {
"import": {
"types": "./dist/token/token-issuer/token-issuer.d.ts",
"default": "./dist/token/token-issuer/token-issuer.js"
}
},
"./token/token-verifier": {
"import": {
"types": "./dist/token/token-verifier/token-verifier.d.ts",
"default": "./dist/token/token-verifier/token-verifier.js"
}
},
"./schemas": {
"import": {
"types": "./dist/schemas/jwt-tenant.schemas.d.ts",
"default": "./dist/schemas/jwt-tenant.schemas.js"
}
}
},
"files": ["dist", "README.md"],
"scripts": {
"build": "vite build",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome check --write src/",
"docs:check": "bunx markdownlint-cli2 \"src/**/*.md\""
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"@suaveplan/retry": "workspace:^",
"jose": "catalog:security",
"zod": "catalog:forms",
"@opentelemetry/api": "catalog:observability",
"tslib": "catalog:build"
},
"devDependencies": {
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"@suaveplan/testing": "workspace:^"
}
}

View File

@@ -0,0 +1,42 @@
import type { KeyAlgorithm } from "../types/jwt-tenant.types.js";
/** Default JWT expiration when no override is provided. */
export const DEFAULT_EXPIRES_IN = "1h";
/** Default JWKS cache TTL (5 minutes in milliseconds). */
export const DEFAULT_JWKS_CACHE_MAX_AGE = 5 * 60 * 1000;
/** Default clock skew tolerance in seconds. */
export const DEFAULT_CLOCK_TOLERANCE = 0;
/** Default maximum JWKS fetch retries. */
export const DEFAULT_MAX_RETRIES = 3;
/** Unique token ID byte length (produces 32-char hex string). */
export const TOKEN_ID_BYTES = 16;
/** Algorithm family mapping for key generation. */
export const ALGORITHM_FAMILIES: Record<KeyAlgorithm, "EC" | "OKP" | "RSA"> = {
ES256: "EC",
ES384: "EC",
ES512: "EC",
EdDSA: "OKP",
RS256: "RSA",
RS384: "RSA",
RS512: "RSA",
};
/** Curve name for EC algorithms. */
export const ALGORITHM_CURVES: Partial<Record<KeyAlgorithm, string>> = {
ES256: "P-256",
ES384: "P-384",
ES512: "P-521",
EdDSA: "Ed25519",
};
/** RSA modulus length in bits by algorithm. */
export const RSA_MODULUS_LENGTH: Partial<Record<KeyAlgorithm, number>> = {
RS256: 2048,
RS384: 3072,
RS512: 4096,
};

View File

@@ -0,0 +1,65 @@
# jwt-tenant-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the structured error hierarchy for `@suaveplan/jwt-tenant`. All thrown errors extend `SuaveplanError` with a stable code registry under the `JWT.*` namespace. Using `defineCodes` ensures code strings are deduplicated at module load time and are inspectable programmatically without string matching.
## Features
- `JWT_TENANT_CODES` registry with 9 distinct error codes
- `JwtTenantError` class extending `SuaveplanError<JwtTenantErrorCode>`
- Cause-chain forwarding: non-Error causes are swallowed, Error causes preserved
- Machine-inspectable error codes for upstream routing and logging
## Usage: Basic Example
```ts
import { JwtTenantError, JWT_TENANT_CODES } from "@suaveplan/jwt-tenant";
throw new JwtTenantError(
JWT_TENANT_CODES.EXPIRED,
"Token has expired"
);
```
## Usage: Advanced Example
```ts
import { JwtTenantError, JWT_TENANT_CODES } from "@suaveplan/jwt-tenant";
try {
await verifyToken(token, key);
} catch (raw) {
if (raw instanceof Error) {
throw new JwtTenantError(
JWT_TENANT_CODES.INVALID_SIGNATURE,
"Signature verification failed",
{ cause: raw }
);
}
}
```
## API Reference
### `JWT_TENANT_CODES`
Object mapping each error name to its fully qualified code string (e.g., `JWT.EXPIRED`). Produced by `defineCodes("JWT", [...])`.
### `JwtTenantError`
- **constructor(code, message, options?)** — Creates a structured error.
- `code` — `JwtTenantErrorCode`
- `message` — human-readable description
- `options.cause` — optional underlying `Error` for cause-chain
### `JwtTenantErrorCode`
Union type of all code keys: `"JWT_INVALID_SIGNATURE" | "JWT_EXPIRED" | ... | "JWT_SERIALIZATION_FAILED"`.
## Implementation Notes
`JwtTenantError` wraps the `SuaveplanError` constructor pattern established across Suaveplan. Non-Error causes are dropped rather than coerced because `SuaveplanError` only accepts `Error` in its cause chain — this prevents accidental serialisation of arbitrary objects.

View File

@@ -0,0 +1,81 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { JWT_TENANT_CODES, JwtTenantError } from "./jwt-tenant-error.js";
describe("JWT_TENANT_CODES", () => {
it("has all expected error codes", { timeout: 5000 }, () => {
expect(JWT_TENANT_CODES.INVALID_SIGNATURE).toBeDefined();
expect(JWT_TENANT_CODES.EXPIRED).toBeDefined();
expect(JWT_TENANT_CODES.INVALID_CLAIMS).toBeDefined();
expect(JWT_TENANT_CODES.MISSING_TENANT).toBeDefined();
expect(JWT_TENANT_CODES.TENANT_MISMATCH).toBeDefined();
expect(JWT_TENANT_CODES.KEY_NOT_FOUND).toBeDefined();
expect(JWT_TENANT_CODES.JWKS_FETCH_FAILED).toBeDefined();
expect(JWT_TENANT_CODES.ALGORITHM_UNSUPPORTED).toBeDefined();
expect(JWT_TENANT_CODES.SERIALIZATION_FAILED).toBeDefined();
});
it("codes contain the JWT namespace prefix", { timeout: 5000 }, () => {
expect(JWT_TENANT_CODES.EXPIRED).toBe("JWT.EXPIRED");
expect(JWT_TENANT_CODES.INVALID_SIGNATURE).toBe(
"JWT.INVALID_SIGNATURE"
);
});
});
describe("JwtTenantError", () => {
it("is an instance of Error", { timeout: 5000 }, () => {
const err = new JwtTenantError(
JWT_TENANT_CODES.EXPIRED,
"Token expired"
);
expect(err).toBeInstanceOf(Error);
});
it("has correct message", { timeout: 5000 }, () => {
const err = new JwtTenantError(
JWT_TENANT_CODES.EXPIRED,
"Token expired"
);
expect(err.message).toContain("Token expired");
});
it("has correct code", { timeout: 5000 }, () => {
const err = new JwtTenantError(
JWT_TENANT_CODES.INVALID_SIGNATURE,
"bad sig"
);
expect(err.code).toBe(JWT_TENANT_CODES.INVALID_SIGNATURE);
});
it("stores Error cause", { timeout: 5000 }, () => {
const cause = new Error("underlying");
const err = new JwtTenantError(
JWT_TENANT_CODES.JWKS_FETCH_FAILED,
"fetch failed",
{ cause }
);
expect(err.cause).toBe(cause);
});
it("drops non-Error cause", { timeout: 5000 }, () => {
const err = new JwtTenantError(
JWT_TENANT_CODES.SERIALIZATION_FAILED,
"fail",
{ cause: "string-cause" }
);
expect(err.cause).toBeUndefined();
});
it("works without options", { timeout: 5000 }, () => {
const err = new JwtTenantError(
JWT_TENANT_CODES.KEY_NOT_FOUND,
"key missing"
);
expect(err).toBeInstanceOf(JwtTenantError);
});
it("is instanceof JwtTenantError", { timeout: 5000 }, () => {
const err = new JwtTenantError(JWT_TENANT_CODES.EXPIRED, "expired");
expect(err).toBeInstanceOf(JwtTenantError);
});
});

View File

@@ -0,0 +1,33 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/** Stable error codes for jwt-tenant operations. */
export const JWT_TENANT_CODES = defineCodes("JWT", [
"INVALID_SIGNATURE",
"EXPIRED",
"INVALID_CLAIMS",
"MISSING_TENANT",
"TENANT_MISMATCH",
"KEY_NOT_FOUND",
"JWKS_FETCH_FAILED",
"ALGORITHM_UNSUPPORTED",
"SERIALIZATION_FAILED",
] as const);
export type JwtTenantErrorCode =
(typeof JWT_TENANT_CODES)[keyof typeof JWT_TENANT_CODES];
/**
* Structured error for all jwt-tenant failures.
* Extends `SuaveplanError` with a stable code registry under the `JWT.*` namespace.
*/
export class JwtTenantError extends SuaveplanError<JwtTenantErrorCode> {
constructor(
code: JwtTenantErrorCode,
message: string,
options: { cause?: unknown } = {}
) {
super(code, message, {
cause: options.cause instanceof Error ? options.cause : undefined,
});
}
}

View File

@@ -0,0 +1,71 @@
// Errors
export type { JwtTenantErrorCode } from "./errors/jwt-tenant-error.js";
export {
JWT_TENANT_CODES,
JwtTenantError,
} from "./errors/jwt-tenant-error.js";
// JWKS
export {
createInMemoryKeyStore,
InMemoryKeyStore,
} from "./jwks/in-memory-key-store/in-memory-key-store.js";
export {
createJWKSEndpoint,
formatJWKSResponse,
JWKSEndpoint,
} from "./jwks/jwks-endpoint/jwks-endpoint.js";
// Key generation
export {
calculateKeyExpiration,
deserializeKeyPair,
generateKeyPair,
getAlgorithmFamily,
importPublicKeyFromJWK,
isKeyPairExpired,
keyPairToJWK,
serializeKeyPair,
} from "./keys/key-generator/key-generator.js";
// Schemas
export {
CreateTokenOptionsSchema,
JWKSEndpointOptionsSchema,
KeyGenOptionsSchema,
SerializedKeyPairSchema,
TokenIssuerConfigSchema,
TokenVerifierConfigSchema,
VerifyTokenOptionsSchema,
} from "./schemas/jwt-tenant.schemas.js";
// Token issuance
export {
createTokenIssuer,
signToken,
TokenIssuer,
} from "./token/token-issuer/token-issuer.js";
// Token verification
export {
createTokenVerifier,
TokenVerifier,
verifyTokenWithKey,
} from "./token/token-verifier/token-verifier.js";
// Types
export type {
CreateTokenOptions,
JWK,
JWKS,
JWKSEndpointConfig,
JWKSEndpointOptions,
JWKSResponse,
KeyAlgorithm,
KeyGenOptions,
KeyPair,
SerializedKeyPair,
TenantJWTPayload,
TenantKeyStore,
TokenIssuerConfig,
TokenVerifierConfig,
VerifyTokenOptions,
VerifyTokenResult,
} from "./types/jwt-tenant.types.js";

View File

@@ -0,0 +1,74 @@
# in-memory-key-store
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides an in-process, non-persistent implementation of `TenantKeyStore`. Used as the default key store in development, testing, and single-node deployments where distributed key sharing is not required. The injectable `Clock` makes expiry checks deterministic in unit tests without real time manipulation.
## Features
- `InMemoryKeyStore` implementing the full `TenantKeyStore` interface
- `Map<keyId, KeyPair>` backing store with O(1) lookup
- Expiry-aware `getActiveKeys()` — filters out keys where `clock() >= expiresAt`
- `pruneExpired()` housekeeping method for long-running processes
- `createInMemoryKeyStore(clock?)` factory function
- Optional `tenantId` parameter in `getActiveKeys` (pass-through; use one store per tenant)
## Usage: Basic Example
```ts
import { InMemoryKeyStore } from "@suaveplan/jwt-tenant/jwks/in-memory-key-store";
import { generateKeyPair } from "@suaveplan/jwt-tenant/keys/key-generator";
const store = new InMemoryKeyStore();
const kp = await generateKeyPair("EdDSA");
await store.addKey(kp);
const active = await store.getActiveKeys();
console.log(active.length); // 1
```
## Usage: Advanced Example
```ts
import { InMemoryKeyStore } from "@suaveplan/jwt-tenant/jwks/in-memory-key-store";
import { generateKeyPair } from "@suaveplan/jwt-tenant/keys/key-generator";
let time = 0;
const clock = () => time;
const store = new InMemoryKeyStore(clock);
// Add a key that expires at t=1000
const kp = await generateKeyPair("EdDSA", { ttlMs: 1000, clock });
await store.addKey(kp);
time = 500;
console.log((await store.getActiveKeys()).length); // 1 — not yet expired
time = 2000;
console.log((await store.getActiveKeys()).length); // 0 — expired
store.pruneExpired(); // remove expired entries from the Map
```
## API Reference
### `InMemoryKeyStore`
Constructor: `new InMemoryKeyStore(clock?)`.
- **`getActiveKeys(tenantId?): Promise<KeyPair[]>`** — Returns all non-expired key pairs. Expired keys (where `clock() >= expiresAt`) are excluded. The `tenantId` parameter is accepted for interface compatibility but is a no-op in this implementation.
- **`getKeyById(keyId): Promise<KeyPair | null>`** — Looks up a key by its ID. Returns `null` when not found.
- **`addKey(keyPair): Promise<void>`** — Inserts or replaces a key pair in the store.
- **`removeKey(keyId): Promise<void>`** — Removes a key pair. No-op if the key doesn't exist.
- **`pruneExpired(): void`** — Synchronously removes all expired entries. Call periodically to prevent unbounded memory growth in long-running processes.
### `createInMemoryKeyStore(clock?): InMemoryKeyStore`
Factory function; equivalent to `new InMemoryKeyStore(clock)`.
## Implementation Notes
The `keys` map is `protected` (not `private`) to allow test subclasses to inspect internal state without public API exposure. The `tenantId` parameter in `getActiveKeys` is explicitly voided (`void tenantId`) to satisfy the TypeScript "unused variable" checker while documenting the intentional no-op. In production multi-tenant scenarios, use a separate `InMemoryKeyStore` instance per tenant or implement a `DatabaseKeyStore` with tenant-scoped queries.

View File

@@ -0,0 +1,141 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { generateKeyPair } from "../../keys/key-generator/key-generator.js";
import type { KeyPair } from "../../types/jwt-tenant.types.js";
import {
createInMemoryKeyStore,
InMemoryKeyStore,
} from "./in-memory-key-store.js";
function makeKeyPair(id: string, expiresAt?: Date): KeyPair {
return {
keyId: id,
algorithm: "EdDSA",
privateKey: {},
publicKey: {},
createdAt: new Date(1000),
...(expiresAt !== undefined && { expiresAt }),
};
}
describe("InMemoryKeyStore", () => {
let store: InMemoryKeyStore;
beforeEach(() => {
store = new InMemoryKeyStore();
});
it("starts empty", { timeout: 5000 }, async () => {
expect(await store.getActiveKeys()).toHaveLength(0);
});
it("adds and retrieves a key", { timeout: 30000 }, async () => {
const kp = await generateKeyPair("EdDSA", { keyId: "k1" });
await store.addKey(kp);
const active = await store.getActiveKeys();
expect(active).toHaveLength(1);
expect(active[0]!.keyId).toBe("k1");
});
it("getKeyById returns key when found", { timeout: 5000 }, async () => {
const kp = makeKeyPair("k2");
await store.addKey(kp);
const found = await store.getKeyById("k2");
expect(found).not.toBeNull();
expect(found!.keyId).toBe("k2");
});
it("getKeyById returns null when not found", {
timeout: 5000,
}, async () => {
const result = await store.getKeyById("nonexistent");
expect(result).toBeNull();
});
it("removes a key", { timeout: 5000 }, async () => {
await store.addKey(makeKeyPair("k3"));
await store.removeKey("k3");
expect(await store.getActiveKeys()).toHaveLength(0);
});
it("removeKey is a no-op for nonexistent key", {
timeout: 5000,
}, async () => {
await expect(store.removeKey("nope")).resolves.toBeUndefined();
});
it("filters out expired keys in getActiveKeys", {
timeout: 5000,
}, async () => {
const time = 1000;
const clock = () => time;
const s = new InMemoryKeyStore(clock);
await s.addKey(makeKeyPair("expired", new Date(500)));
await s.addKey(makeKeyPair("active", new Date(2000)));
const active = await s.getActiveKeys();
expect(active).toHaveLength(1);
expect(active[0]!.keyId).toBe("active");
});
it("ignores tenantId parameter (pass-through)", {
timeout: 5000,
}, async () => {
await store.addKey(makeKeyPair("k4"));
const active = await store.getActiveKeys("tenant-x");
expect(active).toHaveLength(1);
});
it("pruneExpired removes expired keys", { timeout: 5000 }, async () => {
const time = 1000;
const clock = () => time;
const s = new InMemoryKeyStore(clock);
await s.addKey(makeKeyPair("expired", new Date(500)));
await s.addKey(makeKeyPair("active", new Date(9999)));
s.pruneExpired();
const all = await s.getActiveKeys();
expect(all).toHaveLength(1);
expect(all[0]!.keyId).toBe("active");
});
it("pruneExpired keeps non-expired keys", { timeout: 5000 }, async () => {
const time = 100;
const clock = () => time;
const s = new InMemoryKeyStore(clock);
await s.addKey(makeKeyPair("active", new Date(9999)));
s.pruneExpired();
expect(await s.getActiveKeys()).toHaveLength(1);
});
it("pruneExpired ignores keys with no expiresAt", {
timeout: 5000,
}, async () => {
const s = new InMemoryKeyStore();
await s.addKey(makeKeyPair("no-expiry"));
s.pruneExpired();
expect(await s.getActiveKeys()).toHaveLength(1);
});
it("upserts on duplicate keyId", { timeout: 5000 }, async () => {
await store.addKey(makeKeyPair("dup"));
await store.addKey({
...makeKeyPair("dup"),
createdAt: new Date(9999),
});
const all = await store.getActiveKeys();
expect(all).toHaveLength(1);
});
it("createInMemoryKeyStore factory creates instance", {
timeout: 5000,
}, () => {
const s = createInMemoryKeyStore();
expect(s).toBeInstanceOf(InMemoryKeyStore);
});
it("createInMemoryKeyStore factory accepts clock", {
timeout: 5000,
}, () => {
const clock = () => 42;
const s = createInMemoryKeyStore(clock);
expect(s).toBeInstanceOf(InMemoryKeyStore);
});
});

View File

@@ -0,0 +1,62 @@
import type { Clock } from "@suaveplan/types";
import type { KeyPair, TenantKeyStore } from "../../types/jwt-tenant.types.js";
const defaultClock: Clock = () => Date.now();
/**
* In-memory implementation of `TenantKeyStore`.
*
* Stores key pairs in a `Map` keyed by `keyId`. The injectable `clock`
* controls expiry checks so tests can simulate time passing without
* real sleeps.
*/
export class InMemoryKeyStore implements TenantKeyStore {
protected readonly keys: Map<string, KeyPair> = new Map();
protected readonly clock: Clock;
constructor(clock: Clock = defaultClock) {
this.clock = clock;
}
async getActiveKeys(tenantId?: string): Promise<KeyPair[]> {
const now = this.clock();
const active: KeyPair[] = [];
for (const kp of this.keys.values()) {
if (kp.expiresAt !== undefined && kp.expiresAt.getTime() <= now) {
continue;
}
active.push(kp);
}
// tenantId filter is a no-op in the in-memory implementation —
// keys here are not tenant-scoped; callers use a single store per tenant.
void tenantId;
return active;
}
async getKeyById(keyId: string): Promise<KeyPair | null> {
return this.keys.get(keyId) ?? null;
}
async addKey(keyPair: KeyPair): Promise<void> {
this.keys.set(keyPair.keyId, keyPair);
}
async removeKey(keyId: string): Promise<void> {
this.keys.delete(keyId);
}
/** Remove all expired keys from the store (housekeeping). */
pruneExpired(): void {
const now = this.clock();
for (const [id, kp] of this.keys.entries()) {
if (kp.expiresAt !== undefined && kp.expiresAt.getTime() <= now) {
this.keys.delete(id);
}
}
}
}
/** Factory function for `InMemoryKeyStore`. */
export function createInMemoryKeyStore(clock?: Clock): InMemoryKeyStore {
return new InMemoryKeyStore(clock);
}

View File

@@ -0,0 +1,4 @@
export {
createInMemoryKeyStore,
InMemoryKeyStore,
} from "./in-memory-key-store.js";

View File

@@ -0,0 +1,5 @@
export {
createJWKSEndpoint,
formatJWKSResponse,
JWKSEndpoint,
} from "./jwks-endpoint.js";

View File

@@ -0,0 +1,81 @@
# jwks-endpoint
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides a framework-agnostic JWKS endpoint backed by a `TenantKeyStore`. The endpoint serialises active public keys from the store into a `{ keys: JWK[] }` JSON Web Key Set and exposes an HTTP response builder that any framework can consume. An internal cache reduces key-store reads on high-traffic endpoints, with the TTL controlled by `cacheMaxAge`.
## Features
- `JWKSEndpoint` class with configurable cache TTL and injectable clock
- `getJWKS(config?)` — fetches active public keys and returns a `JWKS` object
- `handleRequest(request)` — returns a framework-agnostic `{ status, body, headers }` response
- `clearCache()` — invalidates the internal JWKS cache on demand
- `formatJWKSResponse(keys)` — standalone pure function to wrap JWK arrays
- `createJWKSEndpoint(options)` — factory function
- `includeExpired` option to bypass cache and include all keys
## Usage: Basic Example
```ts
import { InMemoryKeyStore } from "@suaveplan/jwt-tenant/jwks/in-memory-key-store";
import { JWKSEndpoint } from "@suaveplan/jwt-tenant/jwks/jwks-endpoint";
import { generateKeyPair } from "@suaveplan/jwt-tenant/keys/key-generator";
const store = new InMemoryKeyStore();
const kp = await generateKeyPair("EdDSA");
await store.addKey(kp);
const endpoint = new JWKSEndpoint({ keyStore: store });
const response = await endpoint.handleRequest(null);
// response.status === 200
// JSON.parse(response.body).keys.length === 1
```
## Usage: Advanced Example
```ts
import { JWKSEndpoint } from "@suaveplan/jwt-tenant/jwks/jwks-endpoint";
// Use in a Hono or Express route
app.get("/.well-known/jwks.json", async (req, res) => {
const response = await endpoint.handleRequest(req);
res.status(response.status)
.set(response.headers)
.send(response.body);
});
// Force cache invalidation after key rotation
await store.addKey(newKeyPair);
endpoint.clearCache();
```
## API Reference
### `JWKSEndpoint`
Constructor: `new JWKSEndpoint(options: JWKSEndpointOptions)`.
- `options.keyStore` — `TenantKeyStore` implementation
- `options.cacheMaxAge` — cache TTL in milliseconds (default 5 min)
- `options.clock` — injectable clock for cache expiry
#### Methods
- **`getJWKS(config?): Promise<JWKS>`** — Returns active public keys as a JWKS. Caches result unless `config.includeExpired` is `true`.
- **`handleRequest(request): Promise<JWKSResponse>`** — Returns `{ status: 200, body: string, headers }`. The `request` parameter is accepted for interface compatibility.
- **`clearCache(): void`** — Resets the internal cache immediately.
### `formatJWKSResponse(keys: JWK[]): JWKS`
Pure function that wraps a `JWK[]` in a `{ keys }` envelope.
### `createJWKSEndpoint(options): JWKSEndpoint`
Factory function; equivalent to `new JWKSEndpoint(options)`.
## Implementation Notes
The cache uses a simple timestamp check against `clock()`. Cache invalidation on key rotation must be explicit (`clearCache()`). The `handleRequest` `_request` parameter is intentionally ignored — the endpoint serves the same JWKS for all requests. In production, mount the endpoint behind a CDN with short TTLs for automatic key rotation propagation.

View File

@@ -0,0 +1,164 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { generateKeyPair } from "../../keys/key-generator/key-generator.js";
import type { JWK, TenantKeyStore } from "../../types/jwt-tenant.types.js";
import { InMemoryKeyStore } from "../in-memory-key-store/in-memory-key-store.js";
import {
createJWKSEndpoint,
formatJWKSResponse,
JWKSEndpoint,
} from "./jwks-endpoint.js";
describe("formatJWKSResponse", () => {
it("wraps keys in JWKS envelope", { timeout: 5000 }, () => {
const keys: JWK[] = [{ kty: "OKP", kid: "k1" }];
const jwks = formatJWKSResponse(keys);
expect(jwks.keys).toHaveLength(1);
expect(jwks.keys[0]!.kty).toBe("OKP");
});
it("returns empty keys array when input is empty", {
timeout: 5000,
}, () => {
const jwks = formatJWKSResponse([]);
expect(jwks.keys).toHaveLength(0);
});
});
describe("JWKSEndpoint", () => {
let store: InMemoryKeyStore;
let endpoint: JWKSEndpoint;
let time: number;
let clock: () => number;
beforeEach(() => {
time = 1000;
clock = () => time;
store = new InMemoryKeyStore(clock);
endpoint = new JWKSEndpoint({
keyStore: store,
cacheMaxAge: 5000,
clock,
});
});
it("returns empty JWKS with no keys", { timeout: 30000 }, async () => {
const jwks = await endpoint.getJWKS();
expect(jwks.keys).toHaveLength(0);
});
it("returns JWKS with active keys", { timeout: 30000 }, async () => {
const kp = await generateKeyPair("EdDSA", { keyId: "k1" });
await store.addKey(kp);
const jwks = await endpoint.getJWKS();
expect(jwks.keys).toHaveLength(1);
expect(jwks.keys[0]!["kid"]).toBe("k1");
});
it("caches JWKS within TTL", { timeout: 30000 }, async () => {
const kp = await generateKeyPair("EdDSA", { keyId: "k1" });
await store.addKey(kp);
const jwks1 = await endpoint.getJWKS();
// Add another key — cache should still return original
const kp2 = await generateKeyPair("EdDSA", { keyId: "k2" });
await store.addKey(kp2);
time = 1100; // within 5000ms TTL
const jwks2 = await endpoint.getJWKS();
expect(jwks2.keys).toHaveLength(1); // cached
expect(jwks1).toBe(jwks2); // same reference
});
it("refreshes cache after TTL expires", { timeout: 30000 }, async () => {
const kp = await generateKeyPair("EdDSA", { keyId: "k1" });
await store.addKey(kp);
await endpoint.getJWKS();
// Add key then advance past TTL
const kp2 = await generateKeyPair("EdDSA", { keyId: "k2" });
await store.addKey(kp2);
time = 7000; // past 5000ms TTL
const jwks = await endpoint.getJWKS();
expect(jwks.keys).toHaveLength(2);
});
it("clearCache forces refresh on next call", {
timeout: 30000,
}, async () => {
const kp = await generateKeyPair("EdDSA", { keyId: "k1" });
await store.addKey(kp);
await endpoint.getJWKS();
const kp2 = await generateKeyPair("EdDSA", { keyId: "k2" });
await store.addKey(kp2);
endpoint.clearCache();
const jwks = await endpoint.getJWKS();
expect(jwks.keys).toHaveLength(2);
});
it("includeExpired bypasses cache", { timeout: 30000 }, async () => {
const kp = await generateKeyPair("EdDSA", { keyId: "k1" });
await store.addKey(kp);
await endpoint.getJWKS(); // prime cache
const kp2 = await generateKeyPair("EdDSA", { keyId: "k2" });
await store.addKey(kp2);
time = 1100; // within TTL
// includeExpired bypasses cache — fetches fresh from store
const jwks = await endpoint.getJWKS({ includeExpired: true });
expect(jwks.keys).toHaveLength(2);
});
it("handleRequest returns status 200 JSON response", {
timeout: 30000,
}, async () => {
const kp = await generateKeyPair("EdDSA", { keyId: "k1" });
await store.addKey(kp);
const response = await endpoint.handleRequest(null);
expect(response.status).toBe(200);
expect(response.headers["Content-Type"]).toBe("application/json");
const parsed = JSON.parse(response.body) as { keys: unknown[] };
expect(parsed.keys).toHaveLength(1);
});
it("handleRequest includes Cache-Control header", {
timeout: 30000,
}, async () => {
const response = await endpoint.handleRequest({});
expect(response.headers["Cache-Control"]).toContain("max-age=");
});
it("createJWKSEndpoint factory creates instance", {
timeout: 5000,
}, () => {
const ep = createJWKSEndpoint({ keyStore: store });
expect(ep).toBeInstanceOf(JWKSEndpoint);
});
it("uses default cacheMaxAge when not specified", {
timeout: 30000,
}, async () => {
const ep = new JWKSEndpoint({ keyStore: store });
const jwks = await ep.getJWKS();
expect(jwks.keys).toHaveLength(0);
});
it("handles store with no active keys after expiry", {
timeout: 5000,
}, async () => {
const expiredStore: TenantKeyStore = {
async getActiveKeys() {
return [];
},
async getKeyById() {
return null;
},
async addKey() {},
async removeKey() {},
};
const ep = new JWKSEndpoint({ keyStore: expiredStore });
const jwks = await ep.getJWKS();
expect(jwks.keys).toHaveLength(0);
});
});

View File

@@ -0,0 +1,98 @@
import type { Clock } from "@suaveplan/types";
import { DEFAULT_JWKS_CACHE_MAX_AGE } from "../../constants/index.js";
import { keyPairToJWK } from "../../keys/key-generator/key-generator.js";
import type {
JWK,
JWKS,
JWKSEndpointConfig,
JWKSEndpointOptions,
JWKSResponse,
TenantKeyStore,
} from "../../types/jwt-tenant.types.js";
const defaultClock: Clock = () => Date.now();
/** Format a set of JWKs into a JWKS response body. */
export function formatJWKSResponse(keys: JWK[]): JWKS {
return { keys };
}
/**
* Serves a JWKS endpoint backed by a `TenantKeyStore`.
*
* Provides a simple cache to avoid re-fetching all public keys on every
* request. The `handleRequest` method returns a framework-agnostic HTTP
* response object that can be forwarded to any HTTP server.
*/
export class JWKSEndpoint {
protected readonly keyStore: TenantKeyStore;
protected readonly cacheMaxAge: number;
protected readonly clock: Clock;
protected cachedJWKS: JWKS | null = null;
protected cacheExpiresAt = 0;
constructor(options: JWKSEndpointOptions) {
this.keyStore = options.keyStore;
this.cacheMaxAge = options.cacheMaxAge ?? DEFAULT_JWKS_CACHE_MAX_AGE;
this.clock = options.clock ?? defaultClock;
}
/**
* Build and (optionally) cache a JWKS from the key store.
* `includeExpired` defaults to `false`.
*/
async getJWKS(config: JWKSEndpointConfig = {}): Promise<JWKS> {
const includeExpired = config.includeExpired ?? false;
const now = this.clock();
if (
!includeExpired &&
this.cachedJWKS !== null &&
now < this.cacheExpiresAt
) {
return this.cachedJWKS;
}
const keys = await this.keyStore.getActiveKeys();
const jwks: JWK[] = await Promise.all(
keys.map((kp) => keyPairToJWK(kp))
);
const result = formatJWKSResponse(jwks);
if (!includeExpired) {
this.cachedJWKS = result;
this.cacheExpiresAt = now + this.cacheMaxAge;
}
return result;
}
/**
* Handle an HTTP request and return a framework-agnostic response.
* The `request` parameter is accepted but ignored (all requests return JWKS).
*/
async handleRequest(_request: unknown): Promise<JWKSResponse> {
const jwks = await this.getJWKS();
const body = JSON.stringify(jwks);
return {
status: 200,
body,
headers: {
"Content-Type": "application/json",
"Cache-Control": `public, max-age=${Math.floor(this.cacheMaxAge / 1000)}`,
},
};
}
/** Invalidate the internal JWKS cache. */
clearCache(): void {
this.cachedJWKS = null;
this.cacheExpiresAt = 0;
}
}
/** Factory function for `JWKSEndpoint`. */
export function createJWKSEndpoint(options: JWKSEndpointOptions): JWKSEndpoint {
return new JWKSEndpoint(options);
}

View File

@@ -0,0 +1,10 @@
export {
calculateKeyExpiration,
deserializeKeyPair,
generateKeyPair,
getAlgorithmFamily,
importPublicKeyFromJWK,
isKeyPairExpired,
keyPairToJWK,
serializeKeyPair,
} from "./key-generator.js";

View File

@@ -0,0 +1,110 @@
# key-generator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides cryptographic key pair generation and serialization utilities for `@suaveplan/jwt-tenant`. Key generation delegates to the `jose` library for actual key material, while serialization converts between opaque `KeyPair` objects (with CryptoKey references) and JSON-safe `SerializedKeyPair` objects suitable for database storage or transmission. An injectable `Clock` makes timestamp generation deterministic in tests.
## Features
- `generateKeyPair(algorithm, options)` — generates ES256/ES384/ES512/EdDSA/RS256/RS384/RS512 key pairs
- `serializeKeyPair(keyPair)` — exports keys to JWK format with metadata headers
- `deserializeKeyPair(serialized)` — imports JWKs back to opaque key references
- `keyPairToJWK(keyPair)` — extracts the public JWK for JWKS endpoint serving
- `importPublicKeyFromJWK(jwk)` — imports a raw JWK for use in verification
- `calculateKeyExpiration(createdAt, ttlMs)` — computes expiry Date from creation time and TTL
- `isKeyPairExpired(keyPair, clock?)` — checks whether a key pair has passed its `expiresAt`
- `getAlgorithmFamily(algorithm)` — returns `"EC" | "OKP" | "RSA"`
- Injectable `Clock` for deterministic `createdAt`/`expiresAt` in tests
## Usage: Basic Example
```ts
import { generateKeyPair, keyPairToJWK } from "@suaveplan/jwt-tenant/keys/key-generator";
const keyPair = await generateKeyPair("EdDSA");
console.log(keyPair.keyId); // e.g. "550e8400-e29b-41d4-a716-446655440000"
const jwk = await keyPairToJWK(keyPair);
console.log(jwk.kty); // "OKP"
```
## Usage: Advanced Example
```ts
import {
generateKeyPair,
serializeKeyPair,
deserializeKeyPair,
isKeyPairExpired,
calculateKeyExpiration,
} from "@suaveplan/jwt-tenant/keys/key-generator";
// Generate with 30-day TTL using a fixed clock for tests
const clock = () => new Date("2025-01-01").getTime();
const keyPair = await generateKeyPair("ES256", {
keyId: "my-key-v1",
ttlMs: 30 * 24 * 60 * 60 * 1000,
clock,
});
// Persist to storage
const serialized = await serializeKeyPair(keyPair);
localStorage.setItem("keyPair", JSON.stringify(serialized));
// Restore from storage
const restored = await deserializeKeyPair(JSON.parse(localStorage.getItem("keyPair")!));
console.log(restored.keyId === keyPair.keyId); // true
// Check expiry
const expired = isKeyPairExpired(restored, () => Date.now());
```
## API Reference
### `generateKeyPair(algorithm?, options?): Promise<KeyPair>`
Generates a key pair for the given algorithm. Default algorithm is `"EdDSA"`.
- `options.keyId` — override the auto-generated UUID key ID
- `options.ttlMs` — milliseconds until expiry; sets `expiresAt`
- `options.clock` — injectable clock for `createdAt`/`expiresAt`
- Throws `JwtTenantError(JWT.ALGORITHM_UNSUPPORTED)` on unknown algorithms
### `serializeKeyPair(keyPair): Promise<SerializedKeyPair>`
Exports a `KeyPair` to a JSON-serializable form with JWK-encoded keys.
- Throws `JwtTenantError(JWT.SERIALIZATION_FAILED)` on export failure
### `deserializeKeyPair(serialized): Promise<KeyPair>`
Imports a `SerializedKeyPair` back to an in-memory `KeyPair` with opaque key references.
- Throws `JwtTenantError(JWT.SERIALIZATION_FAILED)` on import failure
### `keyPairToJWK(keyPair): Promise<JWK>`
Returns the public JWK with `kid`, `alg`, and `use: "sig"` embedded.
### `importPublicKeyFromJWK(jwk): Promise<unknown>`
Imports a raw JWK as an opaque key reference for token verification.
### `calculateKeyExpiration(createdAt, ttlMs): Date`
Pure function — returns `createdAt + ttlMs` as a `Date`.
### `isKeyPairExpired(keyPair, clock?): boolean`
Returns `true` when `clock() >= keyPair.expiresAt.getTime()`. Returns `false` when `expiresAt` is absent.
### `getAlgorithmFamily(algorithm): "EC" | "OKP" | "RSA"`
Maps algorithm names to their key family for key generation routing.
## Implementation Notes
All `privateKey`/`publicKey` fields in `KeyPair` are typed as `unknown` to avoid leaking `jose` internal types into the public API. The `as Parameters<typeof exportJWK>[0]` casts are intentional adapter boundaries — they are not part of the public API contract. RSA key generation uses 2048-bit moduli for RS256, 3072 for RS384, and 4096 for RS512 to meet NIST recommendations.

View File

@@ -0,0 +1,265 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { JwtTenantError } from "../../errors/jwt-tenant-error.js";
import type {
KeyAlgorithm,
SerializedKeyPair,
} from "../../types/jwt-tenant.types.js";
import {
calculateKeyExpiration,
deserializeKeyPair,
generateKeyPair,
getAlgorithmFamily,
importPublicKeyFromJWK,
isKeyPairExpired,
keyPairToJWK,
serializeKeyPair,
} from "./key-generator.js";
describe("generateKeyPair", () => {
it("generates EdDSA key pair by default", { timeout: 30000 }, async () => {
const kp = await generateKeyPair();
expect(kp.algorithm).toBe("EdDSA");
expect(kp.privateKey).toBeDefined();
expect(kp.publicKey).toBeDefined();
expect(typeof kp.keyId).toBe("string");
expect(kp.createdAt).toBeInstanceOf(Date);
});
it("uses provided keyId", { timeout: 30000 }, async () => {
const kp = await generateKeyPair("EdDSA", { keyId: "my-key" });
expect(kp.keyId).toBe("my-key");
});
it("generates ES256 key pair", { timeout: 30000 }, async () => {
const kp = await generateKeyPair("ES256");
expect(kp.algorithm).toBe("ES256");
});
it("generates ES384 key pair", { timeout: 30000 }, async () => {
const kp = await generateKeyPair("ES384");
expect(kp.algorithm).toBe("ES384");
});
it("generates ES512 key pair", { timeout: 30000 }, async () => {
const kp = await generateKeyPair("ES512");
expect(kp.algorithm).toBe("ES512");
});
it("sets expiresAt when ttlMs provided", { timeout: 30000 }, async () => {
const clock = () => 1_000_000;
const kp = await generateKeyPair("EdDSA", { ttlMs: 3600000, clock });
expect(kp.expiresAt).toBeInstanceOf(Date);
expect(kp.expiresAt!.getTime()).toBe(1_000_000 + 3600000);
});
it("uses injectable clock for createdAt", { timeout: 30000 }, async () => {
const clock = () => 9999999;
const kp = await generateKeyPair("EdDSA", { clock });
expect(kp.createdAt.getTime()).toBe(9999999);
});
it("throws JwtTenantError for invalid algorithm", {
timeout: 30000,
}, async () => {
await expect(
generateKeyPair("INVALID" as KeyAlgorithm)
).rejects.toBeInstanceOf(JwtTenantError);
});
});
describe("serializeKeyPair / deserializeKeyPair", () => {
it("round-trips EdDSA key pair", { timeout: 30000 }, async () => {
const kp = await generateKeyPair("EdDSA");
const serialized = await serializeKeyPair(kp);
expect(serialized.keyId).toBe(kp.keyId);
expect(serialized.algorithm).toBe("EdDSA");
expect(typeof serialized.createdAt).toBe("string");
expect(serialized.privateKeyJwk).toBeDefined();
expect(serialized.publicKeyJwk).toBeDefined();
const restored = await deserializeKeyPair(serialized);
expect(restored.keyId).toBe(kp.keyId);
expect(restored.algorithm).toBe("EdDSA");
});
it("round-trips ES256 key pair", { timeout: 30000 }, async () => {
const kp = await generateKeyPair("ES256");
const serialized = await serializeKeyPair(kp);
const restored = await deserializeKeyPair(serialized);
expect(restored.keyId).toBe(kp.keyId);
});
it("preserves expiresAt in serialization", { timeout: 30000 }, async () => {
const clock = () => 1_000_000;
const kp = await generateKeyPair("EdDSA", { ttlMs: 3600000, clock });
const serialized = await serializeKeyPair(kp);
expect(serialized.expiresAt).toBeDefined();
const restored = await deserializeKeyPair(serialized);
expect(restored.expiresAt).toBeInstanceOf(Date);
});
it("handles no expiresAt in serialization", {
timeout: 30000,
}, async () => {
const kp = await generateKeyPair("EdDSA");
const serialized = await serializeKeyPair(kp);
expect(serialized.expiresAt).toBeUndefined();
const restored = await deserializeKeyPair(serialized);
expect(restored.expiresAt).toBeUndefined();
});
it("throws on invalid serialized key pair deserialization", {
timeout: 30000,
}, async () => {
const bad: SerializedKeyPair = {
keyId: "x",
algorithm: "EdDSA",
privateKeyJwk: { kty: "invalid" },
publicKeyJwk: { kty: "invalid" },
createdAt: new Date().toISOString(),
};
await expect(deserializeKeyPair(bad)).rejects.toBeInstanceOf(
JwtTenantError
);
});
it("embedds kid and alg in serialized JWKs", {
timeout: 30000,
}, async () => {
const kp = await generateKeyPair("EdDSA", { keyId: "test-kid" });
const serialized = await serializeKeyPair(kp);
expect(serialized.publicKeyJwk["kid"]).toBe("test-kid");
expect(serialized.publicKeyJwk["alg"]).toBe("EdDSA");
expect(serialized.publicKeyJwk["use"]).toBe("sig");
});
});
describe("keyPairToJWK", () => {
it("exports public JWK with metadata", { timeout: 30000 }, async () => {
const kp = await generateKeyPair("EdDSA", { keyId: "key1" });
const jwk = await keyPairToJWK(kp);
expect(jwk["kid"]).toBe("key1");
expect(jwk["alg"]).toBe("EdDSA");
expect(jwk["use"]).toBe("sig");
expect(jwk.kty).toBe("OKP");
});
it("throws JwtTenantError when exportJWK fails", {
timeout: 5000,
}, async () => {
const bogus = {
keyId: "x",
algorithm: "EdDSA" as KeyAlgorithm,
privateKey: {} as unknown,
publicKey: {} as unknown,
createdAt: new Date(),
};
await expect(keyPairToJWK(bogus)).rejects.toBeInstanceOf(
JwtTenantError
);
});
});
describe("generateKeyPair RSA", () => {
it("generates RS256 RSA key pair", { timeout: 60000 }, async () => {
const kp = await generateKeyPair("RS256");
expect(kp.algorithm).toBe("RS256");
expect(kp.privateKey).toBeDefined();
expect(kp.publicKey).toBeDefined();
});
});
describe("serializeKeyPair error path", () => {
it("throws JwtTenantError when exportJWK fails", {
timeout: 5000,
}, async () => {
const bogus = {
keyId: "x",
algorithm: "EdDSA" as KeyAlgorithm,
privateKey: {} as unknown,
publicKey: {} as unknown,
createdAt: new Date(),
};
await expect(serializeKeyPair(bogus)).rejects.toBeInstanceOf(
JwtTenantError
);
});
});
describe("importPublicKeyFromJWK", () => {
it("imports a valid public JWK", { timeout: 30000 }, async () => {
const kp = await generateKeyPair("EdDSA");
const jwk = await keyPairToJWK(kp);
const key = await importPublicKeyFromJWK(jwk);
expect(key).toBeDefined();
});
it("throws JwtTenantError for invalid JWK", {
timeout: 30000,
}, async () => {
await expect(
importPublicKeyFromJWK({ kty: "invalid" })
).rejects.toBeInstanceOf(JwtTenantError);
});
it("uses alg from JWK when available", { timeout: 30000 }, async () => {
const kp = await generateKeyPair("ES256");
const jwk = await keyPairToJWK(kp);
const key = await importPublicKeyFromJWK(jwk);
expect(key).toBeDefined();
});
});
describe("calculateKeyExpiration", () => {
it("adds ttl to createdAt", { timeout: 5000 }, () => {
const createdAt = new Date(1_000_000);
const result = calculateKeyExpiration(createdAt, 3600000);
expect(result.getTime()).toBe(1_000_000 + 3600000);
});
});
describe("isKeyPairExpired", () => {
it("returns false when no expiresAt", { timeout: 5000 }, async () => {
const kp = await generateKeyPair("EdDSA");
expect(isKeyPairExpired(kp)).toBe(false);
});
it("returns false when not yet expired", { timeout: 30000 }, async () => {
const clock = () => 1000;
const kp = await generateKeyPair("EdDSA", { ttlMs: 10000, clock });
expect(isKeyPairExpired(kp, () => 5000)).toBe(false);
});
it("returns true when expired", { timeout: 30000 }, async () => {
const clock = () => 1000;
const kp = await generateKeyPair("EdDSA", { ttlMs: 10000, clock });
expect(isKeyPairExpired(kp, () => 20000)).toBe(true);
});
it("uses default clock when none provided", {
timeout: 30000,
}, async () => {
const clock = () => 1;
const kp = await generateKeyPair("EdDSA", { ttlMs: 1, clock });
// expiresAt is 2ms — with real clock now >> 2, should be expired
expect(isKeyPairExpired(kp)).toBe(true);
});
});
describe("getAlgorithmFamily", () => {
it("returns EC for ES256/ES384/ES512", { timeout: 5000 }, () => {
expect(getAlgorithmFamily("ES256")).toBe("EC");
expect(getAlgorithmFamily("ES384")).toBe("EC");
expect(getAlgorithmFamily("ES512")).toBe("EC");
});
it("returns OKP for EdDSA", { timeout: 5000 }, () => {
expect(getAlgorithmFamily("EdDSA")).toBe("OKP");
});
it("returns RSA for RS256/RS384/RS512", { timeout: 5000 }, () => {
expect(getAlgorithmFamily("RS256")).toBe("RSA");
expect(getAlgorithmFamily("RS384")).toBe("RSA");
expect(getAlgorithmFamily("RS512")).toBe("RSA");
});
});

View File

@@ -0,0 +1,213 @@
import type { Clock } from "@suaveplan/types";
import {
exportJWK,
importJWK,
generateKeyPair as joseGenerateKeyPair,
} from "jose";
import {
ALGORITHM_CURVES,
ALGORITHM_FAMILIES,
RSA_MODULUS_LENGTH,
} from "../../constants/index.js";
import {
JWT_TENANT_CODES,
JwtTenantError,
} from "../../errors/jwt-tenant-error.js";
import type {
JWK,
KeyAlgorithm,
KeyGenOptions,
KeyPair,
SerializedKeyPair,
} from "../../types/jwt-tenant.types.js";
const defaultClock: Clock = () => Date.now();
/**
* Generate a new cryptographic key pair for JWT signing.
*
* Delegates to the `jose` library for actual key material generation.
* The injectable `clock` controls the `createdAt` timestamp for
* deterministic testing.
*/
export async function generateKeyPair(
algorithm: KeyAlgorithm = "EdDSA",
options: KeyGenOptions = {}
): Promise<KeyPair> {
const clock = options.clock ?? defaultClock;
const keyId = options.keyId ?? crypto.randomUUID();
try {
const family = ALGORITHM_FAMILIES[algorithm];
const genOptions: Record<string, unknown> = { extractable: true };
if (family === "EC" || algorithm === "EdDSA") {
genOptions["crv"] =
ALGORITHM_CURVES[
algorithm as "ES256" | "ES384" | "ES512" | "EdDSA"
];
}
if (family === "RSA") {
genOptions["modulusLength"] =
RSA_MODULUS_LENGTH[algorithm as "RS256" | "RS384" | "RS512"];
}
const { privateKey, publicKey } = await joseGenerateKeyPair(
algorithm,
genOptions
);
const createdAt = new Date(clock());
const keyPair: KeyPair = {
keyId,
algorithm,
privateKey,
publicKey,
createdAt,
};
if (options.ttlMs !== undefined) {
keyPair.expiresAt = new Date(clock() + options.ttlMs);
}
return keyPair;
} catch (err) {
throw new JwtTenantError(
JWT_TENANT_CODES.ALGORITHM_UNSUPPORTED,
`Failed to generate key pair for algorithm ${algorithm}`,
{ cause: err }
);
}
}
/** Serialize a `KeyPair` to a JSON-safe `SerializedKeyPair`. */
export async function serializeKeyPair(
keyPair: KeyPair
): Promise<SerializedKeyPair> {
try {
const privateKeyJwk = (await exportJWK(
keyPair.privateKey as Parameters<typeof exportJWK>[0]
)) as JWK;
const publicKeyJwk = (await exportJWK(
keyPair.publicKey as Parameters<typeof exportJWK>[0]
)) as JWK;
// Embed key metadata
privateKeyJwk["kid"] = keyPair.keyId;
privateKeyJwk["alg"] = keyPair.algorithm;
publicKeyJwk["kid"] = keyPair.keyId;
publicKeyJwk["alg"] = keyPair.algorithm;
publicKeyJwk["use"] = "sig";
const serialized: SerializedKeyPair = {
keyId: keyPair.keyId,
algorithm: keyPair.algorithm,
privateKeyJwk,
publicKeyJwk,
createdAt: keyPair.createdAt.toISOString(),
};
if (keyPair.expiresAt !== undefined) {
serialized.expiresAt = keyPair.expiresAt.toISOString();
}
return serialized;
} catch (err) {
throw new JwtTenantError(
JWT_TENANT_CODES.SERIALIZATION_FAILED,
"Failed to serialize key pair",
{ cause: err }
);
}
}
/** Deserialize a `SerializedKeyPair` back to a `KeyPair`. */
export async function deserializeKeyPair(
serialized: SerializedKeyPair
): Promise<KeyPair> {
try {
const privateKey = await importJWK(
serialized.privateKeyJwk as Parameters<typeof importJWK>[0],
serialized.algorithm
);
const publicKey = await importJWK(
serialized.publicKeyJwk as Parameters<typeof importJWK>[0],
serialized.algorithm
);
const keyPair: KeyPair = {
keyId: serialized.keyId,
algorithm: serialized.algorithm,
privateKey,
publicKey,
createdAt: new Date(serialized.createdAt),
};
if (serialized.expiresAt !== undefined) {
keyPair.expiresAt = new Date(serialized.expiresAt);
}
return keyPair;
} catch (err) {
throw new JwtTenantError(
JWT_TENANT_CODES.SERIALIZATION_FAILED,
"Failed to deserialize key pair",
{ cause: err }
);
}
}
/** Export the public key of a `KeyPair` as a JWK. */
export async function keyPairToJWK(keyPair: KeyPair): Promise<JWK> {
try {
const jwk = (await exportJWK(
keyPair.publicKey as Parameters<typeof exportJWK>[0]
)) as JWK;
jwk["kid"] = keyPair.keyId;
jwk["alg"] = keyPair.algorithm;
jwk["use"] = "sig";
return jwk;
} catch (err) {
throw new JwtTenantError(
JWT_TENANT_CODES.SERIALIZATION_FAILED,
"Failed to export public key as JWK",
{ cause: err }
);
}
}
/** Import a public key from a JWK for verification use. */
export async function importPublicKeyFromJWK(jwk: JWK): Promise<unknown> {
try {
const alg = (jwk["alg"] as string | undefined) ?? "EdDSA";
return await importJWK(jwk as Parameters<typeof importJWK>[0], alg);
} catch (err) {
throw new JwtTenantError(
JWT_TENANT_CODES.SERIALIZATION_FAILED,
"Failed to import public key from JWK",
{ cause: err }
);
}
}
/** Calculate the expiry date for a key given a TTL. */
export function calculateKeyExpiration(createdAt: Date, ttlMs: number): Date {
return new Date(createdAt.getTime() + ttlMs);
}
/** Check whether a key pair has expired relative to the given clock. */
export function isKeyPairExpired(
keyPair: KeyPair,
clock: Clock = defaultClock
): boolean {
if (keyPair.expiresAt === undefined) return false;
return clock() >= keyPair.expiresAt.getTime();
}
/** Return the algorithm family for a given algorithm. */
export function getAlgorithmFamily(
algorithm: KeyAlgorithm
): "EC" | "OKP" | "RSA" {
return ALGORITHM_FAMILIES[algorithm];
}

View File

@@ -0,0 +1,77 @@
# jwt-tenant.schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides Zod schemas for all public configuration and option types in `@suaveplan/jwt-tenant`. These schemas enable runtime validation of user-supplied configuration, making it possible to surface configuration errors at construction time rather than at operation time. They are also useful for serializing and deserializing key pairs in storage-layer integrations.
## Features
- `KeyGenOptionsSchema` — validates key generation options
- `TokenIssuerConfigSchema` — validates issuer configuration
- `CreateTokenOptionsSchema` — validates per-token creation options (required `subject` and `tenantId`)
- `VerifyTokenOptionsSchema` — validates per-verification constraint options
- `TokenVerifierConfigSchema` — validates verifier configuration
- `JWKSEndpointOptionsSchema` — validates JWKS endpoint options
- `SerializedKeyPairSchema` — validates serialized key pair JSON format
## Usage: Basic Example
```ts
import { CreateTokenOptionsSchema } from "@suaveplan/jwt-tenant/schemas";
const result = CreateTokenOptionsSchema.safeParse({
subject: "user-123",
tenantId: "acme",
role: "admin",
});
if (!result.success) {
console.error(result.error.format());
}
```
## Usage: Advanced Example
```ts
import { SerializedKeyPairSchema } from "@suaveplan/jwt-tenant/schemas";
const rawJson = JSON.parse(fs.readFileSync("keypair.json", "utf8"));
const parsed = SerializedKeyPairSchema.parse(rawJson);
// parsed is type-narrowed as SerializedKeyPair
```
## API Reference
### `KeyGenOptionsSchema`
Strict schema: `keyId?` (string), `ttlMs?` (positive number).
### `TokenIssuerConfigSchema`
Strict schema: `issuer?` (URL string), `defaultExpiresIn?` (string).
### `CreateTokenOptionsSchema`
Strict schema with required `subject` (min 1) and `tenantId` (min 1). Optional: `expiresIn`, `domains`, `role`, `permissions`, `sessionId`, `issuer`, `additionalClaims`.
### `VerifyTokenOptionsSchema`
Strict schema: all fields optional — `requiredClaims`, `requiredRole`, `requiredPermissions`, `requiredTenantId`, `requiredDomains`.
### `TokenVerifierConfigSchema`
Strict schema: `issuer?` (URL), `audience?` (string[]), `clockTolerance?` (nonnegative), `jwksUri?` (URL), `cacheMaxAge?` (positive), `maxRetries?` (positive int).
### `JWKSEndpointOptionsSchema`
Strict schema: `cacheMaxAge?` (positive). The `keyStore` and `clock` fields are runtime objects and are excluded.
### `SerializedKeyPairSchema`
Validates `keyId`, `algorithm` (enum), `privateKeyJwk`/`publicKeyJwk` (records), `createdAt`/`expiresAt?` (ISO-8601 datetime strings).
## Implementation Notes
All schemas use `.strict()` to reject unknown keys, making configuration typos immediately surfaced. The `clock` and `signal` fields in config interfaces are runtime objects that cannot be validated by Zod and are intentionally excluded from the schemas.

View File

@@ -0,0 +1,194 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CreateTokenOptionsSchema,
JWKSEndpointOptionsSchema,
KeyGenOptionsSchema,
SerializedKeyPairSchema,
TokenIssuerConfigSchema,
TokenVerifierConfigSchema,
VerifyTokenOptionsSchema,
} from "./jwt-tenant.schemas.js";
describe("KeyGenOptionsSchema", () => {
it("accepts empty object", { timeout: 5000 }, () => {
expect(KeyGenOptionsSchema.safeParse({}).success).toBe(true);
});
it("accepts keyId and ttlMs", { timeout: 5000 }, () => {
expect(
KeyGenOptionsSchema.safeParse({ keyId: "k1", ttlMs: 3600000 })
.success
).toBe(true);
});
it("rejects unknown keys", { timeout: 5000 }, () => {
expect(
KeyGenOptionsSchema.safeParse({ unknown: "value" }).success
).toBe(false);
});
it("rejects non-positive ttlMs", { timeout: 5000 }, () => {
expect(KeyGenOptionsSchema.safeParse({ ttlMs: 0 }).success).toBe(false);
});
});
describe("TokenIssuerConfigSchema", () => {
it("accepts empty object", { timeout: 5000 }, () => {
expect(TokenIssuerConfigSchema.safeParse({}).success).toBe(true);
});
it("accepts valid issuer URL", { timeout: 5000 }, () => {
expect(
TokenIssuerConfigSchema.safeParse({
issuer: "https://auth.example.com",
}).success
).toBe(true);
});
it("rejects non-URL issuer", { timeout: 5000 }, () => {
expect(
TokenIssuerConfigSchema.safeParse({ issuer: "not-a-url" }).success
).toBe(false);
});
});
describe("CreateTokenOptionsSchema", () => {
it("requires subject and tenantId", { timeout: 5000 }, () => {
expect(CreateTokenOptionsSchema.safeParse({}).success).toBe(false);
expect(
CreateTokenOptionsSchema.safeParse({
subject: "u",
tenantId: "t",
}).success
).toBe(true);
});
it("rejects empty subject", { timeout: 5000 }, () => {
expect(
CreateTokenOptionsSchema.safeParse({ subject: "", tenantId: "t" })
.success
).toBe(false);
});
it("accepts all optional fields", { timeout: 5000 }, () => {
expect(
CreateTokenOptionsSchema.safeParse({
subject: "u",
tenantId: "t",
expiresIn: "1h",
domains: ["example.com"],
role: "admin",
permissions: ["read"],
sessionId: "sess-1",
issuer: "https://auth.example.com",
additionalClaims: { custom: 42 },
}).success
).toBe(true);
});
});
describe("VerifyTokenOptionsSchema", () => {
it("accepts empty object", { timeout: 5000 }, () => {
expect(VerifyTokenOptionsSchema.safeParse({}).success).toBe(true);
});
it("accepts all constraint fields", { timeout: 5000 }, () => {
expect(
VerifyTokenOptionsSchema.safeParse({
requiredClaims: ["sub"],
requiredRole: "admin",
requiredPermissions: ["read"],
requiredTenantId: "acme",
requiredDomains: ["example.com"],
}).success
).toBe(true);
});
});
describe("TokenVerifierConfigSchema", () => {
it("accepts empty object", { timeout: 5000 }, () => {
expect(TokenVerifierConfigSchema.safeParse({}).success).toBe(true);
});
it("accepts valid config", { timeout: 5000 }, () => {
expect(
TokenVerifierConfigSchema.safeParse({
issuer: "https://auth.example.com",
audience: ["aud1"],
clockTolerance: 30,
jwksUri: "https://auth.example.com/.well-known/jwks.json",
cacheMaxAge: 300000,
maxRetries: 3,
}).success
).toBe(true);
});
it("rejects negative clockTolerance", { timeout: 5000 }, () => {
expect(
TokenVerifierConfigSchema.safeParse({ clockTolerance: -1 }).success
).toBe(false);
});
});
describe("JWKSEndpointOptionsSchema", () => {
it("accepts empty object", { timeout: 5000 }, () => {
expect(JWKSEndpointOptionsSchema.safeParse({}).success).toBe(true);
});
it("accepts cacheMaxAge", { timeout: 5000 }, () => {
expect(
JWKSEndpointOptionsSchema.safeParse({ cacheMaxAge: 60000 }).success
).toBe(true);
});
});
describe("SerializedKeyPairSchema", () => {
it("accepts valid serialized key pair", { timeout: 5000 }, () => {
expect(
SerializedKeyPairSchema.safeParse({
keyId: "k1",
algorithm: "EdDSA",
privateKeyJwk: { kty: "OKP" },
publicKeyJwk: { kty: "OKP" },
createdAt: new Date().toISOString(),
}).success
).toBe(true);
});
it("accepts optional expiresAt", { timeout: 5000 }, () => {
expect(
SerializedKeyPairSchema.safeParse({
keyId: "k1",
algorithm: "EdDSA",
privateKeyJwk: { kty: "OKP" },
publicKeyJwk: { kty: "OKP" },
createdAt: new Date().toISOString(),
expiresAt: new Date().toISOString(),
}).success
).toBe(true);
});
it("rejects invalid algorithm", { timeout: 5000 }, () => {
expect(
SerializedKeyPairSchema.safeParse({
keyId: "k1",
algorithm: "INVALID",
privateKeyJwk: { kty: "OKP" },
publicKeyJwk: { kty: "OKP" },
createdAt: new Date().toISOString(),
}).success
).toBe(false);
});
it("rejects invalid createdAt", { timeout: 5000 }, () => {
expect(
SerializedKeyPairSchema.safeParse({
keyId: "k1",
algorithm: "EdDSA",
privateKeyJwk: { kty: "OKP" },
publicKeyJwk: { kty: "OKP" },
createdAt: "not-a-date",
}).success
).toBe(false);
});
});

View File

@@ -0,0 +1,80 @@
import z from "zod";
/** Schema for KeyGenOptions. */
export const KeyGenOptionsSchema = z
.object({
keyId: z.string().optional(),
ttlMs: z.number().positive().optional(),
})
.strict();
/** Schema for TokenIssuerConfig. */
export const TokenIssuerConfigSchema = z
.object({
issuer: z.string().url().optional(),
defaultExpiresIn: z.string().optional(),
})
.strict();
/** Schema for CreateTokenOptions. */
export const CreateTokenOptionsSchema = z
.object({
subject: z.string().min(1),
tenantId: z.string().min(1),
expiresIn: z.string().optional(),
domains: z.array(z.string()).optional(),
role: z.string().optional(),
permissions: z.array(z.string()).optional(),
sessionId: z.string().optional(),
issuer: z.string().url().optional(),
additionalClaims: z.record(z.string(), z.unknown()).optional(),
})
.strict();
/** Schema for VerifyTokenOptions. */
export const VerifyTokenOptionsSchema = z
.object({
requiredClaims: z.array(z.string()).optional(),
requiredRole: z.string().optional(),
requiredPermissions: z.array(z.string()).optional(),
requiredTenantId: z.string().optional(),
requiredDomains: z.array(z.string()).optional(),
})
.strict();
/** Schema for TokenVerifierConfig. */
export const TokenVerifierConfigSchema = z
.object({
issuer: z.string().url().optional(),
audience: z.array(z.string()).optional(),
clockTolerance: z.number().nonnegative().optional(),
jwksUri: z.string().url().optional(),
cacheMaxAge: z.number().positive().optional(),
maxRetries: z.number().int().positive().optional(),
})
.strict();
/** Schema for JWKSEndpointOptions (without keyStore, which is a runtime object). */
export const JWKSEndpointOptionsSchema = z
.object({
cacheMaxAge: z.number().positive().optional(),
})
.strict();
/** Schema for a SerializedKeyPair. */
export const SerializedKeyPairSchema = z.object({
keyId: z.string().min(1),
algorithm: z.enum([
"ES256",
"ES384",
"ES512",
"EdDSA",
"RS256",
"RS384",
"RS512",
]),
privateKeyJwk: z.record(z.string(), z.unknown()),
publicKeyJwk: z.record(z.string(), z.unknown()),
createdAt: z.string().datetime(),
expiresAt: z.string().datetime().optional(),
});

View File

@@ -0,0 +1,5 @@
export {
createTokenIssuer,
signToken,
TokenIssuer,
} from "./token-issuer.js";

View File

@@ -0,0 +1,75 @@
# token-issuer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Issues signed JWTs with per-tenant claims using the `jose` library. The `TokenIssuer` class wraps `SignJWT` with tenant-aware defaults: it injects `tenant_id`, `session_id`, `domains`, `role`, and `permissions` claims alongside standard `sub`, `iat`, `exp`, and `jti`. An injectable `Clock` makes token timestamps deterministic for unit tests. Duration strings like `"1h"` are parsed to seconds for `exp` computation.
## Features
- `TokenIssuer` class with configurable issuer, default expiry, and injectable clock
- `createToken(keyPair, options)` — signs a JWT with tenant claims
- `createTokenIssuer(config)` — factory function
- `signToken(keyPair, options, config?)` — standalone convenience function
- Duration string parsing: `"1h"`, `"15m"`, `"30s"`, `"2d"`, `"500ms"`
- Standard `jti` (UUID), `iat`, `exp`, `sub`, `iss` claim population
- Optional `additionalClaims` spread for domain-specific payloads
## Usage: Basic Example
```ts
import { generateKeyPair } from "@suaveplan/jwt-tenant/keys/key-generator";
import { TokenIssuer } from "@suaveplan/jwt-tenant/token/token-issuer";
const keyPair = await generateKeyPair("EdDSA");
const issuer = new TokenIssuer({ issuer: "https://auth.example.com" });
const token = await issuer.createToken(keyPair, {
subject: "user-123",
tenantId: "acme",
});
```
## Usage: Advanced Example
```ts
import { signToken } from "@suaveplan/jwt-tenant/token/token-issuer";
const clock = () => new Date("2025-06-01T00:00:00Z").getTime();
const token = await signToken(keyPair, {
subject: "svc-account",
tenantId: "internal",
expiresIn: "15m",
role: "service",
permissions: ["read:metrics", "write:events"],
sessionId: "sess-abc123",
additionalClaims: { env: "production" },
}, { clock });
```
## API Reference
### `TokenIssuer`
Constructor: `new TokenIssuer(config?: TokenIssuerConfig)`.
- `config.issuer` — `iss` claim (optional)
- `config.defaultExpiresIn` — default duration string (default `"1h"`)
- `config.clock` — injectable clock for `iat`/`exp`
#### Methods
- **`createToken(keyPair, options): Promise<string>`** — Signs and returns a compact JWT.
### `createTokenIssuer(config?): TokenIssuer`
Factory function.
### `signToken(keyPair, options, config?): Promise<string>`
Standalone convenience that creates a `TokenIssuer` internally and issues one token.
## Implementation Notes
Duration strings are parsed with a simple regex supporting `ms`, `s`, `m`, `h`, `d` units. Fractional values are not supported. The `exp` claim is set to `floor(now/1000) + duration_in_seconds`, matching the JWT spec's integer seconds requirement. The `jti` is a `crypto.randomUUID()` call for uniqueness.

View File

@@ -0,0 +1,245 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { JwtTenantError } from "../../errors/jwt-tenant-error.js";
import { generateKeyPair } from "../../keys/key-generator/key-generator.js";
import type { KeyPair } from "../../types/jwt-tenant.types.js";
import { createTokenIssuer, signToken, TokenIssuer } from "./token-issuer.js";
// Shared key for most tests — generated once
let sharedKey: KeyPair;
async function getKey(): Promise<KeyPair> {
if (!sharedKey) {
sharedKey = await generateKeyPair("EdDSA");
}
return sharedKey;
}
describe("TokenIssuer", () => {
it("creates a JWT string", { timeout: 30000 }, async () => {
const kp = await getKey();
const issuer = new TokenIssuer();
const token = await issuer.createToken(kp, {
subject: "user-1",
tenantId: "acme",
});
expect(typeof token).toBe("string");
expect(token.split(".")).toHaveLength(3);
});
it("embeds tenant_id claim", { timeout: 30000 }, async () => {
const kp = await getKey();
const issuer = new TokenIssuer();
const token = await issuer.createToken(kp, {
subject: "user-1",
tenantId: "my-tenant",
});
const parts = token.split(".");
const payload = JSON.parse(
Buffer.from(parts[1]!, "base64url").toString()
) as Record<string, unknown>;
expect(payload["tenant_id"]).toBe("my-tenant");
});
it("sets sub, iat, exp, jti", { timeout: 30000 }, async () => {
const kp = await getKey();
const clock = () => 2_000_000;
const issuer = new TokenIssuer({ clock });
const token = await issuer.createToken(kp, {
subject: "sub-1",
tenantId: "t",
});
const parts = token.split(".");
const payload = JSON.parse(
Buffer.from(parts[1]!, "base64url").toString()
) as Record<string, unknown>;
expect(payload["sub"]).toBe("sub-1");
expect(payload["iat"]).toBe(2000);
expect(typeof payload["jti"]).toBe("string");
expect(typeof payload["exp"]).toBe("number");
});
it("uses defaultExpiresIn from config", { timeout: 30000 }, async () => {
const kp = await getKey();
const clock = () => 0;
const issuer = new TokenIssuer({ defaultExpiresIn: "30m", clock });
const token = await issuer.createToken(kp, {
subject: "u",
tenantId: "t",
});
const payload = JSON.parse(
Buffer.from(token.split(".")[1]!, "base64url").toString()
) as Record<string, number>;
expect(payload["exp"]).toBe(1800); // 30 * 60
});
it("option expiresIn overrides config default", {
timeout: 30000,
}, async () => {
const kp = await getKey();
const clock = () => 0;
const issuer = new TokenIssuer({ defaultExpiresIn: "1h", clock });
const token = await issuer.createToken(kp, {
subject: "u",
tenantId: "t",
expiresIn: "2h",
});
const payload = JSON.parse(
Buffer.from(token.split(".")[1]!, "base64url").toString()
) as Record<string, number>;
expect(payload["exp"]).toBe(7200); // 2 * 3600
});
it("sets issuer from options.issuer", { timeout: 30000 }, async () => {
const kp = await getKey();
const issuer = new TokenIssuer({ issuer: "https://default.iss" });
const token = await issuer.createToken(kp, {
subject: "u",
tenantId: "t",
issuer: "https://override.iss",
});
const payload = JSON.parse(
Buffer.from(token.split(".")[1]!, "base64url").toString()
) as Record<string, unknown>;
expect(payload["iss"]).toBe("https://override.iss");
});
it("sets issuer from config when not in options", {
timeout: 30000,
}, async () => {
const kp = await getKey();
const issuer = new TokenIssuer({ issuer: "https://config.iss" });
const token = await issuer.createToken(kp, {
subject: "u",
tenantId: "t",
});
const payload = JSON.parse(
Buffer.from(token.split(".")[1]!, "base64url").toString()
) as Record<string, unknown>;
expect(payload["iss"]).toBe("https://config.iss");
});
it("embeds role, permissions, domains, session_id", {
timeout: 30000,
}, async () => {
const kp = await getKey();
const issuer = new TokenIssuer();
const token = await issuer.createToken(kp, {
subject: "u",
tenantId: "t",
role: "admin",
permissions: ["read", "write"],
domains: ["example.com"],
sessionId: "sess-1",
});
const payload = JSON.parse(
Buffer.from(token.split(".")[1]!, "base64url").toString()
) as Record<string, unknown>;
expect(payload["role"]).toBe("admin");
expect(payload["permissions"]).toEqual(["read", "write"]);
expect(payload["domains"]).toEqual(["example.com"]);
expect(payload["session_id"]).toBe("sess-1");
});
it("spreads additionalClaims", { timeout: 30000 }, async () => {
const kp = await getKey();
const issuer = new TokenIssuer();
const token = await issuer.createToken(kp, {
subject: "u",
tenantId: "t",
additionalClaims: { custom: "value" },
});
const payload = JSON.parse(
Buffer.from(token.split(".")[1]!, "base64url").toString()
) as Record<string, unknown>;
expect(payload["custom"]).toBe("value");
});
it("throws JwtTenantError for invalid expiresIn", {
timeout: 30000,
}, async () => {
const kp = await getKey();
const issuer = new TokenIssuer();
await expect(
issuer.createToken(kp, {
subject: "u",
tenantId: "t",
expiresIn: "invalid",
})
).rejects.toBeInstanceOf(JwtTenantError);
});
it("wraps non-JwtTenantError signing failures", {
timeout: 30000,
}, async () => {
const bogusKp: KeyPair = {
keyId: "x",
algorithm: "EdDSA",
privateKey: {} as unknown,
publicKey: {} as unknown,
createdAt: new Date(),
};
const issuer = new TokenIssuer();
await expect(
issuer.createToken(bogusKp, { subject: "u", tenantId: "t" })
).rejects.toBeInstanceOf(JwtTenantError);
});
it("supports ms duration unit", { timeout: 30000 }, async () => {
const kp = await getKey();
const clock = () => 0;
const issuer = new TokenIssuer({ clock });
const token = await issuer.createToken(kp, {
subject: "u",
tenantId: "t",
expiresIn: "500ms",
});
const payload = JSON.parse(
Buffer.from(token.split(".")[1]!, "base64url").toString()
) as Record<string, number>;
// 500ms = 0.5s; exp is an RFC 7519 NumericDate, fractional seconds permitted
expect(payload["exp"]).toBe(0.5);
});
it("supports d duration unit", { timeout: 30000 }, async () => {
const kp = await getKey();
const clock = () => 0;
const issuer = new TokenIssuer({ clock });
const token = await issuer.createToken(kp, {
subject: "u",
tenantId: "t",
expiresIn: "1d",
});
const payload = JSON.parse(
Buffer.from(token.split(".")[1]!, "base64url").toString()
) as Record<string, number>;
expect(payload["exp"]).toBe(86400);
});
});
describe("createTokenIssuer", () => {
it("creates a TokenIssuer instance", { timeout: 5000 }, () => {
const issuer = createTokenIssuer();
expect(issuer).toBeInstanceOf(TokenIssuer);
});
});
describe("signToken", () => {
it("signs a token using standalone function", {
timeout: 30000,
}, async () => {
const kp = await getKey();
const token = await signToken(kp, { subject: "u", tenantId: "t" });
expect(token.split(".")).toHaveLength(3);
});
it("passes config to TokenIssuer", { timeout: 30000 }, async () => {
const kp = await getKey();
const clock = () => 0;
const token = await signToken(
kp,
{ subject: "u", tenantId: "t" },
{ clock }
);
expect(typeof token).toBe("string");
});
});

View File

@@ -0,0 +1,129 @@
import type { Clock } from "@suaveplan/types";
import { SignJWT } from "jose";
import { DEFAULT_EXPIRES_IN } from "../../constants/index.js";
import {
JWT_TENANT_CODES,
JwtTenantError,
} from "../../errors/jwt-tenant-error.js";
import type {
CreateTokenOptions,
KeyPair,
TokenIssuerConfig,
} from "../../types/jwt-tenant.types.js";
const defaultClock: Clock = () => Date.now();
/** Parse a duration string like "1h", "15m", "30s" into seconds. */
function parseDurationToSeconds(duration: string): number {
const match = duration.match(/^(\d+)(ms|s|m|h|d)$/);
if (!match) {
throw new JwtTenantError(
JWT_TENANT_CODES.INVALID_CLAIMS,
`Invalid duration format: "${duration}". Expected e.g. "1h", "15m", "30s".`
);
}
const value = Number.parseInt(match[1]!, 10);
const unit = match[2]! as "ms" | "s" | "m" | "h" | "d";
const multipliers: Record<"ms" | "s" | "m" | "h" | "d", number> = {
ms: 0.001,
s: 1,
m: 60,
h: 3600,
d: 86400,
};
return value * multipliers[unit];
}
/**
* Issues signed JWTs with per-tenant claims.
*
* The injectable `clock` controls `iat` and `exp` values for deterministic
* token generation in tests. All timestamps are in Unix seconds.
*/
export class TokenIssuer {
protected readonly config: TokenIssuerConfig;
protected readonly clock: Clock;
constructor(config: TokenIssuerConfig = {}) {
this.config = config;
this.clock = config.clock ?? defaultClock;
}
/**
* Sign a JWT with the provided key pair and options.
* Returns the compact JWT string.
*/
async createToken(
keyPair: KeyPair,
options: CreateTokenOptions
): Promise<string> {
try {
const nowMs = this.clock();
const nowSec = Math.floor(nowMs / 1000);
const expiresInStr =
options.expiresIn ??
this.config.defaultExpiresIn ??
DEFAULT_EXPIRES_IN;
const expiresInSec = parseDurationToSeconds(expiresInStr);
const expSec = nowSec + expiresInSec;
const jti = crypto.randomUUID();
const issuer = options.issuer ?? this.config.issuer;
const builder = new SignJWT({
tenant_id: options.tenantId,
...(options.domains !== undefined && {
domains: options.domains,
}),
...(options.role !== undefined && { role: options.role }),
...(options.permissions !== undefined && {
permissions: options.permissions,
}),
...(options.sessionId !== undefined && {
session_id: options.sessionId,
}),
...options.additionalClaims,
})
.setProtectedHeader({
alg: keyPair.algorithm,
kid: keyPair.keyId,
})
.setSubject(options.subject)
.setIssuedAt(nowSec)
.setExpirationTime(expSec)
.setJti(jti);
if (issuer !== undefined) {
builder.setIssuer(issuer);
}
return await builder.sign(
keyPair.privateKey as Parameters<typeof builder.sign>[0]
);
} catch (err) {
if (err instanceof JwtTenantError) throw err;
throw new JwtTenantError(
JWT_TENANT_CODES.INVALID_CLAIMS,
"Failed to create token",
{ cause: err }
);
}
}
}
/** Factory function for `TokenIssuer`. */
export function createTokenIssuer(config: TokenIssuerConfig = {}): TokenIssuer {
return new TokenIssuer(config);
}
/**
* Standalone function to sign a token with a given key pair.
* Equivalent to `createTokenIssuer(config).createToken(keyPair, options)`.
*/
export async function signToken(
keyPair: KeyPair,
options: CreateTokenOptions,
config: TokenIssuerConfig = {}
): Promise<string> {
return new TokenIssuer(config).createToken(keyPair, options);
}

View File

@@ -0,0 +1,5 @@
export {
createTokenVerifier,
TokenVerifier,
verifyTokenWithKey,
} from "./token-verifier.js";

View File

@@ -0,0 +1,83 @@
# token-verifier
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Verifies signed JWTs against local keys or remote JWKS endpoints. The `TokenVerifier` class resolves verification keys from either an in-memory key cache or a remote JWKS URI (with retry and AbortSignal support), then delegates cryptographic verification to `jose`. After successful signature verification, per-verification constraint checks enforce tenant isolation, role requirements, and permission scopes.
## Features
- `TokenVerifier` class with configurable issuer, audience, clock tolerance, and injectable clock
- Remote JWKS URI support with configurable TTL caching and retry (default 3 attempts)
- `AbortSignal` cancellation for JWKS fetch operations
- `addKey(jwk)` — register a local JWK for key-only verification (no remote fetch)
- `clearCache()` — invalidate the JWKS cache for immediate rotation
- `verifyToken(token, options?)` — returns `VerifyTokenResult` (never throws)
- Per-verification constraints: `requiredTenantId`, `requiredRole`, `requiredPermissions`, `requiredClaims`, `requiredDomains`
- `verifyTokenWithKey(token, key, options?)` — standalone function for pre-imported keys
- `createTokenVerifier(config?)` — factory function
## Usage: Basic Example
```ts
import { generateKeyPair, keyPairToJWK } from "@suaveplan/jwt-tenant/keys/key-generator";
import { TokenIssuer } from "@suaveplan/jwt-tenant/token/token-issuer";
import { TokenVerifier } from "@suaveplan/jwt-tenant/token/token-verifier";
const kp = await generateKeyPair("EdDSA");
const issuer = new TokenIssuer({ issuer: "https://auth.example.com" });
const token = await issuer.createToken(kp, { subject: "u1", tenantId: "acme" });
const verifier = new TokenVerifier({ issuer: "https://auth.example.com" });
const jwk = await keyPairToJWK(kp);
verifier.addKey(jwk);
const result = await verifier.verifyToken(token);
// result.valid === true
// result.payload.tenant_id === "acme"
```
## Usage: Advanced Example
```ts
const verifier = new TokenVerifier({
jwksUri: "https://auth.example.com/.well-known/jwks.json",
issuer: "https://auth.example.com",
cacheMaxAge: 5 * 60 * 1000,
clockTolerance: 30,
maxRetries: 3,
signal: AbortSignal.timeout(5000),
});
const result = await verifier.verifyToken(token, {
requiredTenantId: "acme",
requiredRole: "admin",
requiredPermissions: ["write:data"],
requiredDomains: ["acme.example.com"],
});
```
## API Reference
### `TokenVerifier`
Constructor: `new TokenVerifier(config?: TokenVerifierConfig)`.
#### Methods
- **`verifyToken(token, options?): Promise<VerifyTokenResult>`** — Verifies the token. Returns `{ valid: true, payload }` or `{ valid: false, error, errorCode }`. Never throws.
- **`addKey(jwk): void`** — Adds a local JWK to the key cache.
- **`clearCache(): void`** — Clears the key cache.
### `createTokenVerifier(config?): TokenVerifier`
Factory function.
### `verifyTokenWithKey(token, key, options?): Promise<VerifyTokenResult>`
Standalone verification with a pre-imported key reference.
## Implementation Notes
JWKS cache uses timestamp comparison (`clock() >= cacheExpiresAt`) for expiry. Multi-key verification tries each JWK in order and returns on the first success — the last error is preserved and wrapped if all keys fail. The `checkConstraints` method is `protected` to allow subclass extension without exposing it in the public API. All jose errors are classified by message substring matching into one of the JWT error codes.

View File

@@ -0,0 +1,583 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import { JWT_TENANT_CODES } from "../../errors/jwt-tenant-error.js";
import {
generateKeyPair,
keyPairToJWK,
} from "../../keys/key-generator/key-generator.js";
import type { KeyPair } from "../../types/jwt-tenant.types.js";
import { signToken } from "../token-issuer/token-issuer.js";
import {
createTokenVerifier,
TokenVerifier,
verifyTokenWithKey,
} from "./token-verifier.js";
let sharedKey: KeyPair;
async function getKey(): Promise<KeyPair> {
if (!sharedKey) {
sharedKey = await generateKeyPair("EdDSA");
}
return sharedKey;
}
describe("TokenVerifier - basic verification", () => {
it("verifies a valid token", { timeout: 30000 }, async () => {
const kp = await getKey();
const token = await signToken(kp, { subject: "u1", tenantId: "acme" });
const verifier = new TokenVerifier();
const jwk = await keyPairToJWK(kp);
verifier.addKey(jwk);
const result = await verifier.verifyToken(token);
expect(result.valid).toBe(true);
expect(result.payload?.tenant_id).toBe("acme");
expect(result.payload?.sub).toBe("u1");
});
it("returns invalid for tampered token", { timeout: 30000 }, async () => {
const kp = await getKey();
const token = await signToken(kp, { subject: "u1", tenantId: "acme" });
const parts = token.split(".");
const tampered = `${parts[0]!}.${parts[1]!}.invalidsig`;
const verifier = new TokenVerifier();
const jwk = await keyPairToJWK(kp);
verifier.addKey(jwk);
const result = await verifier.verifyToken(tampered);
expect(result.valid).toBe(false);
expect(result.error).toBeDefined();
});
it("returns invalid when no keys configured", {
timeout: 5000,
}, async () => {
const verifier = new TokenVerifier();
const result = await verifier.verifyToken("a.b.c");
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(JWT_TENANT_CODES.KEY_NOT_FOUND);
});
it("returns invalid for expired token", { timeout: 30000 }, async () => {
const kp = await getKey();
const clock = () => 0;
const token = await signToken(
kp,
{ subject: "u", tenantId: "t", expiresIn: "1s" },
{ clock }
);
const verifier = new TokenVerifier();
const jwk = await keyPairToJWK(kp);
verifier.addKey(jwk);
// Verify long after expiry
const result = await verifier.verifyToken(token);
expect(result.valid).toBe(false);
});
it("verifies with issuer check", { timeout: 30000 }, async () => {
const kp = await getKey();
const token = await signToken(
kp,
{ subject: "u", tenantId: "t" },
{ issuer: "https://auth.example.com" }
);
const verifier = new TokenVerifier({
issuer: "https://auth.example.com",
});
const jwk = await keyPairToJWK(kp);
verifier.addKey(jwk);
const result = await verifier.verifyToken(token);
expect(result.valid).toBe(true);
});
it("fails with wrong issuer", { timeout: 30000 }, async () => {
const kp = await getKey();
const token = await signToken(
kp,
{ subject: "u", tenantId: "t" },
{ issuer: "https://wrong.issuer.com" }
);
const verifier = new TokenVerifier({
issuer: "https://auth.example.com",
});
const jwk = await keyPairToJWK(kp);
verifier.addKey(jwk);
const result = await verifier.verifyToken(token);
expect(result.valid).toBe(false);
});
it("clearCache resets key cache", { timeout: 30000 }, async () => {
const kp = await getKey();
const verifier = new TokenVerifier();
const jwk = await keyPairToJWK(kp);
verifier.addKey(jwk);
verifier.clearCache();
const token = await signToken(kp, { subject: "u", tenantId: "t" });
const result = await verifier.verifyToken(token);
expect(result.valid).toBe(false); // no keys after clear
});
it("tries multiple keys and succeeds on second", {
timeout: 30000,
}, async () => {
const kp1 = await generateKeyPair("EdDSA");
const kp2 = await generateKeyPair("EdDSA");
const token = await signToken(kp2, { subject: "u", tenantId: "t" });
const verifier = new TokenVerifier();
verifier.addKey(await keyPairToJWK(kp1)); // wrong key
verifier.addKey(await keyPairToJWK(kp2)); // correct key
const result = await verifier.verifyToken(token);
expect(result.valid).toBe(true);
});
});
describe("TokenVerifier - constraint checks", () => {
it("enforces requiredTenantId", { timeout: 30000 }, async () => {
const kp = await getKey();
const token = await signToken(kp, {
subject: "u",
tenantId: "wrong-tenant",
});
const verifier = new TokenVerifier();
verifier.addKey(await keyPairToJWK(kp));
const result = await verifier.verifyToken(token, {
requiredTenantId: "acme",
});
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(JWT_TENANT_CODES.TENANT_MISMATCH);
});
it("passes requiredTenantId when matching", {
timeout: 30000,
}, async () => {
const kp = await getKey();
const token = await signToken(kp, {
subject: "u",
tenantId: "acme",
});
const verifier = new TokenVerifier();
verifier.addKey(await keyPairToJWK(kp));
const result = await verifier.verifyToken(token, {
requiredTenantId: "acme",
});
expect(result.valid).toBe(true);
});
it("enforces requiredRole", { timeout: 30000 }, async () => {
const kp = await getKey();
const token = await signToken(kp, {
subject: "u",
tenantId: "t",
role: "viewer",
});
const verifier = new TokenVerifier();
verifier.addKey(await keyPairToJWK(kp));
const result = await verifier.verifyToken(token, {
requiredRole: "admin",
});
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(JWT_TENANT_CODES.INVALID_CLAIMS);
});
it("enforces requiredPermissions", { timeout: 30000 }, async () => {
const kp = await getKey();
const token = await signToken(kp, {
subject: "u",
tenantId: "t",
permissions: ["read"],
});
const verifier = new TokenVerifier();
verifier.addKey(await keyPairToJWK(kp));
const result = await verifier.verifyToken(token, {
requiredPermissions: ["read", "write"],
});
expect(result.valid).toBe(false);
});
it("passes requiredPermissions when all present", {
timeout: 30000,
}, async () => {
const kp = await getKey();
const token = await signToken(kp, {
subject: "u",
tenantId: "t",
permissions: ["read", "write"],
});
const verifier = new TokenVerifier();
verifier.addKey(await keyPairToJWK(kp));
const result = await verifier.verifyToken(token, {
requiredPermissions: ["read"],
});
expect(result.valid).toBe(true);
});
it("enforces requiredClaims", { timeout: 30000 }, async () => {
const kp = await getKey();
const token = await signToken(kp, {
subject: "u",
tenantId: "t",
});
const verifier = new TokenVerifier();
verifier.addKey(await keyPairToJWK(kp));
const result = await verifier.verifyToken(token, {
requiredClaims: ["missing_claim"],
});
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(JWT_TENANT_CODES.INVALID_CLAIMS);
});
it("enforces requiredDomains", { timeout: 30000 }, async () => {
const kp = await getKey();
const token = await signToken(kp, {
subject: "u",
tenantId: "t",
domains: ["a.com"],
});
const verifier = new TokenVerifier();
verifier.addKey(await keyPairToJWK(kp));
const result = await verifier.verifyToken(token, {
requiredDomains: ["a.com", "b.com"],
});
expect(result.valid).toBe(false);
});
it("passes requiredDomains when all present", {
timeout: 30000,
}, async () => {
const kp = await getKey();
const token = await signToken(kp, {
subject: "u",
tenantId: "t",
domains: ["a.com", "b.com"],
});
const verifier = new TokenVerifier();
verifier.addKey(await keyPairToJWK(kp));
const result = await verifier.verifyToken(token, {
requiredDomains: ["a.com"],
});
expect(result.valid).toBe(true);
});
it("enforces requiredRole when token has no role", {
timeout: 30000,
}, async () => {
const kp = await getKey();
const token = await signToken(kp, { subject: "u", tenantId: "t" });
const verifier = new TokenVerifier();
verifier.addKey(await keyPairToJWK(kp));
const result = await verifier.verifyToken(token, {
requiredRole: "admin",
});
expect(result.valid).toBe(false);
});
it("enforces requiredPermissions when token has no permissions claim", {
timeout: 30000,
}, async () => {
const kp = await getKey();
const token = await signToken(kp, { subject: "u", tenantId: "t" });
const verifier = new TokenVerifier();
verifier.addKey(await keyPairToJWK(kp));
const result = await verifier.verifyToken(token, {
requiredPermissions: ["read"],
});
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(JWT_TENANT_CODES.INVALID_CLAIMS);
});
it("enforces requiredDomains when token has no domains claim", {
timeout: 30000,
}, async () => {
const kp = await getKey();
const token = await signToken(kp, { subject: "u", tenantId: "t" });
const verifier = new TokenVerifier();
verifier.addKey(await keyPairToJWK(kp));
const result = await verifier.verifyToken(token, {
requiredDomains: ["a.com"],
});
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(JWT_TENANT_CODES.INVALID_CLAIMS);
});
it("passes requiredClaims when claim is present", {
timeout: 30000,
}, async () => {
const kp = await getKey();
const token = await signToken(kp, {
subject: "u",
tenantId: "t",
additionalClaims: { custom: "value" },
});
const verifier = new TokenVerifier();
verifier.addKey(await keyPairToJWK(kp));
const result = await verifier.verifyToken(token, {
requiredClaims: ["custom"],
});
expect(result.valid).toBe(true);
});
});
describe("TokenVerifier - JWKS URI", () => {
it("fetches JWKS from remote URI", { timeout: 30000 }, async () => {
const kp = await generateKeyPair("EdDSA", { keyId: "remote-key" });
const jwk = await keyPairToJWK(kp);
const token = await signToken(kp, { subject: "u", tenantId: "t" });
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ keys: [jwk] }),
});
vi.stubGlobal("fetch", mockFetch);
try {
const verifier = new TokenVerifier({
jwksUri: "https://example.com/.well-known/jwks.json",
});
const result = await verifier.verifyToken(token);
expect(result.valid).toBe(true);
expect(mockFetch).toHaveBeenCalledOnce();
} finally {
vi.unstubAllGlobals();
}
});
it("caches JWKS keys after first fetch", { timeout: 30000 }, async () => {
const kp = await generateKeyPair("EdDSA", { keyId: "cached-key" });
const jwk = await keyPairToJWK(kp);
let time = 1000;
const clock = () => time;
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ keys: [jwk] }),
});
vi.stubGlobal("fetch", mockFetch);
try {
const verifier = new TokenVerifier({
jwksUri: "https://example.com/.well-known/jwks.json",
cacheMaxAge: 5000,
clock,
});
const token = await signToken(kp, { subject: "u", tenantId: "t" });
await verifier.verifyToken(token);
time = 1100; // within TTL
await verifier.verifyToken(token);
expect(mockFetch).toHaveBeenCalledOnce(); // cached
} finally {
vi.unstubAllGlobals();
}
});
it("refetches after cache expires", { timeout: 30000 }, async () => {
const kp = await generateKeyPair("EdDSA", { keyId: "refetch-key" });
const jwk = await keyPairToJWK(kp);
let time = 1000;
const clock = () => time;
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ keys: [jwk] }),
});
vi.stubGlobal("fetch", mockFetch);
try {
const verifier = new TokenVerifier({
jwksUri: "https://example.com/.well-known/jwks.json",
cacheMaxAge: 500,
clock,
});
const token = await signToken(kp, { subject: "u", tenantId: "t" });
await verifier.verifyToken(token);
time = 2000; // past TTL
await verifier.verifyToken(token);
expect(mockFetch).toHaveBeenCalledTimes(2);
} finally {
vi.unstubAllGlobals();
}
});
it("retries on JWKS fetch failure", { timeout: 30000 }, async () => {
let callCount = 0;
const mockFetch = vi.fn().mockImplementation(async () => {
callCount++;
if (callCount < 3) throw new Error("network error");
return {
ok: true,
json: async () => ({ keys: [] }),
};
});
vi.stubGlobal("fetch", mockFetch);
try {
const verifier = new TokenVerifier({
jwksUri: "https://example.com/.well-known/jwks.json",
maxRetries: 3,
});
await verifier.verifyToken("a.b.c");
expect(callCount).toBe(3);
} finally {
vi.unstubAllGlobals();
}
});
it("throws after max retries exceeded", { timeout: 30000 }, async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error("always fails"));
vi.stubGlobal("fetch", mockFetch);
try {
const verifier = new TokenVerifier({
jwksUri: "https://example.com/.well-known/jwks.json",
maxRetries: 2,
});
const result = await verifier.verifyToken("a.b.c");
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(JWT_TENANT_CODES.JWKS_FETCH_FAILED);
} finally {
vi.unstubAllGlobals();
}
});
it("handles non-ok HTTP response", { timeout: 30000 }, async () => {
const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 403 });
vi.stubGlobal("fetch", mockFetch);
try {
const verifier = new TokenVerifier({
jwksUri: "https://example.com/.well-known/jwks.json",
maxRetries: 1,
});
const result = await verifier.verifyToken("a.b.c");
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(JWT_TENANT_CODES.JWKS_FETCH_FAILED);
} finally {
vi.unstubAllGlobals();
}
});
it("throws JwtTenantError when aborted", { timeout: 30000 }, async () => {
const ac = new AbortController();
const mockFetch = vi.fn().mockImplementation(async () => {
ac.abort();
throw new Error("aborted");
});
vi.stubGlobal("fetch", mockFetch);
try {
const verifier = new TokenVerifier({
jwksUri: "https://example.com/.well-known/jwks.json",
signal: ac.signal,
maxRetries: 1,
});
const result = await verifier.verifyToken("a.b.c");
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(JWT_TENANT_CODES.JWKS_FETCH_FAILED);
} finally {
vi.unstubAllGlobals();
}
});
});
describe("TokenVerifier - audience + classifier branches", () => {
it("verifies with audience claim match", {
timeout: 30000,
}, async () => {
const kp = await getKey();
const token = await signToken(kp, {
subject: "u",
tenantId: "t",
additionalClaims: { aud: "api://orders" },
});
const verifier = new TokenVerifier({ audience: ["api://orders"] });
verifier.addKey(await keyPairToJWK(kp));
const result = await verifier.verifyToken(token);
expect(result.valid).toBe(true);
});
it("classifies audience mismatch as INVALID_CLAIMS", {
timeout: 30000,
}, async () => {
const kp = await getKey();
const token = await signToken(kp, {
subject: "u",
tenantId: "t",
additionalClaims: { aud: "api://other" },
});
const verifier = new TokenVerifier({ audience: ["api://orders"] });
verifier.addKey(await keyPairToJWK(kp));
const result = await verifier.verifyToken(token);
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(JWT_TENANT_CODES.INVALID_CLAIMS);
});
it("classifies non-JwtTenantError thrown by jwks path", {
timeout: 30000,
}, async () => {
class RawErrVerifier extends TokenVerifier {
protected override async verifyWithJwksUri(): Promise<never> {
throw new Error("token has expired");
}
}
const v = new RawErrVerifier({ jwksUri: "https://example.com/jwks" });
const result = await v.verifyToken("a.b.c");
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(JWT_TENANT_CODES.EXPIRED);
});
it("uses EdDSA default when JWK has no alg field", {
timeout: 30000,
}, async () => {
const kp = await getKey();
const jwk = await keyPairToJWK(kp);
const stripped = { ...jwk };
delete (stripped as Record<string, unknown>)["alg"];
const token = await signToken(kp, { subject: "u", tenantId: "t" });
const verifier = new TokenVerifier();
verifier.addKey(stripped);
const result = await verifier.verifyToken(token);
expect(result.valid).toBe(true);
});
it("falls back to INVALID_SIGNATURE for unrecognized raw errors", {
timeout: 30000,
}, async () => {
class RawErrVerifier extends TokenVerifier {
protected override async verifyWithJwksUri(): Promise<never> {
throw "weird non-error throw";
}
}
const v = new RawErrVerifier({ jwksUri: "https://example.com/jwks" });
const result = await v.verifyToken("a.b.c");
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(JWT_TENANT_CODES.INVALID_SIGNATURE);
});
});
describe("createTokenVerifier", () => {
it("creates a TokenVerifier instance", { timeout: 5000 }, () => {
const v = createTokenVerifier();
expect(v).toBeInstanceOf(TokenVerifier);
});
});
describe("verifyTokenWithKey", () => {
it("verifies with pre-imported key", { timeout: 30000 }, async () => {
const kp = await getKey();
const token = await signToken(kp, { subject: "u", tenantId: "t" });
const result = await verifyTokenWithKey(token, kp.publicKey);
expect(result.valid).toBe(true);
});
it("returns invalid for bad token", { timeout: 30000 }, async () => {
const kp = await getKey();
const result = await verifyTokenWithKey("bad.token.here", kp.publicKey);
expect(result.valid).toBe(false);
});
it("applies constraint checks", { timeout: 30000 }, async () => {
const kp = await getKey();
const token = await signToken(kp, { subject: "u", tenantId: "wrong" });
const result = await verifyTokenWithKey(token, kp.publicKey, {
requiredTenantId: "correct",
});
expect(result.valid).toBe(false);
expect(result.errorCode).toBe(JWT_TENANT_CODES.TENANT_MISMATCH);
});
});

View File

@@ -0,0 +1,328 @@
import type { Clock } from "@suaveplan/types";
import { importJWK, jwtVerify } from "jose";
import {
DEFAULT_CLOCK_TOLERANCE,
DEFAULT_JWKS_CACHE_MAX_AGE,
DEFAULT_MAX_RETRIES,
} from "../../constants/index.js";
import {
JWT_TENANT_CODES,
JwtTenantError,
type JwtTenantErrorCode,
} from "../../errors/jwt-tenant-error.js";
import type {
JWK,
TenantJWTPayload,
TokenVerifierConfig,
VerifyTokenOptions,
VerifyTokenResult,
} from "../../types/jwt-tenant.types.js";
const defaultClock: Clock = () => Date.now();
/** Fetch JWKS from a remote URI with retry and AbortSignal support. */
async function fetchJWKS(
uri: string,
signal: AbortSignal | undefined,
maxRetries: number
): Promise<JWK[]> {
let attempt = 0;
let lastError: unknown;
while (attempt < maxRetries) {
try {
const response = await fetch(uri, {
...(signal !== undefined && { signal }),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const body = (await response.json()) as { keys: unknown[] };
return body.keys as JWK[];
} catch (err) {
if (signal?.aborted === true) {
throw new JwtTenantError(
JWT_TENANT_CODES.JWKS_FETCH_FAILED,
"JWKS fetch was aborted",
{ cause: err as Error }
);
}
lastError = err;
attempt++;
}
}
throw new JwtTenantError(
JWT_TENANT_CODES.JWKS_FETCH_FAILED,
`Failed to fetch JWKS from ${uri} after ${maxRetries} attempts`,
{ cause: lastError as Error }
);
}
/** Classify a jose error message into a `JwtTenantErrorCode`. */
function classifyJoseError(message: string): JwtTenantErrorCode {
const lower = message.toLowerCase();
if (lower.includes("expired") || lower.includes('"exp"')) {
return JWT_TENANT_CODES.EXPIRED;
}
if (lower.includes("signature")) {
return JWT_TENANT_CODES.INVALID_SIGNATURE;
}
if (
lower.includes("issuer") ||
lower.includes('"iss"') ||
lower.includes("audience") ||
lower.includes('"aud"') ||
lower.includes("claim")
) {
return JWT_TENANT_CODES.INVALID_CLAIMS;
}
return JWT_TENANT_CODES.INVALID_SIGNATURE;
}
/** Check per-verification claim constraints on a verified payload. */
function checkConstraints(
payload: TenantJWTPayload,
options: VerifyTokenOptions
): { error: string; errorCode: string } | null {
if (
options.requiredTenantId !== undefined &&
payload.tenant_id !== options.requiredTenantId
) {
return {
error: `Token tenant_id "${payload.tenant_id}" does not match required "${options.requiredTenantId}"`,
errorCode: JWT_TENANT_CODES.TENANT_MISMATCH,
};
}
if (
options.requiredRole !== undefined &&
payload.role !== options.requiredRole
) {
return {
error: `Token role "${payload.role ?? "none"}" does not match required "${options.requiredRole}"`,
errorCode: JWT_TENANT_CODES.INVALID_CLAIMS,
};
}
if (
options.requiredPermissions !== undefined &&
options.requiredPermissions.length > 0
) {
const tokenPerms = (payload.permissions as string[] | undefined) ?? [];
const missing = options.requiredPermissions.filter(
(p) => !tokenPerms.includes(p)
);
if (missing.length > 0) {
return {
error: `Token missing required permissions: ${missing.join(", ")}`,
errorCode: JWT_TENANT_CODES.INVALID_CLAIMS,
};
}
}
if (options.requiredClaims !== undefined) {
for (const claim of options.requiredClaims) {
if (payload[claim] === undefined) {
return {
error: `Token missing required claim: ${claim}`,
errorCode: JWT_TENANT_CODES.INVALID_CLAIMS,
};
}
}
}
if (
options.requiredDomains !== undefined &&
options.requiredDomains.length > 0
) {
const tokenDomains = (payload.domains as string[] | undefined) ?? [];
const missing = options.requiredDomains.filter(
(d) => !tokenDomains.includes(d)
);
if (missing.length > 0) {
return {
error: `Token missing required domains: ${missing.join(", ")}`,
errorCode: JWT_TENANT_CODES.INVALID_CLAIMS,
};
}
}
return null;
}
/**
* Verifies signed JWTs, resolving keys either from a local JWK cache or a
* remote JWKS URI. After successful signature verification, per-verification
* constraint checks enforce tenant isolation, role requirements, and permission
* scopes. `verifyToken` never throws — it always returns a `VerifyTokenResult`.
*/
export class TokenVerifier {
protected readonly config: TokenVerifierConfig;
protected readonly clock: Clock;
protected cachedKeys: JWK[] | null = null;
protected cacheExpiresAt = 0;
constructor(config: TokenVerifierConfig = {}) {
this.config = config;
this.clock = config.clock ?? defaultClock;
}
/** Verify a compact JWT and return a structured result. */
async verifyToken(
token: string,
options: VerifyTokenOptions = {}
): Promise<VerifyTokenResult> {
try {
let verifiedPayload: TenantJWTPayload;
if (this.config.jwksUri !== undefined) {
verifiedPayload = await this.verifyWithJwksUri(token);
} else {
if (this.cachedKeys === null || this.cachedKeys.length === 0) {
return {
valid: false,
error: "No verification keys configured",
errorCode: JWT_TENANT_CODES.KEY_NOT_FOUND,
};
}
verifiedPayload = await this.verifyWithKeys(
token,
this.cachedKeys
);
}
const constraintError = checkConstraints(verifiedPayload, options);
if (constraintError !== null) {
return { valid: false, ...constraintError };
}
return { valid: true, payload: verifiedPayload };
} catch (err) {
if (err instanceof JwtTenantError) {
return {
valid: false,
error: err.message,
errorCode: err.code,
};
}
const message = err instanceof Error ? err.message : String(err);
const code = classifyJoseError(message);
return { valid: false, error: message, errorCode: code };
}
}
/** Add a local JWK to the verification key cache. */
addKey(jwk: JWK): void {
if (this.cachedKeys === null) {
this.cachedKeys = [];
}
this.cachedKeys.push(jwk);
}
/** Clear the JWKS cache (forces re-fetch on next verification). */
clearCache(): void {
this.cachedKeys = null;
this.cacheExpiresAt = 0;
}
protected async verifyWithJwksUri(
token: string
): Promise<TenantJWTPayload> {
const uri = this.config.jwksUri!;
const cacheMaxAge =
this.config.cacheMaxAge ?? DEFAULT_JWKS_CACHE_MAX_AGE;
const maxRetries = this.config.maxRetries ?? DEFAULT_MAX_RETRIES;
const now = this.clock();
if (this.cachedKeys === null || now >= this.cacheExpiresAt) {
this.cachedKeys = await fetchJWKS(
uri,
this.config.signal,
maxRetries
);
this.cacheExpiresAt = now + cacheMaxAge;
}
return this.verifyWithKeys(token, this.cachedKeys);
}
protected async verifyWithKeys(
token: string,
keys: JWK[]
): Promise<TenantJWTPayload> {
const tolerance = this.config.clockTolerance ?? DEFAULT_CLOCK_TOLERANCE;
let lastError: unknown;
for (const jwk of keys) {
try {
const alg = (jwk["alg"] as string | undefined) ?? "EdDSA";
const key = await importJWK(
jwk as Parameters<typeof importJWK>[0],
alg
);
const verifyOptions: Parameters<typeof jwtVerify>[2] = {
clockTolerance: tolerance,
};
if (this.config.issuer !== undefined) {
verifyOptions.issuer = this.config.issuer;
}
if (
this.config.audience !== undefined &&
this.config.audience.length > 0
) {
verifyOptions.audience = this.config.audience;
}
const { payload } = await jwtVerify(token, key, verifyOptions);
return payload as unknown as TenantJWTPayload;
} catch (err) {
lastError = err;
}
}
const message =
lastError instanceof Error ? lastError.message : String(lastError);
const code = classifyJoseError(message);
throw new JwtTenantError(code, message, {
cause: lastError instanceof Error ? lastError : undefined,
});
}
}
/** Factory function for `TokenVerifier`. */
export function createTokenVerifier(
config: TokenVerifierConfig = {}
): TokenVerifier {
return new TokenVerifier(config);
}
/**
* Standalone verification using a pre-imported key reference.
* Pass the raw key (from `importPublicKeyFromJWK` or similar).
*/
export async function verifyTokenWithKey(
token: string,
key: unknown,
options: VerifyTokenOptions = {}
): Promise<VerifyTokenResult> {
try {
const { payload } = await jwtVerify(
token,
key as Parameters<typeof jwtVerify>[1]
);
const tenantPayload = payload as unknown as TenantJWTPayload;
const constraintError = checkConstraints(tenantPayload, options);
if (constraintError !== null) {
return { valid: false, ...constraintError };
}
return { valid: true, payload: tenantPayload };
} catch (err) {
return { valid: false, error: (err as Error).message };
}
}

View File

@@ -0,0 +1,102 @@
# jwt-tenant.types
## Purpose
Defines the complete public type surface for `@suaveplan/jwt-tenant`. Every interface and type alias used across the package is declared here: key pair shapes, JWT payload structure, issuer/verifier configuration, JWKS endpoint types, and the `TenantKeyStore` adapter interface. Centralising types in one file avoids circular imports and makes the public contract immediately readable.
## Features
- `KeyAlgorithm` union covering ECDSA (ES256/ES384/ES512), EdDSA, and RSA (RS256/RS384/RS512)
- `KeyPair` / `SerializedKeyPair` for in-memory and JSON-exportable key representations
- `JWK` / `JWKS` RFC 7517-compatible web key types with open index signature
- `TenantJWTPayload` with required `tenant_id`, `sub`, `iat`, `exp`, `jti` plus optional tenant claims
- `TokenIssuerConfig` / `CreateTokenOptions` for token creation
- `TokenVerifierConfig` / `VerifyTokenOptions` / `VerifyTokenResult` for verification
- `KeyGenOptions` with injectable `Clock` for deterministic timestamps
- `JWKSEndpointOptions` / `JWKSEndpointConfig` / `JWKSResponse` for JWKS serving
- `TenantKeyStore` adapter interface for pluggable key storage
## Usage: Basic Example
```ts
import type {
KeyPair,
TenantJWTPayload,
TokenIssuerConfig,
CreateTokenOptions,
} from "@suaveplan/jwt-tenant";
const config: TokenIssuerConfig = {
issuer: "https://auth.example.com",
defaultExpiresIn: "1h",
};
const options: CreateTokenOptions = {
subject: "user-123",
tenantId: "acme",
role: "admin",
};
```
## Usage: Advanced Example
```ts
import type {
TenantKeyStore,
KeyPair,
JWKSEndpointOptions,
TokenVerifierConfig,
} from "@suaveplan/jwt-tenant";
// Implement a custom key store backed by a database
class DatabaseKeyStore implements TenantKeyStore {
async getActiveKeys(tenantId?: string): Promise<KeyPair[]> {
// fetch from DB filtered by tenantId + expiry
return [];
}
async getKeyById(keyId: string): Promise<KeyPair | null> {
// SELECT by keyId
return null;
}
async addKey(keyPair: KeyPair): Promise<void> {
// INSERT
}
async removeKey(keyId: string): Promise<void> {
// DELETE
}
}
const verifierConfig: TokenVerifierConfig = {
issuer: "https://auth.example.com",
clockTolerance: 30,
jwksUri: "https://auth.example.com/.well-known/jwks.json",
cacheMaxAge: 5 * 60 * 1000,
maxRetries: 3,
};
```
## API Reference
### `KeyAlgorithm`
Union type: `"ES256" | "ES384" | "ES512" | "EdDSA" | "RS256" | "RS384" | "RS512"`.
### `KeyPair`
In-memory key pair with opaque `privateKey`/`publicKey` fields (CryptoKey or jose KeyLike).
### `SerializedKeyPair`
JSON-exportable form with `privateKeyJwk`/`publicKeyJwk` as JWK objects and ISO-8601 date strings.
### `TenantJWTPayload`
Standard JWT claims plus `tenant_id`, optional `domains`, `role`, `permissions`, `session_id`. Open index signature allows additional claims.
### `TenantKeyStore`
Pluggable storage adapter: `getActiveKeys(tenantId?)`, `getKeyById(id)`, `addKey(kp)`, `removeKey(id)`.
## Implementation Notes
All `privateKey` and `publicKey` fields are typed as `unknown` to avoid exposing the `jose` library's internal types in the public interface. Consumers that need raw key material should use `serializeKeyPair` / `deserializeKeyPair` instead.

View File

@@ -0,0 +1,163 @@
import type { Clock } from "@suaveplan/types";
/** Supported JWT signing algorithms. */
export type KeyAlgorithm =
| "ES256"
| "ES384"
| "ES512"
| "EdDSA"
| "RS256"
| "RS384"
| "RS512";
/** A cryptographic key pair for JWT signing and verification. */
export interface KeyPair {
/** Unique key identifier, used in the `kid` header. */
keyId: string;
/** Signing algorithm this key pair supports. */
algorithm: KeyAlgorithm;
/** Private key — opaque CryptoKey or KeyLike. */
privateKey: unknown;
/** Public key — opaque CryptoKey or KeyLike. */
publicKey: unknown;
/** When the key pair was created. */
createdAt: Date;
/** Optional expiry; the key pair should be rotated before this date. */
expiresAt?: Date;
}
/** Serialized (JSON-exportable) form of a key pair. */
export interface SerializedKeyPair {
keyId: string;
algorithm: KeyAlgorithm;
privateKeyJwk: JWK;
publicKeyJwk: JWK;
createdAt: string;
expiresAt?: string;
}
/** A JSON Web Key as defined in RFC 7517. */
export interface JWK {
kty: string;
kid?: string;
alg?: string;
use?: string;
[claim: string]: unknown;
}
/** A JSON Web Key Set. */
export interface JWKS {
keys: JWK[];
}
/** JWT payload structure for multi-tenant tokens. */
export 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;
}
/** Configuration for TokenIssuer. */
export interface TokenIssuerConfig {
/** JWT `iss` claim value. */
issuer?: string;
/** Default token lifetime, e.g. `"1h"`, `"15m"`. */
defaultExpiresIn?: string;
/** Injectable clock for deterministic `iat`/`exp` values. */
clock?: Clock;
}
/** Options for creating a single token. */
export interface CreateTokenOptions {
subject: string;
tenantId: string;
expiresIn?: string;
domains?: string[];
role?: string;
permissions?: string[];
sessionId?: string;
issuer?: string;
additionalClaims?: Record<string, unknown>;
}
/** Configuration for TokenVerifier. */
export interface TokenVerifierConfig {
/** Expected `iss` claim. */
issuer?: string;
/** Expected `aud` claim(s). */
audience?: string[];
/** Clock skew tolerance in seconds. */
clockTolerance?: number;
/** Remote JWKS URI for key fetching. */
jwksUri?: string;
/** JWKS cache TTL in milliseconds (default 5 min). */
cacheMaxAge?: number;
/** Injectable clock. */
clock?: Clock;
/** AbortSignal for JWKS fetch cancellation. */
signal?: AbortSignal;
/** Max retry attempts for JWKS fetch (default 3). */
maxRetries?: number;
}
/** Per-verification constraint options. */
export interface VerifyTokenOptions {
requiredClaims?: string[];
requiredRole?: string;
requiredPermissions?: string[];
requiredTenantId?: string;
requiredDomains?: string[];
}
/** Result of a token verification attempt. */
export interface VerifyTokenResult {
valid: boolean;
payload?: TenantJWTPayload;
error?: string;
errorCode?: string;
}
/** Options for key generation. */
export interface KeyGenOptions {
/** Key ID override; defaults to a random UUID. */
keyId?: string;
/** TTL in milliseconds; sets `expiresAt`. */
ttlMs?: number;
/** Injectable clock for `createdAt`. */
clock?: Clock;
}
/** Configuration for JWKSEndpoint. */
export interface JWKSEndpointOptions {
keyStore: TenantKeyStore;
cacheMaxAge?: number;
clock?: Clock;
}
/** Per-request options for JWKS endpoint. */
export interface JWKSEndpointConfig {
path?: string;
includeExpired?: boolean;
}
/** HTTP-like response from the JWKS endpoint. */
export interface JWKSResponse {
status: number;
body: string;
headers: Record<string, string>;
}
/** Pluggable key storage interface for multi-tenant JWKS management. */
export interface TenantKeyStore {
getActiveKeys(tenantId?: string): Promise<KeyPair[]>;
getKeyById(keyId: string): Promise<KeyPair | null>;
addKey(keyPair: KeyPair): Promise<void>;
removeKey(keyId: string): Promise<void>;
}

View File

@@ -0,0 +1,13 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("@suaveplan/jwt-tenant dep-graph", () => {
it("can import the main entry point", { timeout: 30000 }, async () => {
const mod = await import("../src/index.js");
expect(mod.TokenIssuer).toBeDefined();
expect(mod.TokenVerifier).toBeDefined();
expect(mod.InMemoryKeyStore).toBeDefined();
expect(mod.JWKSEndpoint).toBeDefined();
expect(mod.JWT_TENANT_CODES).toBeDefined();
expect(mod.generateKeyPair).toBeDefined();
});
});

View File

@@ -0,0 +1,8 @@
// Ensure `globalThis.crypto` is available in bare-Node vitest runs.
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,11 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/jwt-tenant",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/types",
"@suaveplan/retry",
"jose",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,27 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**", "src/**/__benchmarks__/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});