@fabrika/proxy-core 0.0.25 → 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 +4 -3
- package/src/constants.ts +2 -7
- package/src/verifier.ts +20 -87
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/proxy-core",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -26,8 +26,9 @@
|
|
|
26
26
|
"test": "bun test"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@fabrika/auth-core": "^0.0
|
|
30
|
-
"@fabrika/
|
|
29
|
+
"@fabrika/auth-core": "^0.2.0",
|
|
30
|
+
"@fabrika/jwks": "^0.2.0",
|
|
31
|
+
"@fabrika/proxy-contract": "^0.2.0",
|
|
31
32
|
"jose": "^5.9.0"
|
|
32
33
|
}
|
|
33
34
|
}
|
package/src/constants.ts
CHANGED
|
@@ -36,13 +36,8 @@ export const APP_QUERY_PARAM = 'app'
|
|
|
36
36
|
*/
|
|
37
37
|
export const LOGIN_REDIRECT_PARAM = 'redirect'
|
|
38
38
|
|
|
39
|
-
/**
|
|
40
|
-
export
|
|
41
|
-
/**
|
|
42
|
-
* Minimum gap between two unknown-`kid` refetches. Bounds how often a token nobody can verify may
|
|
43
|
-
* cost an IAM round trip — the token is denied either way, and a real rotation is picked up within it.
|
|
44
|
-
*/
|
|
45
|
-
export const JWKS_REFETCH_COOLDOWN_SECONDS = 30
|
|
39
|
+
/** The JWKS cache TTL and the unknown-`kid` refetch cooldown, owned by the verifier both planes share. */
|
|
40
|
+
export { JWKS_REFETCH_COOLDOWN_SECONDS, JWKS_TTL_SECONDS } from '@fabrika/jwks'
|
|
46
41
|
|
|
47
42
|
/** Default IAM request timeout. A hung IAM must deny, not hang every request behind it. */
|
|
48
43
|
export const DEFAULT_IAM_TIMEOUT_MS = 5_000
|
package/src/verifier.ts
CHANGED
|
@@ -2,104 +2,37 @@
|
|
|
2
2
|
* Local token verification — the warm path. Fetch the JWKS once, then verify every token with no
|
|
3
3
|
* network call on the warm path.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* The verification itself is `@fabrika/jwks`, shared byte-for-byte with the app SDK. What this file
|
|
6
|
+
* owns is the proxy's construction shape: one long-lived process holding one verifier, so the key set
|
|
7
|
+
* is cached per instance, and the audience is the app the forwarded request resolved to — per call,
|
|
8
|
+
* not per verifier.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
|
-
import
|
|
10
|
-
import {
|
|
11
|
+
import type { Jwks } from '@fabrika/auth-core'
|
|
12
|
+
import { TokenVerifier as JwksVerifier, type VerifyResult } from '@fabrika/jwks'
|
|
11
13
|
import { JWKS_REFETCH_COOLDOWN_SECONDS, JWKS_TTL_SECONDS } from './constants'
|
|
12
|
-
import { IamUnavailableError } from './iam'
|
|
13
14
|
|
|
14
|
-
export type VerifyResult
|
|
15
|
-
| { ok: true; claims: AccessTokenClaims }
|
|
16
|
-
/** Signature, issuer, audience, expiry or claim-shape failure — the token is not trustworthy. */
|
|
17
|
-
| { ok: false; reason: 'invalid' }
|
|
18
|
-
/** The key set could not be obtained, so nothing can be verified. Deny, but say why. */
|
|
19
|
-
| { ok: false; reason: 'unavailable' }
|
|
20
|
-
|
|
21
|
-
/** Sentinel distinguishing "no signing key matched (kid rotation)" from a real verification failure. */
|
|
22
|
-
const NO_KEY = Symbol('no-matching-key')
|
|
15
|
+
export type { VerifyResult }
|
|
23
16
|
|
|
24
17
|
export class TokenVerifier {
|
|
25
|
-
private
|
|
26
|
-
/** When the last unknown-`kid` refetch was attempted. Null until one has been. */
|
|
27
|
-
private lastForcedAt: number | null = null
|
|
18
|
+
private readonly inner: JwksVerifier
|
|
28
19
|
|
|
29
20
|
constructor(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
) {
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Verify `token` for `audience` and narrow it to access claims. An unknown `kid` triggers one JWKS
|
|
39
|
-
* refetch (a key rotated in), then a retry — but at most once per cooldown, because otherwise any
|
|
40
|
-
* caller can turn every request into an IAM round trip just by inventing a `kid`.
|
|
41
|
-
*/
|
|
42
|
-
async verify(token: string, audience: string): Promise<VerifyResult> {
|
|
43
|
-
let payload: unknown
|
|
44
|
-
try {
|
|
45
|
-
payload = await this.verifyWith(token, audience, false)
|
|
46
|
-
if (payload === NO_KEY && this.mayForceRefetch()) {
|
|
47
|
-
payload = await this.verifyWith(token, audience, true)
|
|
48
|
-
}
|
|
49
|
-
} catch (err) {
|
|
50
|
-
if (err instanceof IamUnavailableError) {
|
|
51
|
-
return { ok: false, reason: 'unavailable' }
|
|
52
|
-
}
|
|
53
|
-
throw err
|
|
54
|
-
}
|
|
55
|
-
if (payload === NO_KEY || payload === null) {
|
|
56
|
-
return { ok: false, reason: 'invalid' }
|
|
57
|
-
}
|
|
58
|
-
const claims = parseAccessClaims(payload)
|
|
59
|
-
return claims === null ? { ok: false, reason: 'invalid' } : { ok: true, claims }
|
|
21
|
+
getJwks: () => Promise<Jwks>,
|
|
22
|
+
issuer: string,
|
|
23
|
+
now: () => number,
|
|
24
|
+
ttlSeconds: number = JWKS_TTL_SECONDS,
|
|
25
|
+
refetchCooldownSeconds: number = JWKS_REFETCH_COOLDOWN_SECONDS,
|
|
26
|
+
) {
|
|
27
|
+
this.inner = new JwksVerifier({ getJwks, issuer, now, ttlSeconds, refetchCooldownSeconds })
|
|
60
28
|
}
|
|
61
29
|
|
|
62
30
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
31
|
+
* Verify `token` for `audience`. An unknown `kid` forces one JWKS refetch (a key rotated in), then a
|
|
32
|
+
* retry — but at most once per cooldown, because otherwise any caller can turn every request into an
|
|
33
|
+
* IAM round trip just by inventing a `kid`.
|
|
66
34
|
*/
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (this.lastForcedAt !== null && now - this.lastForcedAt < this.refetchCooldownSeconds) {
|
|
70
|
-
return false
|
|
71
|
-
}
|
|
72
|
-
this.lastForcedAt = now
|
|
73
|
-
return true
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** Returns the payload, `null` on a genuine verification failure, or `NO_KEY` on a kid miss. */
|
|
77
|
-
private async verifyWith(token: string, audience: string, force: boolean): Promise<unknown> {
|
|
78
|
-
const jwks = await this.jwks(force)
|
|
79
|
-
try {
|
|
80
|
-
const { payload } = await jwtVerify(token, jwks, { issuer: this.issuer, audience })
|
|
81
|
-
return payload
|
|
82
|
-
} catch (err) {
|
|
83
|
-
return err instanceof joseErrors.JWKSNoMatchingKey ? NO_KEY : null
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
private async jwks(force: boolean): Promise<ReturnType<typeof createLocalJWKSet>> {
|
|
88
|
-
const now = this.now()
|
|
89
|
-
if (!force && this.cached !== null && this.cached.expiresAt > now) {
|
|
90
|
-
return this.cached.verifier
|
|
91
|
-
}
|
|
92
|
-
let keys: Jwks
|
|
93
|
-
try {
|
|
94
|
-
keys = await this.getJwks()
|
|
95
|
-
} catch {
|
|
96
|
-
// Any JWKS failure is an IAM outage as far as we are concerned. Note that we do NOT fall
|
|
97
|
-
// back to a stale verifier here: the previous key set may have been rotated out precisely
|
|
98
|
-
// because it was compromised.
|
|
99
|
-
throw new IamUnavailableError('getJwks')
|
|
100
|
-
}
|
|
101
|
-
const verifier = createLocalJWKSet(keys)
|
|
102
|
-
this.cached = { verifier, expiresAt: now + this.ttlSeconds }
|
|
103
|
-
return verifier
|
|
35
|
+
verify(token: string, audience: string): Promise<VerifyResult> {
|
|
36
|
+
return this.inner.verify(token, audience)
|
|
104
37
|
}
|
|
105
38
|
}
|