Initial Commit
This commit is contained in:
65
packages/identity/auth/jwt-tenant/README.md
Normal file
65
packages/identity/auth/jwt-tenant/README.md
Normal file
File diff suppressed because one or more lines are too long
91
packages/identity/auth/jwt-tenant/package.json
Normal file
91
packages/identity/auth/jwt-tenant/package.json
Normal 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:^"
|
||||
}
|
||||
}
|
||||
42
packages/identity/auth/jwt-tenant/src/constants/index.ts
Normal file
42
packages/identity/auth/jwt-tenant/src/constants/index.ts
Normal 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,
|
||||
};
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
71
packages/identity/auth/jwt-tenant/src/index.ts
Normal file
71
packages/identity/auth/jwt-tenant/src/index.ts
Normal 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";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
createInMemoryKeyStore,
|
||||
InMemoryKeyStore,
|
||||
} from "./in-memory-key-store.js";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
createJWKSEndpoint,
|
||||
formatJWKSResponse,
|
||||
JWKSEndpoint,
|
||||
} from "./jwks-endpoint.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
calculateKeyExpiration,
|
||||
deserializeKeyPair,
|
||||
generateKeyPair,
|
||||
getAlgorithmFamily,
|
||||
importPublicKeyFromJWK,
|
||||
isKeyPairExpired,
|
||||
keyPairToJWK,
|
||||
serializeKeyPair,
|
||||
} from "./key-generator.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
createTokenIssuer,
|
||||
signToken,
|
||||
TokenIssuer,
|
||||
} from "./token-issuer.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
createTokenVerifier,
|
||||
TokenVerifier,
|
||||
verifyTokenWithKey,
|
||||
} from "./token-verifier.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
102
packages/identity/auth/jwt-tenant/src/types/jwt-tenant.types.md
Normal file
102
packages/identity/auth/jwt-tenant/src/types/jwt-tenant.types.md
Normal 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.
|
||||
163
packages/identity/auth/jwt-tenant/src/types/jwt-tenant.types.ts
Normal file
163
packages/identity/auth/jwt-tenant/src/types/jwt-tenant.types.ts
Normal 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>;
|
||||
}
|
||||
13
packages/identity/auth/jwt-tenant/tests/dep-graph.test.ts
Normal file
13
packages/identity/auth/jwt-tenant/tests/dep-graph.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
11
packages/identity/auth/jwt-tenant/tsconfig.json
Normal file
11
packages/identity/auth/jwt-tenant/tsconfig.json
Normal 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"]
|
||||
}
|
||||
12
packages/identity/auth/jwt-tenant/tsconfig.lib.json
Normal file
12
packages/identity/auth/jwt-tenant/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
39
packages/identity/auth/jwt-tenant/vite.config.ts
Normal file
39
packages/identity/auth/jwt-tenant/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
27
packages/identity/auth/jwt-tenant/vitest.config.ts
Normal file
27
packages/identity/auth/jwt-tenant/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user