@fabrika/jwks 0.2.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/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@fabrika/jwks",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/contember/fabrika-platform.git",
8
+ "directory": "packages/jwks"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "!src/**/__tests__/**"
16
+ ],
17
+ "exports": {
18
+ ".": {
19
+ "bun": "./src/index.ts",
20
+ "types": "./src/index.ts",
21
+ "default": "./src/index.ts"
22
+ }
23
+ },
24
+ "scripts": {
25
+ "typecheck": "tsc --noEmit",
26
+ "test": "bun test"
27
+ },
28
+ "dependencies": {
29
+ "@fabrika/auth-core": "^0.2.0",
30
+ "jose": "^5.9.0"
31
+ }
32
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ // @fabrika/jwks — the single JWKS-backed access-token verifier.
2
+ //
3
+ // Two packages verify the proxy-minted token against IAM's key set: `@fabrika/auth` (the app SDK)
4
+ // and `@fabrika/proxy-core` (the enforcement point). Neither may import the other — the SDK must not
5
+ // pull in enforcement code, the proxy must not pull in the SDK — and `@fabrika/auth-core` cannot host
6
+ // the code because it is deliberately dependency-free (no `jose`) for its browser-side consumers.
7
+ // So the implementation lives here and both wrap it with their own construction shape.
8
+
9
+ export { createJwksCache, JWKS_REFETCH_COOLDOWN_SECONDS, JWKS_TTL_SECONDS, TokenVerifier } from './verifier'
10
+ export type { JwksCache, TokenVerifierOptions, VerifyResult } from './verifier'
@@ -0,0 +1,154 @@
1
+ /**
2
+ * The ONE JWKS-backed access-token verifier: fetch IAM's key set, cache it, verify every token
3
+ * locally with no network call on the warm path.
4
+ *
5
+ * `verify` is THREE-STATE, and the distinction is load-bearing: "this token is bad" is the caller's
6
+ * fault (401) and "we could not check" is ours (503). Both deny; only the second is an incident.
7
+ * Collapsing them would let a JWKS outage read as a forged token.
8
+ */
9
+
10
+ import { type AccessTokenClaims, type Jwks, parseAccessClaims } from '@fabrika/auth-core'
11
+ import { createLocalJWKSet, errors as joseErrors, jwtVerify } from 'jose'
12
+
13
+ export type VerifyResult =
14
+ | { ok: true; claims: AccessTokenClaims }
15
+ /** Signature, issuer, audience, expiry or claim-shape failure — the token is not trustworthy. */
16
+ | { ok: false; reason: 'invalid' }
17
+ /** The key set could not be obtained, so nothing could be checked. Deny, but say why. */
18
+ | { ok: false; reason: 'unavailable' }
19
+
20
+ /** How long a fetched JWKS is reused before a refetch (a kid rotation also forces one on demand). */
21
+ export const JWKS_TTL_SECONDS = 600
22
+ /**
23
+ * Minimum gap between two unknown-`kid` refetches. Bounds how often a token nobody can verify may
24
+ * cost an IAM round trip — the token is denied either way, and a real rotation is picked up within it.
25
+ */
26
+ export const JWKS_REFETCH_COOLDOWN_SECONDS = 30
27
+
28
+ /** Sentinel distinguishing "no signing key matched (kid rotation)" from a real verification failure. */
29
+ const NO_KEY = Symbol('no-matching-key')
30
+
31
+ /** Raised only by `keySet`, so `verify` can tell an unfetchable key set from an untrustworthy token. */
32
+ class JwksUnavailableError extends Error {
33
+ constructor() {
34
+ super('the IAM key set could not be fetched')
35
+ this.name = 'JwksUnavailableError'
36
+ }
37
+ }
38
+
39
+ /** jose's local key-set resolver — what `jwtVerify` is handed once the JWKS is in hand. */
40
+ type LocalJwks = ReturnType<typeof createLocalJWKSet>
41
+
42
+ /**
43
+ * The mutable state verification carries between calls: the cached key set and when an unknown `kid`
44
+ * last forced a refetch. Injectable because the app SDK keys it by the IAM BINDING — `@fabrika/app`
45
+ * builds a new SDK object per request, so per-instance state would refetch, and reset the cooldown,
46
+ * on every request.
47
+ */
48
+ export interface JwksCache {
49
+ entry: { keys: LocalJwks; expiresAt: number } | null
50
+ lastForcedAt: number | null
51
+ }
52
+
53
+ export function createJwksCache(): JwksCache {
54
+ return { entry: null, lastForcedAt: null }
55
+ }
56
+
57
+ export interface TokenVerifierOptions {
58
+ getJwks: () => Promise<Jwks>
59
+ /** Byte-exact: it is the token's `iss` and jose compares it verbatim. */
60
+ issuer: string
61
+ /** Unix seconds; injectable for tests. Defaults to the wall clock. */
62
+ now?: () => number
63
+ ttlSeconds?: number
64
+ refetchCooldownSeconds?: number
65
+ /** Where the key set and the cooldown live. Defaults to state private to this verifier. */
66
+ cache?: JwksCache
67
+ }
68
+
69
+ export class TokenVerifier {
70
+ private readonly getJwks: () => Promise<Jwks>
71
+ private readonly issuer: string
72
+ private readonly now: () => number
73
+ private readonly ttlSeconds: number
74
+ private readonly refetchCooldownSeconds: number
75
+ private readonly cache: JwksCache
76
+
77
+ constructor(options: TokenVerifierOptions) {
78
+ this.getJwks = options.getJwks
79
+ this.issuer = options.issuer
80
+ this.now = options.now ?? (() => Math.floor(Date.now() / 1000))
81
+ this.ttlSeconds = options.ttlSeconds ?? JWKS_TTL_SECONDS
82
+ this.refetchCooldownSeconds = options.refetchCooldownSeconds ?? JWKS_REFETCH_COOLDOWN_SECONDS
83
+ this.cache = options.cache ?? createJwksCache()
84
+ }
85
+
86
+ /**
87
+ * Verify `token` for `audience` and narrow it to access claims. An unknown `kid` forces one JWKS
88
+ * refetch (a key rotated in), then a retry — but at most once per cooldown, because otherwise any
89
+ * caller can turn every request into an IAM round trip just by inventing a `kid`.
90
+ */
91
+ async verify(token: string, audience: string): Promise<VerifyResult> {
92
+ let payload: unknown
93
+ try {
94
+ payload = await this.verifyWith(token, audience, false)
95
+ if (payload === NO_KEY && this.mayForceRefetch()) {
96
+ payload = await this.verifyWith(token, audience, true)
97
+ }
98
+ } catch (err) {
99
+ if (err instanceof JwksUnavailableError) {
100
+ return { ok: false, reason: 'unavailable' }
101
+ }
102
+ throw err
103
+ }
104
+ if (payload === NO_KEY || payload === null) {
105
+ return { ok: false, reason: 'invalid' }
106
+ }
107
+ const claims = parseAccessClaims(payload)
108
+ return claims === null ? { ok: false, reason: 'invalid' } : { ok: true, claims }
109
+ }
110
+
111
+ /**
112
+ * May an unknown `kid` force a refetch right now? A rotation is rare and the token is denied either
113
+ * way, so rate-limiting this costs a few seconds of rotation lag and removes an amplification
114
+ * primitive: without it, N requests with N invented kids are N IAM fetches.
115
+ */
116
+ private mayForceRefetch(): boolean {
117
+ const now = this.now()
118
+ if (this.cache.lastForcedAt !== null && now - this.cache.lastForcedAt < this.refetchCooldownSeconds) {
119
+ return false
120
+ }
121
+ this.cache.lastForcedAt = now
122
+ return true
123
+ }
124
+
125
+ /** Returns the payload, `null` on a genuine verification failure, or `NO_KEY` on a kid miss. */
126
+ private async verifyWith(token: string, audience: string, force: boolean): Promise<unknown> {
127
+ const keys = await this.keySet(force)
128
+ try {
129
+ const { payload } = await jwtVerify(token, keys, { issuer: this.issuer, audience })
130
+ return payload
131
+ } catch (err) {
132
+ return err instanceof joseErrors.JWKSNoMatchingKey ? NO_KEY : null
133
+ }
134
+ }
135
+
136
+ private async keySet(force: boolean): Promise<LocalJwks> {
137
+ const now = this.now()
138
+ const cached = this.cache.entry
139
+ if (!force && cached !== null && cached.expiresAt > now) {
140
+ return cached.keys
141
+ }
142
+ let jwks: Jwks
143
+ try {
144
+ jwks = await this.getJwks()
145
+ } catch {
146
+ // Never re-wrap the cause: it can stringify a request URL or a transport credential. A stale
147
+ // key set is not a fallback either — it may have been rotated out because it was compromised.
148
+ throw new JwksUnavailableError()
149
+ }
150
+ const keys = createLocalJWKSet(jwks)
151
+ this.cache.entry = { keys, expiresAt: now + this.ttlSeconds }
152
+ return keys
153
+ }
154
+ }