@codemation/managed-auth 0.1.0
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 +19 -0
- package/.turbo/turbo-lint.log +4 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/CHANGELOG.md +11 -0
- package/LICENSE +37 -0
- package/dist/index.cjs +176 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +85 -0
- package/dist/index.d.ts +85 -0
- package/dist/index.js +151 -0
- package/dist/index.js.map +1 -0
- package/package.json +48 -0
- package/src/JwksCache.ts +92 -0
- package/src/ManagedJwtVerifier.ts +82 -0
- package/src/index.ts +11 -0
- package/src/types.ts +49 -0
- package/test/JwksCache.test.ts +137 -0
- package/test/ManagedJwtVerifier.test.ts +225 -0
- package/tsconfig.json +9 -0
- package/tsdown.config.ts +11 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
|
|
2
|
+
> @codemation/managed-auth@0.1.0 build C:\Users\ChrisBlokland\projects\codemation\framework\packages\managed-auth
|
|
3
|
+
> tsdown
|
|
4
|
+
|
|
5
|
+
ℹ tsdown v0.15.12 powered by rolldown v1.0.0-beta.45
|
|
6
|
+
ℹ Using tsdown config: C:\Users\ChrisBlokland\projects\codemation\framework\packages\managed-auth\tsdown.config.ts
|
|
7
|
+
ℹ entry: src\index.ts
|
|
8
|
+
ℹ tsconfig: tsconfig.json
|
|
9
|
+
ℹ Build start
|
|
10
|
+
ℹ [CJS] dist\index.cjs 5.49 kB │ gzip: 2.15 kB
|
|
11
|
+
ℹ [CJS] dist\index.cjs.map 8.47 kB │ gzip: 2.96 kB
|
|
12
|
+
ℹ [CJS] 2 files, total: 13.96 kB
|
|
13
|
+
ℹ [ESM] dist\index.js 4.45 kB │ gzip: 1.72 kB
|
|
14
|
+
ℹ [ESM] dist\index.js.map 8.46 kB │ gzip: 2.96 kB
|
|
15
|
+
ℹ [ESM] dist\index.d.ts 3.27 kB │ gzip: 1.34 kB
|
|
16
|
+
ℹ [ESM] 3 files, total: 16.17 kB
|
|
17
|
+
ℹ [CJS] dist\index.d.cts 3.27 kB │ gzip: 1.34 kB
|
|
18
|
+
ℹ [CJS] 1 files, total: 3.27 kB
|
|
19
|
+
✔ Build complete in 3258ms
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# @codemation/managed-auth
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 8285ec0: Add `@codemation/managed-auth` package and `auth.kind: "managed"` support in `@codemation/host`.
|
|
8
|
+
|
|
9
|
+
`@codemation/managed-auth` is a new publishable package containing the JWKS cache and EdDSA JWT verifier used by managed-mode workspaces. It has no dependency on `@codemation/host` or `@codemation/core` and is intentionally self-contained so the closed-source workspace-mcp can install it from the public registry.
|
|
10
|
+
|
|
11
|
+
`@codemation/host` gains `auth.kind: "managed"` — a new auth mode where Better Auth is not mounted, the workspace verifies CP-signed JWT bearers, and a single-origin CORS allowlist is enforced via `CP_WEB_ORIGIN`. Boot-time guard ensures all required env vars are present before startup.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Codemation Pre-Stable License
|
|
2
|
+
|
|
3
|
+
Copyright (c) Made Relevant B.V. All rights reserved.
|
|
4
|
+
|
|
5
|
+
1. Definitions
|
|
6
|
+
|
|
7
|
+
"Software" means the Codemation source code, documentation, and artifacts in this repository and any published npm packages in the Codemation monorepo.
|
|
8
|
+
|
|
9
|
+
"Stable Version" means the first published release of the package `@codemation/core` on the public npm registry with version 1.0.0 or higher.
|
|
10
|
+
|
|
11
|
+
2. Permitted use (before Stable Version)
|
|
12
|
+
|
|
13
|
+
Until a Stable Version exists, you may use, copy, modify, and distribute the Software only for non-commercial purposes, including personal learning, research, evaluation, and internal use within your organization that does not charge third parties for access to the Software or a product or service whose primary value is the Software.
|
|
14
|
+
|
|
15
|
+
3. Restrictions (before Stable Version)
|
|
16
|
+
|
|
17
|
+
Until a Stable Version exists, you must not:
|
|
18
|
+
|
|
19
|
+
a) Sell, rent, lease, or sublicense the Software or a derivative work for a fee;
|
|
20
|
+
|
|
21
|
+
b) Offer the Software or a derivative work as part of a paid product or service (including hosting, support, or consulting) where the Software is a material part of the offering;
|
|
22
|
+
|
|
23
|
+
c) Use the Software or a derivative work primarily to generate revenue or commercial advantage for you or others.
|
|
24
|
+
|
|
25
|
+
These restrictions apply to all versions published before a Stable Version, even if a later Stable Version is released under different terms.
|
|
26
|
+
|
|
27
|
+
4. After Stable Version
|
|
28
|
+
|
|
29
|
+
The maintainers may publish a Stable Version under different license terms. If they do, those terms apply only to that Stable Version and subsequent releases they designate; they do not automatically apply to earlier pre-stable versions.
|
|
30
|
+
|
|
31
|
+
5. No warranty
|
|
32
|
+
|
|
33
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
34
|
+
|
|
35
|
+
6. Third-party components
|
|
36
|
+
|
|
37
|
+
The Software may include third-party components under their own licenses. Those licenses govern those components.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
//#region rolldown:runtime
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __copyProps = (to, from, except, desc) => {
|
|
9
|
+
if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
10
|
+
key = keys[i];
|
|
11
|
+
if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
|
|
12
|
+
get: ((k) => from[k]).bind(null, key),
|
|
13
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
|
|
19
|
+
value: mod,
|
|
20
|
+
enumerable: true
|
|
21
|
+
}) : target, mod));
|
|
22
|
+
|
|
23
|
+
//#endregion
|
|
24
|
+
let jose = require("jose");
|
|
25
|
+
jose = __toESM(jose);
|
|
26
|
+
|
|
27
|
+
//#region src/JwksCache.ts
|
|
28
|
+
const DEFAULT_TTL_MS = 900 * 1e3;
|
|
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
|
+
var JwksCache = class {
|
|
40
|
+
cache = null;
|
|
41
|
+
constructor(config, fetch, clock) {
|
|
42
|
+
this.config = config;
|
|
43
|
+
this.fetch = fetch;
|
|
44
|
+
this.clock = clock;
|
|
45
|
+
}
|
|
46
|
+
/** Returns the cached key for `kid`, fetching if needed. Refreshes once on kid miss. */
|
|
47
|
+
async getKey(kid) {
|
|
48
|
+
let entry = await this.getValidEntry();
|
|
49
|
+
const cached = entry.keys.get(kid);
|
|
50
|
+
if (cached !== void 0) return cached;
|
|
51
|
+
entry = await this.refresh();
|
|
52
|
+
return entry.keys.get(kid) ?? null;
|
|
53
|
+
}
|
|
54
|
+
async getValidEntry() {
|
|
55
|
+
if (this.cache !== null && this.clock.now() < this.cache.expiresAt) return this.cache;
|
|
56
|
+
return this.refresh();
|
|
57
|
+
}
|
|
58
|
+
async refresh() {
|
|
59
|
+
const response = await this.fetch(this.config.jwksUrl);
|
|
60
|
+
if (!response.ok) throw new Error(`JWKS fetch failed for ${this.config.jwksUrl}`);
|
|
61
|
+
const body = await response.json();
|
|
62
|
+
const keys = /* @__PURE__ */ new Map();
|
|
63
|
+
for (const jwk of body.keys ?? []) {
|
|
64
|
+
if (!jwk.kid) continue;
|
|
65
|
+
try {
|
|
66
|
+
const key = await (0, jose.importJWK)(jwk);
|
|
67
|
+
keys.set(jwk.kid, key);
|
|
68
|
+
} catch {}
|
|
69
|
+
}
|
|
70
|
+
const ttl = this.config.ttlMs ?? DEFAULT_TTL_MS;
|
|
71
|
+
const jitter = Math.random() * (this.config.jitterMs ?? DEFAULT_JITTER_MS);
|
|
72
|
+
const entry = {
|
|
73
|
+
keys,
|
|
74
|
+
expiresAt: this.clock.now() + ttl + jitter
|
|
75
|
+
};
|
|
76
|
+
this.cache = entry;
|
|
77
|
+
return entry;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
//#endregion
|
|
82
|
+
//#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
|
+
var ManagedJwtVerifier = class {
|
|
92
|
+
constructor(config, jwksCache) {
|
|
93
|
+
this.config = config;
|
|
94
|
+
this.jwksCache = jwksCache;
|
|
95
|
+
}
|
|
96
|
+
async verify(token) {
|
|
97
|
+
let kid;
|
|
98
|
+
try {
|
|
99
|
+
const header = (0, jose.decodeProtectedHeader)(token);
|
|
100
|
+
kid = typeof header.kid === "string" ? header.kid : void 0;
|
|
101
|
+
} catch {
|
|
102
|
+
return {
|
|
103
|
+
failure: "malformed",
|
|
104
|
+
message: "Unable to decode JWT header."
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if (!kid) return {
|
|
108
|
+
failure: "missing-kid",
|
|
109
|
+
message: "JWT header is missing the `kid` field."
|
|
110
|
+
};
|
|
111
|
+
const key = await this.jwksCache.getKey(kid);
|
|
112
|
+
if (key === null) return {
|
|
113
|
+
failure: "unknown-kid",
|
|
114
|
+
message: `No key found for kid "${kid}" after JWKS refresh.`
|
|
115
|
+
};
|
|
116
|
+
try {
|
|
117
|
+
const { payload } = await (0, jose.jwtVerify)(token, key, {
|
|
118
|
+
issuer: this.config.expectedIssuer,
|
|
119
|
+
audience: this.config.expectedAudience,
|
|
120
|
+
algorithms: ["EdDSA"]
|
|
121
|
+
});
|
|
122
|
+
const sub = payload.sub;
|
|
123
|
+
const aud = payload.aud;
|
|
124
|
+
const workspaceId = Array.isArray(aud) ? aud[0] : aud;
|
|
125
|
+
if (!sub || typeof sub !== "string") return {
|
|
126
|
+
failure: "malformed",
|
|
127
|
+
message: "JWT is missing the `sub` claim."
|
|
128
|
+
};
|
|
129
|
+
if (!workspaceId || typeof workspaceId !== "string") return {
|
|
130
|
+
failure: "wrong-aud",
|
|
131
|
+
message: "JWT `aud` claim is absent or not a string."
|
|
132
|
+
};
|
|
133
|
+
return {
|
|
134
|
+
userId: sub,
|
|
135
|
+
workspaceId
|
|
136
|
+
};
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (error instanceof jose.errors.JWTExpired) return {
|
|
139
|
+
failure: "expired",
|
|
140
|
+
message: "JWT has expired."
|
|
141
|
+
};
|
|
142
|
+
if (error instanceof jose.errors.JWTClaimValidationFailed) {
|
|
143
|
+
const claim = error.claim;
|
|
144
|
+
if (claim === "iss") return {
|
|
145
|
+
failure: "wrong-iss",
|
|
146
|
+
message: "JWT `iss` claim does not match expected issuer."
|
|
147
|
+
};
|
|
148
|
+
if (claim === "aud") return {
|
|
149
|
+
failure: "wrong-aud",
|
|
150
|
+
message: "JWT `aud` claim does not match expected audience."
|
|
151
|
+
};
|
|
152
|
+
if (claim === "nbf") return {
|
|
153
|
+
failure: "not-yet-valid",
|
|
154
|
+
message: "JWT is not yet valid (nbf check failed)."
|
|
155
|
+
};
|
|
156
|
+
return {
|
|
157
|
+
failure: "malformed",
|
|
158
|
+
message: `JWT claim validation failed: ${error.message}`
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
if (error instanceof jose.errors.JWSSignatureVerificationFailed || error instanceof jose.errors.JWSInvalid) return {
|
|
162
|
+
failure: "bad-signature",
|
|
163
|
+
message: "JWT signature verification failed."
|
|
164
|
+
};
|
|
165
|
+
return {
|
|
166
|
+
failure: "malformed",
|
|
167
|
+
message: "JWT verification failed."
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
//#endregion
|
|
174
|
+
exports.JwksCache = JwksCache;
|
|
175
|
+
exports.ManagedJwtVerifier = ManagedJwtVerifier;
|
|
176
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +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; // 15 minutes\nconst DEFAULT_JITTER_MS = 60 * 1000; // 60 seconds\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\n/**\n * Fetches and caches JWKS from a configured URL.\n *\n * On a `kid` cache miss the cache is refreshed once before failing —\n * this handles key rotation without requiring a restart.\n *\n * Refresh-window jitter avoids thundering-herd stampedes when multiple\n * workers share the same process lifetime.\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 /** Returns the cached key for `kid`, fetching if needed. Refreshes once on kid miss. */\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 // kid miss — refresh once to handle key rotation\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 // Skip malformed individual keys — don't fail the whole cache refresh\n }\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\n/**\n * Validates CP-signed EdDSA JWT bearers.\n *\n * Checks: signature, `iss` matches expected, `aud` matches expected workspaceId,\n * `exp`, `nbf`. Returns a `VerifiedManagedPrincipal` or a structured `JwtVerificationFailure`.\n *\n * Hono-independent — no web framework coupling.\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 // Decode header to get kid before verifying signature\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
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { importJWK } from "jose";
|
|
2
|
+
|
|
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
|
+
interface VerifiedManagedPrincipal {
|
|
10
|
+
readonly userId: string;
|
|
11
|
+
readonly workspaceId: string;
|
|
12
|
+
}
|
|
13
|
+
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
|
+
interface JwtVerificationFailure {
|
|
16
|
+
readonly failure: JwtVerificationFailureReason;
|
|
17
|
+
readonly message: string;
|
|
18
|
+
}
|
|
19
|
+
interface JwksCacheConfig {
|
|
20
|
+
/** URL to the JWKS endpoint (e.g. https://cp.example.com/.well-known/jwks.json). */
|
|
21
|
+
readonly jwksUrl: string;
|
|
22
|
+
/** TTL in milliseconds before the cache is considered stale. Defaults to 15 minutes. */
|
|
23
|
+
readonly ttlMs?: number;
|
|
24
|
+
/** Jitter window in milliseconds applied to TTL to avoid stampedes. Defaults to 60 seconds. */
|
|
25
|
+
readonly jitterMs?: number;
|
|
26
|
+
}
|
|
27
|
+
interface ManagedJwtVerifierConfig {
|
|
28
|
+
/** Expected value of the JWT `iss` claim — must exactly match. */
|
|
29
|
+
readonly expectedIssuer: string;
|
|
30
|
+
/** Expected `aud` claim — must exactly match the workspace ID. */
|
|
31
|
+
readonly expectedAudience: string;
|
|
32
|
+
readonly jwksCache: JwksCacheConfig;
|
|
33
|
+
}
|
|
34
|
+
/** Minimal fetch-compatible function type for injection / testing. */
|
|
35
|
+
type FetchFn = (url: string) => Promise<{
|
|
36
|
+
ok: boolean;
|
|
37
|
+
json(): Promise<unknown>;
|
|
38
|
+
}>;
|
|
39
|
+
/** Minimal clock interface for injection / testing. */
|
|
40
|
+
interface Clock {
|
|
41
|
+
now(): number;
|
|
42
|
+
}
|
|
43
|
+
//#endregion
|
|
44
|
+
//#region src/JwksCache.d.ts
|
|
45
|
+
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
|
+
declare class JwksCache {
|
|
56
|
+
private readonly config;
|
|
57
|
+
private readonly fetch;
|
|
58
|
+
private readonly clock;
|
|
59
|
+
private cache;
|
|
60
|
+
constructor(config: JwksCacheConfig, fetch: FetchFn, clock: Clock);
|
|
61
|
+
/** Returns the cached key for `kid`, fetching if needed. Refreshes once on kid miss. */
|
|
62
|
+
getKey(kid: string): Promise<JoseKey | null>;
|
|
63
|
+
private getValidEntry;
|
|
64
|
+
private refresh;
|
|
65
|
+
}
|
|
66
|
+
//#endregion
|
|
67
|
+
//#region src/ManagedJwtVerifier.d.ts
|
|
68
|
+
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
|
+
declare class ManagedJwtVerifier {
|
|
78
|
+
private readonly config;
|
|
79
|
+
private readonly jwksCache;
|
|
80
|
+
constructor(config: ManagedJwtVerifierConfig, jwksCache: JwksCache);
|
|
81
|
+
verify(token: string): Promise<VerifyResult>;
|
|
82
|
+
}
|
|
83
|
+
//#endregion
|
|
84
|
+
export { type Clock, type FetchFn, JwksCache, type JwksCacheConfig, type JwtVerificationFailure, type JwtVerificationFailureReason, ManagedJwtVerifier, type ManagedJwtVerifierConfig, type VerifiedManagedPrincipal };
|
|
85
|
+
//# sourceMappingURL=index.d.cts.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { importJWK } from "jose";
|
|
2
|
+
|
|
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
|
+
interface VerifiedManagedPrincipal {
|
|
10
|
+
readonly userId: string;
|
|
11
|
+
readonly workspaceId: string;
|
|
12
|
+
}
|
|
13
|
+
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
|
+
interface JwtVerificationFailure {
|
|
16
|
+
readonly failure: JwtVerificationFailureReason;
|
|
17
|
+
readonly message: string;
|
|
18
|
+
}
|
|
19
|
+
interface JwksCacheConfig {
|
|
20
|
+
/** URL to the JWKS endpoint (e.g. https://cp.example.com/.well-known/jwks.json). */
|
|
21
|
+
readonly jwksUrl: string;
|
|
22
|
+
/** TTL in milliseconds before the cache is considered stale. Defaults to 15 minutes. */
|
|
23
|
+
readonly ttlMs?: number;
|
|
24
|
+
/** Jitter window in milliseconds applied to TTL to avoid stampedes. Defaults to 60 seconds. */
|
|
25
|
+
readonly jitterMs?: number;
|
|
26
|
+
}
|
|
27
|
+
interface ManagedJwtVerifierConfig {
|
|
28
|
+
/** Expected value of the JWT `iss` claim — must exactly match. */
|
|
29
|
+
readonly expectedIssuer: string;
|
|
30
|
+
/** Expected `aud` claim — must exactly match the workspace ID. */
|
|
31
|
+
readonly expectedAudience: string;
|
|
32
|
+
readonly jwksCache: JwksCacheConfig;
|
|
33
|
+
}
|
|
34
|
+
/** Minimal fetch-compatible function type for injection / testing. */
|
|
35
|
+
type FetchFn = (url: string) => Promise<{
|
|
36
|
+
ok: boolean;
|
|
37
|
+
json(): Promise<unknown>;
|
|
38
|
+
}>;
|
|
39
|
+
/** Minimal clock interface for injection / testing. */
|
|
40
|
+
interface Clock {
|
|
41
|
+
now(): number;
|
|
42
|
+
}
|
|
43
|
+
//#endregion
|
|
44
|
+
//#region src/JwksCache.d.ts
|
|
45
|
+
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
|
+
declare class JwksCache {
|
|
56
|
+
private readonly config;
|
|
57
|
+
private readonly fetch;
|
|
58
|
+
private readonly clock;
|
|
59
|
+
private cache;
|
|
60
|
+
constructor(config: JwksCacheConfig, fetch: FetchFn, clock: Clock);
|
|
61
|
+
/** Returns the cached key for `kid`, fetching if needed. Refreshes once on kid miss. */
|
|
62
|
+
getKey(kid: string): Promise<JoseKey | null>;
|
|
63
|
+
private getValidEntry;
|
|
64
|
+
private refresh;
|
|
65
|
+
}
|
|
66
|
+
//#endregion
|
|
67
|
+
//#region src/ManagedJwtVerifier.d.ts
|
|
68
|
+
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
|
+
declare class ManagedJwtVerifier {
|
|
78
|
+
private readonly config;
|
|
79
|
+
private readonly jwksCache;
|
|
80
|
+
constructor(config: ManagedJwtVerifierConfig, jwksCache: JwksCache);
|
|
81
|
+
verify(token: string): Promise<VerifyResult>;
|
|
82
|
+
}
|
|
83
|
+
//#endregion
|
|
84
|
+
export { type Clock, type FetchFn, JwksCache, type JwksCacheConfig, type JwtVerificationFailure, type JwtVerificationFailureReason, ManagedJwtVerifier, type ManagedJwtVerifierConfig, type VerifiedManagedPrincipal };
|
|
85
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { decodeProtectedHeader, errors, importJWK, jwtVerify } from "jose";
|
|
2
|
+
|
|
3
|
+
//#region src/JwksCache.ts
|
|
4
|
+
const DEFAULT_TTL_MS = 900 * 1e3;
|
|
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
|
+
var JwksCache = class {
|
|
16
|
+
cache = null;
|
|
17
|
+
constructor(config, fetch, clock) {
|
|
18
|
+
this.config = config;
|
|
19
|
+
this.fetch = fetch;
|
|
20
|
+
this.clock = clock;
|
|
21
|
+
}
|
|
22
|
+
/** Returns the cached key for `kid`, fetching if needed. Refreshes once on kid miss. */
|
|
23
|
+
async getKey(kid) {
|
|
24
|
+
let entry = await this.getValidEntry();
|
|
25
|
+
const cached = entry.keys.get(kid);
|
|
26
|
+
if (cached !== void 0) return cached;
|
|
27
|
+
entry = await this.refresh();
|
|
28
|
+
return entry.keys.get(kid) ?? null;
|
|
29
|
+
}
|
|
30
|
+
async getValidEntry() {
|
|
31
|
+
if (this.cache !== null && this.clock.now() < this.cache.expiresAt) return this.cache;
|
|
32
|
+
return this.refresh();
|
|
33
|
+
}
|
|
34
|
+
async refresh() {
|
|
35
|
+
const response = await this.fetch(this.config.jwksUrl);
|
|
36
|
+
if (!response.ok) throw new Error(`JWKS fetch failed for ${this.config.jwksUrl}`);
|
|
37
|
+
const body = await response.json();
|
|
38
|
+
const keys = /* @__PURE__ */ new Map();
|
|
39
|
+
for (const jwk of body.keys ?? []) {
|
|
40
|
+
if (!jwk.kid) continue;
|
|
41
|
+
try {
|
|
42
|
+
const key = await importJWK(jwk);
|
|
43
|
+
keys.set(jwk.kid, key);
|
|
44
|
+
} catch {}
|
|
45
|
+
}
|
|
46
|
+
const ttl = this.config.ttlMs ?? DEFAULT_TTL_MS;
|
|
47
|
+
const jitter = Math.random() * (this.config.jitterMs ?? DEFAULT_JITTER_MS);
|
|
48
|
+
const entry = {
|
|
49
|
+
keys,
|
|
50
|
+
expiresAt: this.clock.now() + ttl + jitter
|
|
51
|
+
};
|
|
52
|
+
this.cache = entry;
|
|
53
|
+
return entry;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
//#endregion
|
|
58
|
+
//#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
|
+
var ManagedJwtVerifier = class {
|
|
68
|
+
constructor(config, jwksCache) {
|
|
69
|
+
this.config = config;
|
|
70
|
+
this.jwksCache = jwksCache;
|
|
71
|
+
}
|
|
72
|
+
async verify(token) {
|
|
73
|
+
let kid;
|
|
74
|
+
try {
|
|
75
|
+
const header = decodeProtectedHeader(token);
|
|
76
|
+
kid = typeof header.kid === "string" ? header.kid : void 0;
|
|
77
|
+
} catch {
|
|
78
|
+
return {
|
|
79
|
+
failure: "malformed",
|
|
80
|
+
message: "Unable to decode JWT header."
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (!kid) return {
|
|
84
|
+
failure: "missing-kid",
|
|
85
|
+
message: "JWT header is missing the `kid` field."
|
|
86
|
+
};
|
|
87
|
+
const key = await this.jwksCache.getKey(kid);
|
|
88
|
+
if (key === null) return {
|
|
89
|
+
failure: "unknown-kid",
|
|
90
|
+
message: `No key found for kid "${kid}" after JWKS refresh.`
|
|
91
|
+
};
|
|
92
|
+
try {
|
|
93
|
+
const { payload } = await jwtVerify(token, key, {
|
|
94
|
+
issuer: this.config.expectedIssuer,
|
|
95
|
+
audience: this.config.expectedAudience,
|
|
96
|
+
algorithms: ["EdDSA"]
|
|
97
|
+
});
|
|
98
|
+
const sub = payload.sub;
|
|
99
|
+
const aud = payload.aud;
|
|
100
|
+
const workspaceId = Array.isArray(aud) ? aud[0] : aud;
|
|
101
|
+
if (!sub || typeof sub !== "string") return {
|
|
102
|
+
failure: "malformed",
|
|
103
|
+
message: "JWT is missing the `sub` claim."
|
|
104
|
+
};
|
|
105
|
+
if (!workspaceId || typeof workspaceId !== "string") return {
|
|
106
|
+
failure: "wrong-aud",
|
|
107
|
+
message: "JWT `aud` claim is absent or not a string."
|
|
108
|
+
};
|
|
109
|
+
return {
|
|
110
|
+
userId: sub,
|
|
111
|
+
workspaceId
|
|
112
|
+
};
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (error instanceof errors.JWTExpired) return {
|
|
115
|
+
failure: "expired",
|
|
116
|
+
message: "JWT has expired."
|
|
117
|
+
};
|
|
118
|
+
if (error instanceof errors.JWTClaimValidationFailed) {
|
|
119
|
+
const claim = error.claim;
|
|
120
|
+
if (claim === "iss") return {
|
|
121
|
+
failure: "wrong-iss",
|
|
122
|
+
message: "JWT `iss` claim does not match expected issuer."
|
|
123
|
+
};
|
|
124
|
+
if (claim === "aud") return {
|
|
125
|
+
failure: "wrong-aud",
|
|
126
|
+
message: "JWT `aud` claim does not match expected audience."
|
|
127
|
+
};
|
|
128
|
+
if (claim === "nbf") return {
|
|
129
|
+
failure: "not-yet-valid",
|
|
130
|
+
message: "JWT is not yet valid (nbf check failed)."
|
|
131
|
+
};
|
|
132
|
+
return {
|
|
133
|
+
failure: "malformed",
|
|
134
|
+
message: `JWT claim validation failed: ${error.message}`
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
if (error instanceof errors.JWSSignatureVerificationFailed || error instanceof errors.JWSInvalid) return {
|
|
138
|
+
failure: "bad-signature",
|
|
139
|
+
message: "JWT signature verification failed."
|
|
140
|
+
};
|
|
141
|
+
return {
|
|
142
|
+
failure: "malformed",
|
|
143
|
+
message: "JWT verification failed."
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
//#endregion
|
|
150
|
+
export { JwksCache, ManagedJwtVerifier };
|
|
151
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +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; // 15 minutes\nconst DEFAULT_JITTER_MS = 60 * 1000; // 60 seconds\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\n/**\n * Fetches and caches JWKS from a configured URL.\n *\n * On a `kid` cache miss the cache is refreshed once before failing —\n * this handles key rotation without requiring a restart.\n *\n * Refresh-window jitter avoids thundering-herd stampedes when multiple\n * workers share the same process lifetime.\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 /** Returns the cached key for `kid`, fetching if needed. Refreshes once on kid miss. */\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 // kid miss — refresh once to handle key rotation\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 // Skip malformed individual keys — don't fail the whole cache refresh\n }\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\n/**\n * Validates CP-signed EdDSA JWT bearers.\n *\n * Checks: signature, `iss` matches expected, `aud` matches expected workspaceId,\n * `exp`, `nbf`. Returns a `VerifiedManagedPrincipal` or a structured `JwtVerificationFailure`.\n *\n * Hono-independent — no web framework coupling.\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 // Decode header to get kid before verifying signature\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
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@codemation/managed-auth",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"author": "Made Relevant B.V.",
|
|
8
|
+
"homepage": "https://www.maderelevant.com",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/MadeRelevant/codemation",
|
|
12
|
+
"directory": "packages/managed-auth"
|
|
13
|
+
},
|
|
14
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "./dist/index.cjs",
|
|
17
|
+
"module": "./dist/index.js",
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"development": {
|
|
23
|
+
"import": "./src/index.ts",
|
|
24
|
+
"require": "./dist/index.cjs"
|
|
25
|
+
},
|
|
26
|
+
"import": "./dist/index.js",
|
|
27
|
+
"require": "./dist/index.cjs"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"jose": "^6.0.10"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^25.3.5",
|
|
35
|
+
"eslint": "^10.0.3",
|
|
36
|
+
"tsdown": "^0.15.5",
|
|
37
|
+
"typescript": "^5.9.3",
|
|
38
|
+
"vitest": "^3.1.4"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"changeset:verify": "pnpm --workspace-root run changeset:verify",
|
|
42
|
+
"dev": "tsdown --watch",
|
|
43
|
+
"build": "tsdown",
|
|
44
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
45
|
+
"lint": "eslint .",
|
|
46
|
+
"test:unit": "vitest run --passWithNoTests"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/JwksCache.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { importJWK } from "jose";
|
|
2
|
+
import type { Clock, FetchFn, JwksCacheConfig } from "./types";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_TTL_MS = 15 * 60 * 1000; // 15 minutes
|
|
5
|
+
const DEFAULT_JITTER_MS = 60 * 1000; // 60 seconds
|
|
6
|
+
|
|
7
|
+
type JoseKey = Awaited<ReturnType<typeof importJWK>>;
|
|
8
|
+
|
|
9
|
+
interface CacheEntry {
|
|
10
|
+
keys: Map<string, JoseKey>;
|
|
11
|
+
expiresAt: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface JwkKey {
|
|
15
|
+
kid?: string;
|
|
16
|
+
kty?: string;
|
|
17
|
+
crv?: string;
|
|
18
|
+
x?: string;
|
|
19
|
+
use?: string;
|
|
20
|
+
alg?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface JwksResponse {
|
|
24
|
+
keys: JwkKey[];
|
|
25
|
+
}
|
|
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
|
+
export class JwksCache {
|
|
37
|
+
private cache: CacheEntry | null = null;
|
|
38
|
+
|
|
39
|
+
constructor(
|
|
40
|
+
private readonly config: JwksCacheConfig,
|
|
41
|
+
private readonly fetch: FetchFn,
|
|
42
|
+
private readonly clock: Clock,
|
|
43
|
+
) {}
|
|
44
|
+
|
|
45
|
+
/** Returns the cached key for `kid`, fetching if needed. Refreshes once on kid miss. */
|
|
46
|
+
async getKey(kid: string): Promise<JoseKey | null> {
|
|
47
|
+
let entry = await this.getValidEntry();
|
|
48
|
+
const cached = entry.keys.get(kid);
|
|
49
|
+
if (cached !== undefined) {
|
|
50
|
+
return cached;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// kid miss — refresh once to handle key rotation
|
|
54
|
+
entry = await this.refresh();
|
|
55
|
+
return entry.keys.get(kid) ?? null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private async getValidEntry(): Promise<CacheEntry> {
|
|
59
|
+
if (this.cache !== null && this.clock.now() < this.cache.expiresAt) {
|
|
60
|
+
return this.cache;
|
|
61
|
+
}
|
|
62
|
+
return this.refresh();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private async refresh(): Promise<CacheEntry> {
|
|
66
|
+
const response = await this.fetch(this.config.jwksUrl);
|
|
67
|
+
if (!response.ok) {
|
|
68
|
+
throw new Error(`JWKS fetch failed for ${this.config.jwksUrl}`);
|
|
69
|
+
}
|
|
70
|
+
const body = (await response.json()) as JwksResponse;
|
|
71
|
+
const keys = new Map<string, JoseKey>();
|
|
72
|
+
|
|
73
|
+
for (const jwk of body.keys ?? []) {
|
|
74
|
+
if (!jwk.kid) continue;
|
|
75
|
+
try {
|
|
76
|
+
const key = await importJWK(jwk as Parameters<typeof importJWK>[0]);
|
|
77
|
+
keys.set(jwk.kid, key);
|
|
78
|
+
} catch {
|
|
79
|
+
// Skip malformed individual keys — don't fail the whole cache refresh
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const ttl = this.config.ttlMs ?? DEFAULT_TTL_MS;
|
|
84
|
+
const jitter = Math.random() * (this.config.jitterMs ?? DEFAULT_JITTER_MS);
|
|
85
|
+
const entry: CacheEntry = {
|
|
86
|
+
keys,
|
|
87
|
+
expiresAt: this.clock.now() + ttl + jitter,
|
|
88
|
+
};
|
|
89
|
+
this.cache = entry;
|
|
90
|
+
return entry;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { decodeProtectedHeader, jwtVerify, errors as joseErrors } from "jose";
|
|
2
|
+
import type { JwtVerificationFailure, ManagedJwtVerifierConfig, VerifiedManagedPrincipal } from "./types";
|
|
3
|
+
import type { JwksCache } from "./JwksCache";
|
|
4
|
+
|
|
5
|
+
type VerifyResult = VerifiedManagedPrincipal | JwtVerificationFailure;
|
|
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
|
+
export class ManagedJwtVerifier {
|
|
16
|
+
constructor(
|
|
17
|
+
private readonly config: ManagedJwtVerifierConfig,
|
|
18
|
+
private readonly jwksCache: JwksCache,
|
|
19
|
+
) {}
|
|
20
|
+
|
|
21
|
+
async verify(token: string): Promise<VerifyResult> {
|
|
22
|
+
// Decode header to get kid before verifying signature
|
|
23
|
+
let kid: string | undefined;
|
|
24
|
+
try {
|
|
25
|
+
const header = decodeProtectedHeader(token);
|
|
26
|
+
kid = typeof header.kid === "string" ? header.kid : undefined;
|
|
27
|
+
} catch {
|
|
28
|
+
return { failure: "malformed", message: "Unable to decode JWT header." };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!kid) {
|
|
32
|
+
return { failure: "missing-kid", message: "JWT header is missing the `kid` field." };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const key = await this.jwksCache.getKey(kid);
|
|
36
|
+
if (key === null) {
|
|
37
|
+
return { failure: "unknown-kid", message: `No key found for kid "${kid}" after JWKS refresh.` };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const { payload } = await jwtVerify(token, key, {
|
|
42
|
+
issuer: this.config.expectedIssuer,
|
|
43
|
+
audience: this.config.expectedAudience,
|
|
44
|
+
algorithms: ["EdDSA"],
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const sub = payload.sub;
|
|
48
|
+
const aud = payload.aud;
|
|
49
|
+
const workspaceId = Array.isArray(aud) ? aud[0] : aud;
|
|
50
|
+
|
|
51
|
+
if (!sub || typeof sub !== "string") {
|
|
52
|
+
return { failure: "malformed", message: "JWT is missing the `sub` claim." };
|
|
53
|
+
}
|
|
54
|
+
if (!workspaceId || typeof workspaceId !== "string") {
|
|
55
|
+
return { failure: "wrong-aud", message: "JWT `aud` claim is absent or not a string." };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { userId: sub, workspaceId };
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (error instanceof joseErrors.JWTExpired) {
|
|
61
|
+
return { failure: "expired", message: "JWT has expired." };
|
|
62
|
+
}
|
|
63
|
+
if (error instanceof joseErrors.JWTClaimValidationFailed) {
|
|
64
|
+
const claim = error.claim;
|
|
65
|
+
if (claim === "iss") {
|
|
66
|
+
return { failure: "wrong-iss", message: "JWT `iss` claim does not match expected issuer." };
|
|
67
|
+
}
|
|
68
|
+
if (claim === "aud") {
|
|
69
|
+
return { failure: "wrong-aud", message: "JWT `aud` claim does not match expected audience." };
|
|
70
|
+
}
|
|
71
|
+
if (claim === "nbf") {
|
|
72
|
+
return { failure: "not-yet-valid", message: "JWT is not yet valid (nbf check failed)." };
|
|
73
|
+
}
|
|
74
|
+
return { failure: "malformed", message: `JWT claim validation failed: ${error.message}` };
|
|
75
|
+
}
|
|
76
|
+
if (error instanceof joseErrors.JWSSignatureVerificationFailed || error instanceof joseErrors.JWSInvalid) {
|
|
77
|
+
return { failure: "bad-signature", message: "JWT signature verification failed." };
|
|
78
|
+
}
|
|
79
|
+
return { failure: "malformed", message: "JWT verification failed." };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { JwksCache } from "./JwksCache";
|
|
2
|
+
export { ManagedJwtVerifier } from "./ManagedJwtVerifier";
|
|
3
|
+
export type {
|
|
4
|
+
Clock,
|
|
5
|
+
FetchFn,
|
|
6
|
+
JwksCacheConfig,
|
|
7
|
+
JwtVerificationFailure,
|
|
8
|
+
JwtVerificationFailureReason,
|
|
9
|
+
ManagedJwtVerifierConfig,
|
|
10
|
+
VerifiedManagedPrincipal,
|
|
11
|
+
} from "./types";
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A successfully verified CP-signed JWT principal.
|
|
3
|
+
* `userId` maps to the JWT `sub` claim; `workspaceId` maps to `aud`.
|
|
4
|
+
*/
|
|
5
|
+
export interface VerifiedManagedPrincipal {
|
|
6
|
+
readonly userId: string;
|
|
7
|
+
readonly workspaceId: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type JwtVerificationFailureReason =
|
|
11
|
+
| "missing-kid"
|
|
12
|
+
| "unknown-kid"
|
|
13
|
+
| "bad-signature"
|
|
14
|
+
| "wrong-iss"
|
|
15
|
+
| "wrong-aud"
|
|
16
|
+
| "expired"
|
|
17
|
+
| "not-yet-valid"
|
|
18
|
+
| "malformed";
|
|
19
|
+
|
|
20
|
+
/** Structured failure returned instead of throwing, so callers can map to HTTP status codes cleanly. */
|
|
21
|
+
export interface JwtVerificationFailure {
|
|
22
|
+
readonly failure: JwtVerificationFailureReason;
|
|
23
|
+
readonly message: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface JwksCacheConfig {
|
|
27
|
+
/** URL to the JWKS endpoint (e.g. https://cp.example.com/.well-known/jwks.json). */
|
|
28
|
+
readonly jwksUrl: string;
|
|
29
|
+
/** TTL in milliseconds before the cache is considered stale. Defaults to 15 minutes. */
|
|
30
|
+
readonly ttlMs?: number;
|
|
31
|
+
/** Jitter window in milliseconds applied to TTL to avoid stampedes. Defaults to 60 seconds. */
|
|
32
|
+
readonly jitterMs?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ManagedJwtVerifierConfig {
|
|
36
|
+
/** Expected value of the JWT `iss` claim — must exactly match. */
|
|
37
|
+
readonly expectedIssuer: string;
|
|
38
|
+
/** Expected `aud` claim — must exactly match the workspace ID. */
|
|
39
|
+
readonly expectedAudience: string;
|
|
40
|
+
readonly jwksCache: JwksCacheConfig;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Minimal fetch-compatible function type for injection / testing. */
|
|
44
|
+
export type FetchFn = (url: string) => Promise<{ ok: boolean; json(): Promise<unknown> }>;
|
|
45
|
+
|
|
46
|
+
/** Minimal clock interface for injection / testing. */
|
|
47
|
+
export interface Clock {
|
|
48
|
+
now(): number;
|
|
49
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { generateKeyPair, exportJWK } from "jose";
|
|
2
|
+
import { describe, it, expect, beforeAll } from "vitest";
|
|
3
|
+
import { JwksCache } from "../src/JwksCache.js";
|
|
4
|
+
import type { Clock, FetchFn } from "../src/types.js";
|
|
5
|
+
|
|
6
|
+
interface FakeJwkKey {
|
|
7
|
+
kid: string;
|
|
8
|
+
kty: string;
|
|
9
|
+
crv?: string;
|
|
10
|
+
x?: string;
|
|
11
|
+
d?: string;
|
|
12
|
+
use?: string;
|
|
13
|
+
alg?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function makeClock(nowMs = 0): Clock {
|
|
17
|
+
let current = nowMs;
|
|
18
|
+
return {
|
|
19
|
+
now: () => current,
|
|
20
|
+
advance(ms: number) {
|
|
21
|
+
current += ms;
|
|
22
|
+
},
|
|
23
|
+
} as Clock & { advance(ms: number): void };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("JwksCache", () => {
|
|
27
|
+
let edKey1: FakeJwkKey;
|
|
28
|
+
let edKey2: FakeJwkKey;
|
|
29
|
+
|
|
30
|
+
beforeAll(async () => {
|
|
31
|
+
const kp1 = await generateKeyPair("EdDSA");
|
|
32
|
+
const pub1 = await exportJWK(kp1.publicKey);
|
|
33
|
+
edKey1 = { kid: "key-1", kty: pub1.kty!, crv: pub1.crv, x: pub1.x, use: "sig", alg: "EdDSA" };
|
|
34
|
+
|
|
35
|
+
const kp2 = await generateKeyPair("EdDSA");
|
|
36
|
+
const pub2 = await exportJWK(kp2.publicKey);
|
|
37
|
+
edKey2 = { kid: "key-2", kty: pub2.kty!, crv: pub2.crv, x: pub2.x, use: "sig", alg: "EdDSA" };
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("fetches keys on cold start and returns the key for a known kid", async () => {
|
|
41
|
+
const responses = new Map([["/jwks", [edKey1]]]);
|
|
42
|
+
let callCount = 0;
|
|
43
|
+
const fetch: FetchFn = async (url) => {
|
|
44
|
+
callCount++;
|
|
45
|
+
return { ok: true, json: async () => ({ keys: responses.get(url) ?? [] }) };
|
|
46
|
+
};
|
|
47
|
+
const cache = new JwksCache({ jwksUrl: "/jwks", ttlMs: 60_000, jitterMs: 0 }, fetch, makeClock());
|
|
48
|
+
|
|
49
|
+
const key = await cache.getKey("key-1");
|
|
50
|
+
expect(key).not.toBeNull();
|
|
51
|
+
expect(callCount).toBe(1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns the cached key on a subsequent hit without re-fetching", async () => {
|
|
55
|
+
let callCount = 0;
|
|
56
|
+
const fetch: FetchFn = async () => {
|
|
57
|
+
callCount++;
|
|
58
|
+
return { ok: true, json: async () => ({ keys: [edKey1] }) };
|
|
59
|
+
};
|
|
60
|
+
const clock = makeClock(0) as ReturnType<typeof makeClock> & { advance(ms: number): void };
|
|
61
|
+
(clock as unknown as { advance(ms: number): void }).advance = (ms: number) => {
|
|
62
|
+
(clock as unknown as { _now: number })._now = ((clock as unknown as { _now: number })._now ?? 0) + ms;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Use a simple advancing clock
|
|
66
|
+
let now = 0;
|
|
67
|
+
const advancingClock: Clock = { now: () => now };
|
|
68
|
+
|
|
69
|
+
const cache = new JwksCache({ jwksUrl: "/jwks", ttlMs: 60_000, jitterMs: 0 }, fetch, advancingClock);
|
|
70
|
+
|
|
71
|
+
await cache.getKey("key-1");
|
|
72
|
+
now += 30_000; // advance 30s — still within TTL
|
|
73
|
+
await cache.getKey("key-1");
|
|
74
|
+
|
|
75
|
+
expect(callCount).toBe(1); // no second fetch
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("re-fetches after TTL expires", async () => {
|
|
79
|
+
let callCount = 0;
|
|
80
|
+
const fetch: FetchFn = async () => {
|
|
81
|
+
callCount++;
|
|
82
|
+
return { ok: true, json: async () => ({ keys: [edKey1] }) };
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
let now = 0;
|
|
86
|
+
const clock: Clock = { now: () => now };
|
|
87
|
+
|
|
88
|
+
const cache = new JwksCache({ jwksUrl: "/jwks", ttlMs: 60_000, jitterMs: 0 }, fetch, clock);
|
|
89
|
+
|
|
90
|
+
await cache.getKey("key-1");
|
|
91
|
+
now += 61_000; // past TTL
|
|
92
|
+
await cache.getKey("key-1");
|
|
93
|
+
|
|
94
|
+
expect(callCount).toBe(2);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("refreshes once on kid miss and returns the new key", async () => {
|
|
98
|
+
let callCount = 0;
|
|
99
|
+
// First fetch has only key-1; second has key-1 and key-2
|
|
100
|
+
const fetch: FetchFn = async () => {
|
|
101
|
+
callCount++;
|
|
102
|
+
const keys = callCount === 1 ? [edKey1] : [edKey1, edKey2];
|
|
103
|
+
return { ok: true, json: async () => ({ keys }) };
|
|
104
|
+
};
|
|
105
|
+
const clock: Clock = { now: () => 0 };
|
|
106
|
+
const cache = new JwksCache({ jwksUrl: "/jwks", ttlMs: 60_000, jitterMs: 0 }, fetch, clock);
|
|
107
|
+
|
|
108
|
+
// Populate cache with just key-1
|
|
109
|
+
await cache.getKey("key-1");
|
|
110
|
+
expect(callCount).toBe(1);
|
|
111
|
+
|
|
112
|
+
// Ask for key-2 — cache miss triggers one refresh
|
|
113
|
+
const key = await cache.getKey("key-2");
|
|
114
|
+
expect(key).not.toBeNull();
|
|
115
|
+
expect(callCount).toBe(2);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("returns null after refresh when kid is still unknown", async () => {
|
|
119
|
+
const fetch: FetchFn = async () => ({
|
|
120
|
+
ok: true,
|
|
121
|
+
json: async () => ({ keys: [edKey1] }),
|
|
122
|
+
});
|
|
123
|
+
const clock: Clock = { now: () => 0 };
|
|
124
|
+
const cache = new JwksCache({ jwksUrl: "/jwks", ttlMs: 60_000, jitterMs: 0 }, fetch, clock);
|
|
125
|
+
|
|
126
|
+
const key = await cache.getKey("unknown-kid");
|
|
127
|
+
expect(key).toBeNull();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("propagates fetch errors", async () => {
|
|
131
|
+
const fetch: FetchFn = async () => ({ ok: false, json: async () => ({}) });
|
|
132
|
+
const clock: Clock = { now: () => 0 };
|
|
133
|
+
const cache = new JwksCache({ jwksUrl: "/jwks" }, fetch, clock);
|
|
134
|
+
|
|
135
|
+
await expect(cache.getKey("any")).rejects.toThrow("JWKS fetch failed");
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { generateKeyPair, exportJWK, SignJWT } from "jose";
|
|
2
|
+
import { describe, it, expect, beforeAll } from "vitest";
|
|
3
|
+
import { JwksCache } from "../src/JwksCache.js";
|
|
4
|
+
import { ManagedJwtVerifier } from "../src/ManagedJwtVerifier.js";
|
|
5
|
+
import type { Clock, FetchFn } from "../src/types.js";
|
|
6
|
+
|
|
7
|
+
const ISSUER = "https://cp.example.com";
|
|
8
|
+
const WORKSPACE_ID = "ws-abc123";
|
|
9
|
+
|
|
10
|
+
interface JwkPublicKey {
|
|
11
|
+
kid: string;
|
|
12
|
+
kty: string;
|
|
13
|
+
crv?: string;
|
|
14
|
+
x?: string;
|
|
15
|
+
use?: string;
|
|
16
|
+
alg?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface TestKeyPair {
|
|
20
|
+
privateKey: Awaited<ReturnType<typeof generateKeyPair>>["privateKey"];
|
|
21
|
+
publicKey: Awaited<ReturnType<typeof generateKeyPair>>["publicKey"];
|
|
22
|
+
jwk: JwkPublicKey;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function generateEd25519Pair(kid: string): Promise<TestKeyPair> {
|
|
26
|
+
const { privateKey, publicKey } = await generateKeyPair("EdDSA");
|
|
27
|
+
const pub = await exportJWK(publicKey);
|
|
28
|
+
return {
|
|
29
|
+
privateKey,
|
|
30
|
+
publicKey,
|
|
31
|
+
jwk: { kid, kty: pub.kty!, crv: pub.crv, x: pub.x, use: "sig", alg: "EdDSA" },
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function makeStaticFetch(keys: JwkPublicKey[]): FetchFn {
|
|
36
|
+
return async () => ({
|
|
37
|
+
ok: true,
|
|
38
|
+
json: async () => ({ keys }),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeFrozenClock(): Clock {
|
|
43
|
+
return { now: () => 0 };
|
|
44
|
+
}
|
|
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
|
+
const EXP_FUTURE_UNIX = Math.floor(new Date("2099-12-31T00:00:00Z").getTime() / 1000);
|
|
51
|
+
const EXP_PAST_UNIX = Math.floor(new Date("2000-01-01T00:00:00Z").getTime() / 1000);
|
|
52
|
+
const NBF_PAST_UNIX = Math.floor(new Date("2000-01-01T00:00:00Z").getTime() / 1000);
|
|
53
|
+
const NBF_FUTURE_UNIX = Math.floor(new Date("2099-12-31T00:00:00Z").getTime() / 1000);
|
|
54
|
+
|
|
55
|
+
async function makeToken(
|
|
56
|
+
privateKey: Awaited<ReturnType<typeof generateKeyPair>>["privateKey"],
|
|
57
|
+
kid: string,
|
|
58
|
+
overrides: Partial<{
|
|
59
|
+
iss: string;
|
|
60
|
+
aud: string;
|
|
61
|
+
sub: string;
|
|
62
|
+
expired: boolean;
|
|
63
|
+
notYetValid: boolean;
|
|
64
|
+
}> = {},
|
|
65
|
+
): Promise<string> {
|
|
66
|
+
const exp = overrides.expired === true ? EXP_PAST_UNIX : EXP_FUTURE_UNIX;
|
|
67
|
+
const nbf = overrides.notYetValid === true ? NBF_FUTURE_UNIX : NBF_PAST_UNIX;
|
|
68
|
+
|
|
69
|
+
return new SignJWT({ sub: overrides.sub ?? "user-42" })
|
|
70
|
+
.setProtectedHeader({ alg: "EdDSA", kid })
|
|
71
|
+
.setIssuer(overrides.iss ?? ISSUER)
|
|
72
|
+
.setAudience(overrides.aud ?? WORKSPACE_ID)
|
|
73
|
+
.setExpirationTime(exp)
|
|
74
|
+
.setNotBefore(nbf)
|
|
75
|
+
.sign(privateKey);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function makeVerifier(keys: JwkPublicKey[]): ManagedJwtVerifier {
|
|
79
|
+
const cache = new JwksCache(
|
|
80
|
+
{ jwksUrl: "/jwks", ttlMs: 60_000, jitterMs: 0 },
|
|
81
|
+
makeStaticFetch(keys),
|
|
82
|
+
makeFrozenClock(),
|
|
83
|
+
);
|
|
84
|
+
return new ManagedJwtVerifier(
|
|
85
|
+
{ expectedIssuer: ISSUER, expectedAudience: WORKSPACE_ID, jwksCache: { jwksUrl: "/jwks" } },
|
|
86
|
+
cache,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe("ManagedJwtVerifier", () => {
|
|
91
|
+
let kp1: TestKeyPair;
|
|
92
|
+
let kp2: TestKeyPair;
|
|
93
|
+
|
|
94
|
+
beforeAll(async () => {
|
|
95
|
+
kp1 = await generateEd25519Pair("key-1");
|
|
96
|
+
kp2 = await generateEd25519Pair("key-2");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("returns VerifiedManagedPrincipal for a valid token", async () => {
|
|
100
|
+
const verifier = makeVerifier([kp1.jwk]);
|
|
101
|
+
const token = await makeToken(kp1.privateKey, "key-1");
|
|
102
|
+
const result = await verifier.verify(token);
|
|
103
|
+
expect("failure" in result).toBe(false);
|
|
104
|
+
if (!("failure" in result)) {
|
|
105
|
+
expect(result.userId).toBe("user-42");
|
|
106
|
+
expect(result.workspaceId).toBe(WORKSPACE_ID);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("fails with bad-signature for a tampered token", async () => {
|
|
111
|
+
const verifier = makeVerifier([kp1.jwk]);
|
|
112
|
+
const token = await makeToken(kp1.privateKey, "key-1");
|
|
113
|
+
const parts = token.split(".");
|
|
114
|
+
// Tamper the payload
|
|
115
|
+
const tampered = `${parts[0]}.${parts[1]}X.${parts[2]}`;
|
|
116
|
+
const result = await verifier.verify(tampered);
|
|
117
|
+
expect("failure" in result).toBe(true);
|
|
118
|
+
if ("failure" in result) {
|
|
119
|
+
expect(["bad-signature", "malformed"]).toContain(result.failure);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("fails with wrong-aud for a token targeting a different workspace", async () => {
|
|
124
|
+
const verifier = makeVerifier([kp1.jwk]);
|
|
125
|
+
const token = await makeToken(kp1.privateKey, "key-1", { aud: "ws-other" });
|
|
126
|
+
const result = await verifier.verify(token);
|
|
127
|
+
expect("failure" in result).toBe(true);
|
|
128
|
+
if ("failure" in result) {
|
|
129
|
+
expect(result.failure).toBe("wrong-aud");
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("fails with wrong-iss for a token from a different issuer", async () => {
|
|
134
|
+
const verifier = makeVerifier([kp1.jwk]);
|
|
135
|
+
const token = await makeToken(kp1.privateKey, "key-1", { iss: "https://evil.example.com" });
|
|
136
|
+
const result = await verifier.verify(token);
|
|
137
|
+
expect("failure" in result).toBe(true);
|
|
138
|
+
if ("failure" in result) {
|
|
139
|
+
expect(result.failure).toBe("wrong-iss");
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("fails with expired for an expired token", async () => {
|
|
144
|
+
const verifier = makeVerifier([kp1.jwk]);
|
|
145
|
+
const token = await makeToken(kp1.privateKey, "key-1", { expired: true });
|
|
146
|
+
const result = await verifier.verify(token);
|
|
147
|
+
expect("failure" in result).toBe(true);
|
|
148
|
+
if ("failure" in result) {
|
|
149
|
+
expect(result.failure).toBe("expired");
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("fails with not-yet-valid for a future token", async () => {
|
|
154
|
+
const verifier = makeVerifier([kp1.jwk]);
|
|
155
|
+
const token = await makeToken(kp1.privateKey, "key-1", { notYetValid: true });
|
|
156
|
+
const result = await verifier.verify(token);
|
|
157
|
+
expect("failure" in result).toBe(true);
|
|
158
|
+
if ("failure" in result) {
|
|
159
|
+
expect(result.failure).toBe("not-yet-valid");
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("fails with missing-kid when header has no kid field", async () => {
|
|
164
|
+
const verifier = makeVerifier([kp1.jwk]);
|
|
165
|
+
// Build a token without kid in header using deterministic timestamps
|
|
166
|
+
const token = await new SignJWT({ sub: "user-1" })
|
|
167
|
+
.setProtectedHeader({ alg: "EdDSA" }) // no kid
|
|
168
|
+
.setIssuer(ISSUER)
|
|
169
|
+
.setAudience(WORKSPACE_ID)
|
|
170
|
+
.setExpirationTime(EXP_FUTURE_UNIX)
|
|
171
|
+
.setNotBefore(NBF_PAST_UNIX)
|
|
172
|
+
.sign(kp1.privateKey);
|
|
173
|
+
const result = await verifier.verify(token);
|
|
174
|
+
expect("failure" in result).toBe(true);
|
|
175
|
+
if ("failure" in result) {
|
|
176
|
+
expect(result.failure).toBe("missing-kid");
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("fails with unknown-kid after refresh when kid is not in JWKS", async () => {
|
|
181
|
+
const verifier = makeVerifier([kp1.jwk]);
|
|
182
|
+
// Token signed with key-2 but JWKS only has key-1 (even after refresh)
|
|
183
|
+
const token = await makeToken(kp2.privateKey, "key-2");
|
|
184
|
+
const result = await verifier.verify(token);
|
|
185
|
+
expect("failure" in result).toBe(true);
|
|
186
|
+
if ("failure" in result) {
|
|
187
|
+
expect(result.failure).toBe("unknown-kid");
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
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
|
+
let callCount = 0;
|
|
194
|
+
const fetch: FetchFn = async () => {
|
|
195
|
+
callCount++;
|
|
196
|
+
const keys = callCount === 1 ? [kp1.jwk] : [kp1.jwk, kp2.jwk];
|
|
197
|
+
return { ok: true, json: async () => ({ keys }) };
|
|
198
|
+
};
|
|
199
|
+
const cache = new JwksCache({ jwksUrl: "/jwks", ttlMs: 60_000, jitterMs: 0 }, fetch, { now: () => 0 });
|
|
200
|
+
const verifier = new ManagedJwtVerifier(
|
|
201
|
+
{ expectedIssuer: ISSUER, expectedAudience: WORKSPACE_ID, jwksCache: { jwksUrl: "/jwks" } },
|
|
202
|
+
cache,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
// Warm the cache with key-1 only
|
|
206
|
+
await cache.getKey("key-1");
|
|
207
|
+
|
|
208
|
+
// Now verify a token using key-2 — should trigger refresh and succeed
|
|
209
|
+
const token = await makeToken(kp2.privateKey, "key-2");
|
|
210
|
+
const result = await verifier.verify(token);
|
|
211
|
+
expect("failure" in result).toBe(false);
|
|
212
|
+
if (!("failure" in result)) {
|
|
213
|
+
expect(result.userId).toBe("user-42");
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("fails with malformed for a completely invalid token string", async () => {
|
|
218
|
+
const verifier = makeVerifier([kp1.jwk]);
|
|
219
|
+
const result = await verifier.verify("not.a.jwt");
|
|
220
|
+
expect("failure" in result).toBe(true);
|
|
221
|
+
if ("failure" in result) {
|
|
222
|
+
expect(["malformed", "missing-kid", "unknown-kid", "bad-signature"]).toContain(result.failure);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
});
|
package/tsconfig.json
ADDED
package/tsdown.config.ts
ADDED
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { defineConfig } from "vitest/config";
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
name: "@codemation/managed-auth",
|
|
6
|
+
root: import.meta.dirname,
|
|
7
|
+
environment: "node",
|
|
8
|
+
include: ["test/**/*.test.ts"],
|
|
9
|
+
pool: "threads",
|
|
10
|
+
testTimeout: 30_000,
|
|
11
|
+
},
|
|
12
|
+
resolve: {
|
|
13
|
+
conditions: ["development", "import", "module", "default"],
|
|
14
|
+
},
|
|
15
|
+
});
|