@fabrika/proxy-core 0.0.26 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/proxy-core",
3
- "version": "0.0.26",
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.26",
30
- "@fabrika/proxy-contract": "^0.0.26",
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
- /** How long a fetched JWKS is reused before a refetch (a kid rotation also forces one on demand). */
40
- export const JWKS_TTL_SECONDS = 600
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
- * `verify` returns a three-state result, because "this token is bad" and "we could not check" are
6
- * different denials: the first is the caller's fault (401/403), the second is ours (503). Both deny.
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 { type AccessTokenClaims, type Jwks, parseAccessClaims } from '@fabrika/auth-core'
10
- import { createLocalJWKSet, errors as joseErrors, jwtVerify } from 'jose'
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 cached: { verifier: ReturnType<typeof createLocalJWKSet>; expiresAt: number } | null = null
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
- private readonly getJwks: () => Promise<Jwks>,
31
- private readonly issuer: string,
32
- private readonly now: () => number,
33
- private readonly ttlSeconds: number = JWKS_TTL_SECONDS,
34
- private readonly refetchCooldownSeconds: number = JWKS_REFETCH_COOLDOWN_SECONDS,
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
- * May an unknown `kid` force a refetch right now? A rotation is rare and the token is denied either
64
- * way, so rate-limiting this costs a few seconds of rotation lag and removes an amplification
65
- * primitive: without it, N requests with N invented kids are N IAM fetches.
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
- private mayForceRefetch(): boolean {
68
- const now = this.now()
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
  }