@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 +32 -0
- package/src/index.ts +10 -0
- package/src/verifier.ts +154 -0
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'
|
package/src/verifier.ts
ADDED
|
@@ -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
|
+
}
|