@codemation/managed-auth 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +16 -16
- package/CHANGELOG.md +6 -0
- package/dist/index.cjs +0 -18
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +0 -31
- package/dist/index.d.ts +0 -31
- package/dist/index.js +0 -18
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/JwksCache.ts +3 -16
- package/src/ManagedJwtVerifier.ts +0 -9
- package/src/types.ts +0 -12
- package/test/JwksCache.test.ts +3 -7
- package/test/ManagedJwtVerifier.test.ts +1 -11
- package/.turbo/turbo-lint.log +0 -4
- package/.turbo/turbo-typecheck.log +0 -4
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
|
|
2
|
-
> @codemation/managed-auth@0.1.
|
|
2
|
+
> @codemation/managed-auth@0.1.1 build /home/runner/work/codemation/codemation/packages/managed-auth
|
|
3
3
|
> tsdown
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
5
|
+
[34mℹ[39m tsdown [2mv0.15.12[22m powered by rolldown [2mv1.0.0-beta.45[22m
|
|
6
|
+
[34mℹ[39m Using tsdown config: [4m/home/runner/work/codemation/codemation/packages/managed-auth/tsdown.config.ts[24m
|
|
7
|
+
[34mℹ[39m entry: [34msrc/index.ts[39m
|
|
8
|
+
[34mℹ[39m tsconfig: [34mtsconfig.json[39m
|
|
9
|
+
[34mℹ[39m Build start
|
|
10
|
+
[34mℹ[39m [33m[CJS][39m [2mdist/[22m[1mindex.cjs[22m [2m4.82 kB[22m [2m│ gzip: 1.80 kB[22m
|
|
11
|
+
[34mℹ[39m [33m[CJS][39m [2mdist/[22mindex.cjs.map [2m7.51 kB[22m [2m│ gzip: 2.51 kB[22m
|
|
12
|
+
[34mℹ[39m [33m[CJS][39m 2 files, total: 12.33 kB
|
|
13
|
+
[34mℹ[39m [34m[ESM][39m [2mdist/[22m[1mindex.js[22m [2m3.78 kB[22m [2m│ gzip: 1.37 kB[22m
|
|
14
|
+
[34mℹ[39m [34m[ESM][39m [2mdist/[22mindex.js.map [2m7.50 kB[22m [2m│ gzip: 2.50 kB[22m
|
|
15
|
+
[34mℹ[39m [34m[ESM][39m [2mdist/[22m[32m[1mindex.d.ts[22m[39m [2m1.80 kB[22m [2m│ gzip: 0.66 kB[22m
|
|
16
|
+
[34mℹ[39m [34m[ESM][39m 3 files, total: 13.08 kB
|
|
17
|
+
[34mℹ[39m [33m[CJS][39m [2mdist/[22m[32m[1mindex.d.cts[22m[39m [2m1.80 kB[22m [2m│ gzip: 0.66 kB[22m
|
|
18
|
+
[34mℹ[39m [33m[CJS][39m 1 files, total: 1.80 kB
|
|
19
|
+
[32m✔[39m Build complete in [32m4994ms[39m
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @codemation/managed-auth
|
|
2
2
|
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#247](https://github.com/MadeRelevant/codemation/pull/247) [`bfdd759`](https://github.com/MadeRelevant/codemation/commit/bfdd7590b4903676b223c2f302b9bcd0f4a4583c) Thanks [@cblokland90](https://github.com/cblokland90)! - Remove all human-written comments from TypeScript source files and add `codemation/no-comments` ESLint rule to enforce self-describing code going forward.
|
|
8
|
+
|
|
3
9
|
## 0.1.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
package/dist/index.cjs
CHANGED
|
@@ -27,15 +27,6 @@ jose = __toESM(jose);
|
|
|
27
27
|
//#region src/JwksCache.ts
|
|
28
28
|
const DEFAULT_TTL_MS = 900 * 1e3;
|
|
29
29
|
const DEFAULT_JITTER_MS = 60 * 1e3;
|
|
30
|
-
/**
|
|
31
|
-
* Fetches and caches JWKS from a configured URL.
|
|
32
|
-
*
|
|
33
|
-
* On a `kid` cache miss the cache is refreshed once before failing —
|
|
34
|
-
* this handles key rotation without requiring a restart.
|
|
35
|
-
*
|
|
36
|
-
* Refresh-window jitter avoids thundering-herd stampedes when multiple
|
|
37
|
-
* workers share the same process lifetime.
|
|
38
|
-
*/
|
|
39
30
|
var JwksCache = class {
|
|
40
31
|
cache = null;
|
|
41
32
|
constructor(config, fetch, clock) {
|
|
@@ -43,7 +34,6 @@ var JwksCache = class {
|
|
|
43
34
|
this.fetch = fetch;
|
|
44
35
|
this.clock = clock;
|
|
45
36
|
}
|
|
46
|
-
/** Returns the cached key for `kid`, fetching if needed. Refreshes once on kid miss. */
|
|
47
37
|
async getKey(kid) {
|
|
48
38
|
let entry = await this.getValidEntry();
|
|
49
39
|
const cached = entry.keys.get(kid);
|
|
@@ -80,14 +70,6 @@ var JwksCache = class {
|
|
|
80
70
|
|
|
81
71
|
//#endregion
|
|
82
72
|
//#region src/ManagedJwtVerifier.ts
|
|
83
|
-
/**
|
|
84
|
-
* Validates CP-signed EdDSA JWT bearers.
|
|
85
|
-
*
|
|
86
|
-
* Checks: signature, `iss` matches expected, `aud` matches expected workspaceId,
|
|
87
|
-
* `exp`, `nbf`. Returns a `VerifiedManagedPrincipal` or a structured `JwtVerificationFailure`.
|
|
88
|
-
*
|
|
89
|
-
* Hono-independent — no web framework coupling.
|
|
90
|
-
*/
|
|
91
73
|
var ManagedJwtVerifier = class {
|
|
92
74
|
constructor(config, jwksCache) {
|
|
93
75
|
this.config = config;
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.cjs","names":["config: JwksCacheConfig","fetch: FetchFn","clock: Clock","entry: CacheEntry","config: ManagedJwtVerifierConfig","jwksCache: JwksCache","kid: string | undefined","joseErrors"],"sources":["../src/JwksCache.ts","../src/ManagedJwtVerifier.ts"],"sourcesContent":["import { importJWK } from \"jose\";\nimport type { Clock, FetchFn, JwksCacheConfig } from \"./types\";\n\nconst DEFAULT_TTL_MS = 15 * 60 * 1000
|
|
1
|
+
{"version":3,"file":"index.cjs","names":["config: JwksCacheConfig","fetch: FetchFn","clock: Clock","entry: CacheEntry","config: ManagedJwtVerifierConfig","jwksCache: JwksCache","kid: string | undefined","joseErrors"],"sources":["../src/JwksCache.ts","../src/ManagedJwtVerifier.ts"],"sourcesContent":["import { importJWK } from \"jose\";\nimport type { Clock, FetchFn, JwksCacheConfig } from \"./types\";\n\nconst DEFAULT_TTL_MS = 15 * 60 * 1000;\nconst DEFAULT_JITTER_MS = 60 * 1000;\n\ntype JoseKey = Awaited<ReturnType<typeof importJWK>>;\n\ninterface CacheEntry {\n keys: Map<string, JoseKey>;\n expiresAt: number;\n}\n\ninterface JwkKey {\n kid?: string;\n kty?: string;\n crv?: string;\n x?: string;\n use?: string;\n alg?: string;\n}\n\ninterface JwksResponse {\n keys: JwkKey[];\n}\n\nexport class JwksCache {\n private cache: CacheEntry | null = null;\n\n constructor(\n private readonly config: JwksCacheConfig,\n private readonly fetch: FetchFn,\n private readonly clock: Clock,\n ) {}\n\n async getKey(kid: string): Promise<JoseKey | null> {\n let entry = await this.getValidEntry();\n const cached = entry.keys.get(kid);\n if (cached !== undefined) {\n return cached;\n }\n\n entry = await this.refresh();\n return entry.keys.get(kid) ?? null;\n }\n\n private async getValidEntry(): Promise<CacheEntry> {\n if (this.cache !== null && this.clock.now() < this.cache.expiresAt) {\n return this.cache;\n }\n return this.refresh();\n }\n\n private async refresh(): Promise<CacheEntry> {\n const response = await this.fetch(this.config.jwksUrl);\n if (!response.ok) {\n throw new Error(`JWKS fetch failed for ${this.config.jwksUrl}`);\n }\n const body = (await response.json()) as JwksResponse;\n const keys = new Map<string, JoseKey>();\n\n for (const jwk of body.keys ?? []) {\n if (!jwk.kid) continue;\n try {\n const key = await importJWK(jwk as Parameters<typeof importJWK>[0]);\n keys.set(jwk.kid, key);\n } catch {}\n }\n\n const ttl = this.config.ttlMs ?? DEFAULT_TTL_MS;\n const jitter = Math.random() * (this.config.jitterMs ?? DEFAULT_JITTER_MS);\n const entry: CacheEntry = {\n keys,\n expiresAt: this.clock.now() + ttl + jitter,\n };\n this.cache = entry;\n return entry;\n }\n}\n","import { decodeProtectedHeader, jwtVerify, errors as joseErrors } from \"jose\";\nimport type { JwtVerificationFailure, ManagedJwtVerifierConfig, VerifiedManagedPrincipal } from \"./types\";\nimport type { JwksCache } from \"./JwksCache\";\n\ntype VerifyResult = VerifiedManagedPrincipal | JwtVerificationFailure;\n\nexport class ManagedJwtVerifier {\n constructor(\n private readonly config: ManagedJwtVerifierConfig,\n private readonly jwksCache: JwksCache,\n ) {}\n\n async verify(token: string): Promise<VerifyResult> {\n let kid: string | undefined;\n try {\n const header = decodeProtectedHeader(token);\n kid = typeof header.kid === \"string\" ? header.kid : undefined;\n } catch {\n return { failure: \"malformed\", message: \"Unable to decode JWT header.\" };\n }\n\n if (!kid) {\n return { failure: \"missing-kid\", message: \"JWT header is missing the `kid` field.\" };\n }\n\n const key = await this.jwksCache.getKey(kid);\n if (key === null) {\n return { failure: \"unknown-kid\", message: `No key found for kid \"${kid}\" after JWKS refresh.` };\n }\n\n try {\n const { payload } = await jwtVerify(token, key, {\n issuer: this.config.expectedIssuer,\n audience: this.config.expectedAudience,\n algorithms: [\"EdDSA\"],\n });\n\n const sub = payload.sub;\n const aud = payload.aud;\n const workspaceId = Array.isArray(aud) ? aud[0] : aud;\n\n if (!sub || typeof sub !== \"string\") {\n return { failure: \"malformed\", message: \"JWT is missing the `sub` claim.\" };\n }\n if (!workspaceId || typeof workspaceId !== \"string\") {\n return { failure: \"wrong-aud\", message: \"JWT `aud` claim is absent or not a string.\" };\n }\n\n return { userId: sub, workspaceId };\n } catch (error) {\n if (error instanceof joseErrors.JWTExpired) {\n return { failure: \"expired\", message: \"JWT has expired.\" };\n }\n if (error instanceof joseErrors.JWTClaimValidationFailed) {\n const claim = error.claim;\n if (claim === \"iss\") {\n return { failure: \"wrong-iss\", message: \"JWT `iss` claim does not match expected issuer.\" };\n }\n if (claim === \"aud\") {\n return { failure: \"wrong-aud\", message: \"JWT `aud` claim does not match expected audience.\" };\n }\n if (claim === \"nbf\") {\n return { failure: \"not-yet-valid\", message: \"JWT is not yet valid (nbf check failed).\" };\n }\n return { failure: \"malformed\", message: `JWT claim validation failed: ${error.message}` };\n }\n if (error instanceof joseErrors.JWSSignatureVerificationFailed || error instanceof joseErrors.JWSInvalid) {\n return { failure: \"bad-signature\", message: \"JWT signature verification failed.\" };\n }\n return { failure: \"malformed\", message: \"JWT verification failed.\" };\n }\n }\n}\n"],"mappings":"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"}
|
package/dist/index.d.cts
CHANGED
|
@@ -1,64 +1,41 @@
|
|
|
1
1
|
import { importJWK } from "jose";
|
|
2
2
|
|
|
3
3
|
//#region src/types.d.ts
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* A successfully verified CP-signed JWT principal.
|
|
7
|
-
* `userId` maps to the JWT `sub` claim; `workspaceId` maps to `aud`.
|
|
8
|
-
*/
|
|
9
4
|
interface VerifiedManagedPrincipal {
|
|
10
5
|
readonly userId: string;
|
|
11
6
|
readonly workspaceId: string;
|
|
12
7
|
}
|
|
13
8
|
type JwtVerificationFailureReason = "missing-kid" | "unknown-kid" | "bad-signature" | "wrong-iss" | "wrong-aud" | "expired" | "not-yet-valid" | "malformed";
|
|
14
|
-
/** Structured failure returned instead of throwing, so callers can map to HTTP status codes cleanly. */
|
|
15
9
|
interface JwtVerificationFailure {
|
|
16
10
|
readonly failure: JwtVerificationFailureReason;
|
|
17
11
|
readonly message: string;
|
|
18
12
|
}
|
|
19
13
|
interface JwksCacheConfig {
|
|
20
|
-
/** URL to the JWKS endpoint (e.g. https://cp.example.com/.well-known/jwks.json). */
|
|
21
14
|
readonly jwksUrl: string;
|
|
22
|
-
/** TTL in milliseconds before the cache is considered stale. Defaults to 15 minutes. */
|
|
23
15
|
readonly ttlMs?: number;
|
|
24
|
-
/** Jitter window in milliseconds applied to TTL to avoid stampedes. Defaults to 60 seconds. */
|
|
25
16
|
readonly jitterMs?: number;
|
|
26
17
|
}
|
|
27
18
|
interface ManagedJwtVerifierConfig {
|
|
28
|
-
/** Expected value of the JWT `iss` claim — must exactly match. */
|
|
29
19
|
readonly expectedIssuer: string;
|
|
30
|
-
/** Expected `aud` claim — must exactly match the workspace ID. */
|
|
31
20
|
readonly expectedAudience: string;
|
|
32
21
|
readonly jwksCache: JwksCacheConfig;
|
|
33
22
|
}
|
|
34
|
-
/** Minimal fetch-compatible function type for injection / testing. */
|
|
35
23
|
type FetchFn = (url: string) => Promise<{
|
|
36
24
|
ok: boolean;
|
|
37
25
|
json(): Promise<unknown>;
|
|
38
26
|
}>;
|
|
39
|
-
/** Minimal clock interface for injection / testing. */
|
|
40
27
|
interface Clock {
|
|
41
28
|
now(): number;
|
|
42
29
|
}
|
|
43
30
|
//#endregion
|
|
44
31
|
//#region src/JwksCache.d.ts
|
|
45
32
|
type JoseKey = Awaited<ReturnType<typeof importJWK>>;
|
|
46
|
-
/**
|
|
47
|
-
* Fetches and caches JWKS from a configured URL.
|
|
48
|
-
*
|
|
49
|
-
* On a `kid` cache miss the cache is refreshed once before failing —
|
|
50
|
-
* this handles key rotation without requiring a restart.
|
|
51
|
-
*
|
|
52
|
-
* Refresh-window jitter avoids thundering-herd stampedes when multiple
|
|
53
|
-
* workers share the same process lifetime.
|
|
54
|
-
*/
|
|
55
33
|
declare class JwksCache {
|
|
56
34
|
private readonly config;
|
|
57
35
|
private readonly fetch;
|
|
58
36
|
private readonly clock;
|
|
59
37
|
private cache;
|
|
60
38
|
constructor(config: JwksCacheConfig, fetch: FetchFn, clock: Clock);
|
|
61
|
-
/** Returns the cached key for `kid`, fetching if needed. Refreshes once on kid miss. */
|
|
62
39
|
getKey(kid: string): Promise<JoseKey | null>;
|
|
63
40
|
private getValidEntry;
|
|
64
41
|
private refresh;
|
|
@@ -66,14 +43,6 @@ declare class JwksCache {
|
|
|
66
43
|
//#endregion
|
|
67
44
|
//#region src/ManagedJwtVerifier.d.ts
|
|
68
45
|
type VerifyResult = VerifiedManagedPrincipal | JwtVerificationFailure;
|
|
69
|
-
/**
|
|
70
|
-
* Validates CP-signed EdDSA JWT bearers.
|
|
71
|
-
*
|
|
72
|
-
* Checks: signature, `iss` matches expected, `aud` matches expected workspaceId,
|
|
73
|
-
* `exp`, `nbf`. Returns a `VerifiedManagedPrincipal` or a structured `JwtVerificationFailure`.
|
|
74
|
-
*
|
|
75
|
-
* Hono-independent — no web framework coupling.
|
|
76
|
-
*/
|
|
77
46
|
declare class ManagedJwtVerifier {
|
|
78
47
|
private readonly config;
|
|
79
48
|
private readonly jwksCache;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,64 +1,41 @@
|
|
|
1
1
|
import { importJWK } from "jose";
|
|
2
2
|
|
|
3
3
|
//#region src/types.d.ts
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* A successfully verified CP-signed JWT principal.
|
|
7
|
-
* `userId` maps to the JWT `sub` claim; `workspaceId` maps to `aud`.
|
|
8
|
-
*/
|
|
9
4
|
interface VerifiedManagedPrincipal {
|
|
10
5
|
readonly userId: string;
|
|
11
6
|
readonly workspaceId: string;
|
|
12
7
|
}
|
|
13
8
|
type JwtVerificationFailureReason = "missing-kid" | "unknown-kid" | "bad-signature" | "wrong-iss" | "wrong-aud" | "expired" | "not-yet-valid" | "malformed";
|
|
14
|
-
/** Structured failure returned instead of throwing, so callers can map to HTTP status codes cleanly. */
|
|
15
9
|
interface JwtVerificationFailure {
|
|
16
10
|
readonly failure: JwtVerificationFailureReason;
|
|
17
11
|
readonly message: string;
|
|
18
12
|
}
|
|
19
13
|
interface JwksCacheConfig {
|
|
20
|
-
/** URL to the JWKS endpoint (e.g. https://cp.example.com/.well-known/jwks.json). */
|
|
21
14
|
readonly jwksUrl: string;
|
|
22
|
-
/** TTL in milliseconds before the cache is considered stale. Defaults to 15 minutes. */
|
|
23
15
|
readonly ttlMs?: number;
|
|
24
|
-
/** Jitter window in milliseconds applied to TTL to avoid stampedes. Defaults to 60 seconds. */
|
|
25
16
|
readonly jitterMs?: number;
|
|
26
17
|
}
|
|
27
18
|
interface ManagedJwtVerifierConfig {
|
|
28
|
-
/** Expected value of the JWT `iss` claim — must exactly match. */
|
|
29
19
|
readonly expectedIssuer: string;
|
|
30
|
-
/** Expected `aud` claim — must exactly match the workspace ID. */
|
|
31
20
|
readonly expectedAudience: string;
|
|
32
21
|
readonly jwksCache: JwksCacheConfig;
|
|
33
22
|
}
|
|
34
|
-
/** Minimal fetch-compatible function type for injection / testing. */
|
|
35
23
|
type FetchFn = (url: string) => Promise<{
|
|
36
24
|
ok: boolean;
|
|
37
25
|
json(): Promise<unknown>;
|
|
38
26
|
}>;
|
|
39
|
-
/** Minimal clock interface for injection / testing. */
|
|
40
27
|
interface Clock {
|
|
41
28
|
now(): number;
|
|
42
29
|
}
|
|
43
30
|
//#endregion
|
|
44
31
|
//#region src/JwksCache.d.ts
|
|
45
32
|
type JoseKey = Awaited<ReturnType<typeof importJWK>>;
|
|
46
|
-
/**
|
|
47
|
-
* Fetches and caches JWKS from a configured URL.
|
|
48
|
-
*
|
|
49
|
-
* On a `kid` cache miss the cache is refreshed once before failing —
|
|
50
|
-
* this handles key rotation without requiring a restart.
|
|
51
|
-
*
|
|
52
|
-
* Refresh-window jitter avoids thundering-herd stampedes when multiple
|
|
53
|
-
* workers share the same process lifetime.
|
|
54
|
-
*/
|
|
55
33
|
declare class JwksCache {
|
|
56
34
|
private readonly config;
|
|
57
35
|
private readonly fetch;
|
|
58
36
|
private readonly clock;
|
|
59
37
|
private cache;
|
|
60
38
|
constructor(config: JwksCacheConfig, fetch: FetchFn, clock: Clock);
|
|
61
|
-
/** Returns the cached key for `kid`, fetching if needed. Refreshes once on kid miss. */
|
|
62
39
|
getKey(kid: string): Promise<JoseKey | null>;
|
|
63
40
|
private getValidEntry;
|
|
64
41
|
private refresh;
|
|
@@ -66,14 +43,6 @@ declare class JwksCache {
|
|
|
66
43
|
//#endregion
|
|
67
44
|
//#region src/ManagedJwtVerifier.d.ts
|
|
68
45
|
type VerifyResult = VerifiedManagedPrincipal | JwtVerificationFailure;
|
|
69
|
-
/**
|
|
70
|
-
* Validates CP-signed EdDSA JWT bearers.
|
|
71
|
-
*
|
|
72
|
-
* Checks: signature, `iss` matches expected, `aud` matches expected workspaceId,
|
|
73
|
-
* `exp`, `nbf`. Returns a `VerifiedManagedPrincipal` or a structured `JwtVerificationFailure`.
|
|
74
|
-
*
|
|
75
|
-
* Hono-independent — no web framework coupling.
|
|
76
|
-
*/
|
|
77
46
|
declare class ManagedJwtVerifier {
|
|
78
47
|
private readonly config;
|
|
79
48
|
private readonly jwksCache;
|
package/dist/index.js
CHANGED
|
@@ -3,15 +3,6 @@ import { decodeProtectedHeader, errors, importJWK, jwtVerify } from "jose";
|
|
|
3
3
|
//#region src/JwksCache.ts
|
|
4
4
|
const DEFAULT_TTL_MS = 900 * 1e3;
|
|
5
5
|
const DEFAULT_JITTER_MS = 60 * 1e3;
|
|
6
|
-
/**
|
|
7
|
-
* Fetches and caches JWKS from a configured URL.
|
|
8
|
-
*
|
|
9
|
-
* On a `kid` cache miss the cache is refreshed once before failing —
|
|
10
|
-
* this handles key rotation without requiring a restart.
|
|
11
|
-
*
|
|
12
|
-
* Refresh-window jitter avoids thundering-herd stampedes when multiple
|
|
13
|
-
* workers share the same process lifetime.
|
|
14
|
-
*/
|
|
15
6
|
var JwksCache = class {
|
|
16
7
|
cache = null;
|
|
17
8
|
constructor(config, fetch, clock) {
|
|
@@ -19,7 +10,6 @@ var JwksCache = class {
|
|
|
19
10
|
this.fetch = fetch;
|
|
20
11
|
this.clock = clock;
|
|
21
12
|
}
|
|
22
|
-
/** Returns the cached key for `kid`, fetching if needed. Refreshes once on kid miss. */
|
|
23
13
|
async getKey(kid) {
|
|
24
14
|
let entry = await this.getValidEntry();
|
|
25
15
|
const cached = entry.keys.get(kid);
|
|
@@ -56,14 +46,6 @@ var JwksCache = class {
|
|
|
56
46
|
|
|
57
47
|
//#endregion
|
|
58
48
|
//#region src/ManagedJwtVerifier.ts
|
|
59
|
-
/**
|
|
60
|
-
* Validates CP-signed EdDSA JWT bearers.
|
|
61
|
-
*
|
|
62
|
-
* Checks: signature, `iss` matches expected, `aud` matches expected workspaceId,
|
|
63
|
-
* `exp`, `nbf`. Returns a `VerifiedManagedPrincipal` or a structured `JwtVerificationFailure`.
|
|
64
|
-
*
|
|
65
|
-
* Hono-independent — no web framework coupling.
|
|
66
|
-
*/
|
|
67
49
|
var ManagedJwtVerifier = class {
|
|
68
50
|
constructor(config, jwksCache) {
|
|
69
51
|
this.config = config;
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":["config: JwksCacheConfig","fetch: FetchFn","clock: Clock","entry: CacheEntry","config: ManagedJwtVerifierConfig","jwksCache: JwksCache","kid: string | undefined","joseErrors"],"sources":["../src/JwksCache.ts","../src/ManagedJwtVerifier.ts"],"sourcesContent":["import { importJWK } from \"jose\";\nimport type { Clock, FetchFn, JwksCacheConfig } from \"./types\";\n\nconst DEFAULT_TTL_MS = 15 * 60 * 1000
|
|
1
|
+
{"version":3,"file":"index.js","names":["config: JwksCacheConfig","fetch: FetchFn","clock: Clock","entry: CacheEntry","config: ManagedJwtVerifierConfig","jwksCache: JwksCache","kid: string | undefined","joseErrors"],"sources":["../src/JwksCache.ts","../src/ManagedJwtVerifier.ts"],"sourcesContent":["import { importJWK } from \"jose\";\nimport type { Clock, FetchFn, JwksCacheConfig } from \"./types\";\n\nconst DEFAULT_TTL_MS = 15 * 60 * 1000;\nconst DEFAULT_JITTER_MS = 60 * 1000;\n\ntype JoseKey = Awaited<ReturnType<typeof importJWK>>;\n\ninterface CacheEntry {\n keys: Map<string, JoseKey>;\n expiresAt: number;\n}\n\ninterface JwkKey {\n kid?: string;\n kty?: string;\n crv?: string;\n x?: string;\n use?: string;\n alg?: string;\n}\n\ninterface JwksResponse {\n keys: JwkKey[];\n}\n\nexport class JwksCache {\n private cache: CacheEntry | null = null;\n\n constructor(\n private readonly config: JwksCacheConfig,\n private readonly fetch: FetchFn,\n private readonly clock: Clock,\n ) {}\n\n async getKey(kid: string): Promise<JoseKey | null> {\n let entry = await this.getValidEntry();\n const cached = entry.keys.get(kid);\n if (cached !== undefined) {\n return cached;\n }\n\n entry = await this.refresh();\n return entry.keys.get(kid) ?? null;\n }\n\n private async getValidEntry(): Promise<CacheEntry> {\n if (this.cache !== null && this.clock.now() < this.cache.expiresAt) {\n return this.cache;\n }\n return this.refresh();\n }\n\n private async refresh(): Promise<CacheEntry> {\n const response = await this.fetch(this.config.jwksUrl);\n if (!response.ok) {\n throw new Error(`JWKS fetch failed for ${this.config.jwksUrl}`);\n }\n const body = (await response.json()) as JwksResponse;\n const keys = new Map<string, JoseKey>();\n\n for (const jwk of body.keys ?? []) {\n if (!jwk.kid) continue;\n try {\n const key = await importJWK(jwk as Parameters<typeof importJWK>[0]);\n keys.set(jwk.kid, key);\n } catch {}\n }\n\n const ttl = this.config.ttlMs ?? DEFAULT_TTL_MS;\n const jitter = Math.random() * (this.config.jitterMs ?? DEFAULT_JITTER_MS);\n const entry: CacheEntry = {\n keys,\n expiresAt: this.clock.now() + ttl + jitter,\n };\n this.cache = entry;\n return entry;\n }\n}\n","import { decodeProtectedHeader, jwtVerify, errors as joseErrors } from \"jose\";\nimport type { JwtVerificationFailure, ManagedJwtVerifierConfig, VerifiedManagedPrincipal } from \"./types\";\nimport type { JwksCache } from \"./JwksCache\";\n\ntype VerifyResult = VerifiedManagedPrincipal | JwtVerificationFailure;\n\nexport class ManagedJwtVerifier {\n constructor(\n private readonly config: ManagedJwtVerifierConfig,\n private readonly jwksCache: JwksCache,\n ) {}\n\n async verify(token: string): Promise<VerifyResult> {\n let kid: string | undefined;\n try {\n const header = decodeProtectedHeader(token);\n kid = typeof header.kid === \"string\" ? header.kid : undefined;\n } catch {\n return { failure: \"malformed\", message: \"Unable to decode JWT header.\" };\n }\n\n if (!kid) {\n return { failure: \"missing-kid\", message: \"JWT header is missing the `kid` field.\" };\n }\n\n const key = await this.jwksCache.getKey(kid);\n if (key === null) {\n return { failure: \"unknown-kid\", message: `No key found for kid \"${kid}\" after JWKS refresh.` };\n }\n\n try {\n const { payload } = await jwtVerify(token, key, {\n issuer: this.config.expectedIssuer,\n audience: this.config.expectedAudience,\n algorithms: [\"EdDSA\"],\n });\n\n const sub = payload.sub;\n const aud = payload.aud;\n const workspaceId = Array.isArray(aud) ? aud[0] : aud;\n\n if (!sub || typeof sub !== \"string\") {\n return { failure: \"malformed\", message: \"JWT is missing the `sub` claim.\" };\n }\n if (!workspaceId || typeof workspaceId !== \"string\") {\n return { failure: \"wrong-aud\", message: \"JWT `aud` claim is absent or not a string.\" };\n }\n\n return { userId: sub, workspaceId };\n } catch (error) {\n if (error instanceof joseErrors.JWTExpired) {\n return { failure: \"expired\", message: \"JWT has expired.\" };\n }\n if (error instanceof joseErrors.JWTClaimValidationFailed) {\n const claim = error.claim;\n if (claim === \"iss\") {\n return { failure: \"wrong-iss\", message: \"JWT `iss` claim does not match expected issuer.\" };\n }\n if (claim === \"aud\") {\n return { failure: \"wrong-aud\", message: \"JWT `aud` claim does not match expected audience.\" };\n }\n if (claim === \"nbf\") {\n return { failure: \"not-yet-valid\", message: \"JWT is not yet valid (nbf check failed).\" };\n }\n return { failure: \"malformed\", message: `JWT claim validation failed: ${error.message}` };\n }\n if (error instanceof joseErrors.JWSSignatureVerificationFailed || error instanceof joseErrors.JWSInvalid) {\n return { failure: \"bad-signature\", message: \"JWT signature verification failed.\" };\n }\n return { failure: \"malformed\", message: \"JWT verification failed.\" };\n }\n }\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
package/src/JwksCache.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { importJWK } from "jose";
|
|
2
2
|
import type { Clock, FetchFn, JwksCacheConfig } from "./types";
|
|
3
3
|
|
|
4
|
-
const DEFAULT_TTL_MS = 15 * 60 * 1000;
|
|
5
|
-
const DEFAULT_JITTER_MS = 60 * 1000;
|
|
4
|
+
const DEFAULT_TTL_MS = 15 * 60 * 1000;
|
|
5
|
+
const DEFAULT_JITTER_MS = 60 * 1000;
|
|
6
6
|
|
|
7
7
|
type JoseKey = Awaited<ReturnType<typeof importJWK>>;
|
|
8
8
|
|
|
@@ -24,15 +24,6 @@ interface JwksResponse {
|
|
|
24
24
|
keys: JwkKey[];
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
/**
|
|
28
|
-
* Fetches and caches JWKS from a configured URL.
|
|
29
|
-
*
|
|
30
|
-
* On a `kid` cache miss the cache is refreshed once before failing —
|
|
31
|
-
* this handles key rotation without requiring a restart.
|
|
32
|
-
*
|
|
33
|
-
* Refresh-window jitter avoids thundering-herd stampedes when multiple
|
|
34
|
-
* workers share the same process lifetime.
|
|
35
|
-
*/
|
|
36
27
|
export class JwksCache {
|
|
37
28
|
private cache: CacheEntry | null = null;
|
|
38
29
|
|
|
@@ -42,7 +33,6 @@ export class JwksCache {
|
|
|
42
33
|
private readonly clock: Clock,
|
|
43
34
|
) {}
|
|
44
35
|
|
|
45
|
-
/** Returns the cached key for `kid`, fetching if needed. Refreshes once on kid miss. */
|
|
46
36
|
async getKey(kid: string): Promise<JoseKey | null> {
|
|
47
37
|
let entry = await this.getValidEntry();
|
|
48
38
|
const cached = entry.keys.get(kid);
|
|
@@ -50,7 +40,6 @@ export class JwksCache {
|
|
|
50
40
|
return cached;
|
|
51
41
|
}
|
|
52
42
|
|
|
53
|
-
// kid miss — refresh once to handle key rotation
|
|
54
43
|
entry = await this.refresh();
|
|
55
44
|
return entry.keys.get(kid) ?? null;
|
|
56
45
|
}
|
|
@@ -75,9 +64,7 @@ export class JwksCache {
|
|
|
75
64
|
try {
|
|
76
65
|
const key = await importJWK(jwk as Parameters<typeof importJWK>[0]);
|
|
77
66
|
keys.set(jwk.kid, key);
|
|
78
|
-
} catch {
|
|
79
|
-
// Skip malformed individual keys — don't fail the whole cache refresh
|
|
80
|
-
}
|
|
67
|
+
} catch {}
|
|
81
68
|
}
|
|
82
69
|
|
|
83
70
|
const ttl = this.config.ttlMs ?? DEFAULT_TTL_MS;
|
|
@@ -4,14 +4,6 @@ import type { JwksCache } from "./JwksCache";
|
|
|
4
4
|
|
|
5
5
|
type VerifyResult = VerifiedManagedPrincipal | JwtVerificationFailure;
|
|
6
6
|
|
|
7
|
-
/**
|
|
8
|
-
* Validates CP-signed EdDSA JWT bearers.
|
|
9
|
-
*
|
|
10
|
-
* Checks: signature, `iss` matches expected, `aud` matches expected workspaceId,
|
|
11
|
-
* `exp`, `nbf`. Returns a `VerifiedManagedPrincipal` or a structured `JwtVerificationFailure`.
|
|
12
|
-
*
|
|
13
|
-
* Hono-independent — no web framework coupling.
|
|
14
|
-
*/
|
|
15
7
|
export class ManagedJwtVerifier {
|
|
16
8
|
constructor(
|
|
17
9
|
private readonly config: ManagedJwtVerifierConfig,
|
|
@@ -19,7 +11,6 @@ export class ManagedJwtVerifier {
|
|
|
19
11
|
) {}
|
|
20
12
|
|
|
21
13
|
async verify(token: string): Promise<VerifyResult> {
|
|
22
|
-
// Decode header to get kid before verifying signature
|
|
23
14
|
let kid: string | undefined;
|
|
24
15
|
try {
|
|
25
16
|
const header = decodeProtectedHeader(token);
|
package/src/types.ts
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A successfully verified CP-signed JWT principal.
|
|
3
|
-
* `userId` maps to the JWT `sub` claim; `workspaceId` maps to `aud`.
|
|
4
|
-
*/
|
|
5
1
|
export interface VerifiedManagedPrincipal {
|
|
6
2
|
readonly userId: string;
|
|
7
3
|
readonly workspaceId: string;
|
|
@@ -17,33 +13,25 @@ export type JwtVerificationFailureReason =
|
|
|
17
13
|
| "not-yet-valid"
|
|
18
14
|
| "malformed";
|
|
19
15
|
|
|
20
|
-
/** Structured failure returned instead of throwing, so callers can map to HTTP status codes cleanly. */
|
|
21
16
|
export interface JwtVerificationFailure {
|
|
22
17
|
readonly failure: JwtVerificationFailureReason;
|
|
23
18
|
readonly message: string;
|
|
24
19
|
}
|
|
25
20
|
|
|
26
21
|
export interface JwksCacheConfig {
|
|
27
|
-
/** URL to the JWKS endpoint (e.g. https://cp.example.com/.well-known/jwks.json). */
|
|
28
22
|
readonly jwksUrl: string;
|
|
29
|
-
/** TTL in milliseconds before the cache is considered stale. Defaults to 15 minutes. */
|
|
30
23
|
readonly ttlMs?: number;
|
|
31
|
-
/** Jitter window in milliseconds applied to TTL to avoid stampedes. Defaults to 60 seconds. */
|
|
32
24
|
readonly jitterMs?: number;
|
|
33
25
|
}
|
|
34
26
|
|
|
35
27
|
export interface ManagedJwtVerifierConfig {
|
|
36
|
-
/** Expected value of the JWT `iss` claim — must exactly match. */
|
|
37
28
|
readonly expectedIssuer: string;
|
|
38
|
-
/** Expected `aud` claim — must exactly match the workspace ID. */
|
|
39
29
|
readonly expectedAudience: string;
|
|
40
30
|
readonly jwksCache: JwksCacheConfig;
|
|
41
31
|
}
|
|
42
32
|
|
|
43
|
-
/** Minimal fetch-compatible function type for injection / testing. */
|
|
44
33
|
export type FetchFn = (url: string) => Promise<{ ok: boolean; json(): Promise<unknown> }>;
|
|
45
34
|
|
|
46
|
-
/** Minimal clock interface for injection / testing. */
|
|
47
35
|
export interface Clock {
|
|
48
36
|
now(): number;
|
|
49
37
|
}
|
package/test/JwksCache.test.ts
CHANGED
|
@@ -62,17 +62,16 @@ describe("JwksCache", () => {
|
|
|
62
62
|
(clock as unknown as { _now: number })._now = ((clock as unknown as { _now: number })._now ?? 0) + ms;
|
|
63
63
|
};
|
|
64
64
|
|
|
65
|
-
// Use a simple advancing clock
|
|
66
65
|
let now = 0;
|
|
67
66
|
const advancingClock: Clock = { now: () => now };
|
|
68
67
|
|
|
69
68
|
const cache = new JwksCache({ jwksUrl: "/jwks", ttlMs: 60_000, jitterMs: 0 }, fetch, advancingClock);
|
|
70
69
|
|
|
71
70
|
await cache.getKey("key-1");
|
|
72
|
-
now += 30_000;
|
|
71
|
+
now += 30_000;
|
|
73
72
|
await cache.getKey("key-1");
|
|
74
73
|
|
|
75
|
-
expect(callCount).toBe(1);
|
|
74
|
+
expect(callCount).toBe(1);
|
|
76
75
|
});
|
|
77
76
|
|
|
78
77
|
it("re-fetches after TTL expires", async () => {
|
|
@@ -88,7 +87,7 @@ describe("JwksCache", () => {
|
|
|
88
87
|
const cache = new JwksCache({ jwksUrl: "/jwks", ttlMs: 60_000, jitterMs: 0 }, fetch, clock);
|
|
89
88
|
|
|
90
89
|
await cache.getKey("key-1");
|
|
91
|
-
now += 61_000;
|
|
90
|
+
now += 61_000;
|
|
92
91
|
await cache.getKey("key-1");
|
|
93
92
|
|
|
94
93
|
expect(callCount).toBe(2);
|
|
@@ -96,7 +95,6 @@ describe("JwksCache", () => {
|
|
|
96
95
|
|
|
97
96
|
it("refreshes once on kid miss and returns the new key", async () => {
|
|
98
97
|
let callCount = 0;
|
|
99
|
-
// First fetch has only key-1; second has key-1 and key-2
|
|
100
98
|
const fetch: FetchFn = async () => {
|
|
101
99
|
callCount++;
|
|
102
100
|
const keys = callCount === 1 ? [edKey1] : [edKey1, edKey2];
|
|
@@ -105,11 +103,9 @@ describe("JwksCache", () => {
|
|
|
105
103
|
const clock: Clock = { now: () => 0 };
|
|
106
104
|
const cache = new JwksCache({ jwksUrl: "/jwks", ttlMs: 60_000, jitterMs: 0 }, fetch, clock);
|
|
107
105
|
|
|
108
|
-
// Populate cache with just key-1
|
|
109
106
|
await cache.getKey("key-1");
|
|
110
107
|
expect(callCount).toBe(1);
|
|
111
108
|
|
|
112
|
-
// Ask for key-2 — cache miss triggers one refresh
|
|
113
109
|
const key = await cache.getKey("key-2");
|
|
114
110
|
expect(key).not.toBeNull();
|
|
115
111
|
expect(callCount).toBe(2);
|
|
@@ -43,10 +43,6 @@ function makeFrozenClock(): Clock {
|
|
|
43
43
|
return { now: () => 0 };
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
// Deterministic timestamps: use fixed calendar dates rather than Date.now().
|
|
47
|
-
// exp far in the future (2099) — always valid by the real clock.
|
|
48
|
-
// Expired exp: a date in the past (2000).
|
|
49
|
-
// Future nbf: a date in the distant future (2099).
|
|
50
46
|
const EXP_FUTURE_UNIX = Math.floor(new Date("2099-12-31T00:00:00Z").getTime() / 1000);
|
|
51
47
|
const EXP_PAST_UNIX = Math.floor(new Date("2000-01-01T00:00:00Z").getTime() / 1000);
|
|
52
48
|
const NBF_PAST_UNIX = Math.floor(new Date("2000-01-01T00:00:00Z").getTime() / 1000);
|
|
@@ -111,7 +107,6 @@ describe("ManagedJwtVerifier", () => {
|
|
|
111
107
|
const verifier = makeVerifier([kp1.jwk]);
|
|
112
108
|
const token = await makeToken(kp1.privateKey, "key-1");
|
|
113
109
|
const parts = token.split(".");
|
|
114
|
-
// Tamper the payload
|
|
115
110
|
const tampered = `${parts[0]}.${parts[1]}X.${parts[2]}`;
|
|
116
111
|
const result = await verifier.verify(tampered);
|
|
117
112
|
expect("failure" in result).toBe(true);
|
|
@@ -162,9 +157,8 @@ describe("ManagedJwtVerifier", () => {
|
|
|
162
157
|
|
|
163
158
|
it("fails with missing-kid when header has no kid field", async () => {
|
|
164
159
|
const verifier = makeVerifier([kp1.jwk]);
|
|
165
|
-
// Build a token without kid in header using deterministic timestamps
|
|
166
160
|
const token = await new SignJWT({ sub: "user-1" })
|
|
167
|
-
.setProtectedHeader({ alg: "EdDSA" })
|
|
161
|
+
.setProtectedHeader({ alg: "EdDSA" })
|
|
168
162
|
.setIssuer(ISSUER)
|
|
169
163
|
.setAudience(WORKSPACE_ID)
|
|
170
164
|
.setExpirationTime(EXP_FUTURE_UNIX)
|
|
@@ -179,7 +173,6 @@ describe("ManagedJwtVerifier", () => {
|
|
|
179
173
|
|
|
180
174
|
it("fails with unknown-kid after refresh when kid is not in JWKS", async () => {
|
|
181
175
|
const verifier = makeVerifier([kp1.jwk]);
|
|
182
|
-
// Token signed with key-2 but JWKS only has key-1 (even after refresh)
|
|
183
176
|
const token = await makeToken(kp2.privateKey, "key-2");
|
|
184
177
|
const result = await verifier.verify(token);
|
|
185
178
|
expect("failure" in result).toBe(true);
|
|
@@ -189,7 +182,6 @@ describe("ManagedJwtVerifier", () => {
|
|
|
189
182
|
});
|
|
190
183
|
|
|
191
184
|
it("accepts a new-key token after CP rotates keys (kid appears on refresh)", async () => {
|
|
192
|
-
// First JWKS response has only key-1; second response (on kid miss) adds key-2
|
|
193
185
|
let callCount = 0;
|
|
194
186
|
const fetch: FetchFn = async () => {
|
|
195
187
|
callCount++;
|
|
@@ -202,10 +194,8 @@ describe("ManagedJwtVerifier", () => {
|
|
|
202
194
|
cache,
|
|
203
195
|
);
|
|
204
196
|
|
|
205
|
-
// Warm the cache with key-1 only
|
|
206
197
|
await cache.getKey("key-1");
|
|
207
198
|
|
|
208
|
-
// Now verify a token using key-2 — should trigger refresh and succeed
|
|
209
199
|
const token = await makeToken(kp2.privateKey, "key-2");
|
|
210
200
|
const result = await verifier.verify(token);
|
|
211
201
|
expect("failure" in result).toBe(false);
|
package/.turbo/turbo-lint.log
DELETED