@fabrika/auth 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.
Files changed (2) hide show
  1. package/package.json +3 -2
  2. package/src/verify.ts +25 -83
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/auth",
3
- "version": "0.0.26",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,7 +26,8 @@
26
26
  "test": "bun test"
27
27
  },
28
28
  "dependencies": {
29
- "@fabrika/auth-core": "^0.0.26",
29
+ "@fabrika/auth-core": "^0.2.0",
30
+ "@fabrika/jwks": "^0.2.0",
30
31
  "jose": "^5.9.0"
31
32
  }
32
33
  }
package/src/verify.ts CHANGED
@@ -7,102 +7,44 @@
7
7
  * re-verified here against IAM's published JWKS — signature, `iss`, `aud`, `exp` — before it can
8
8
  * become an `AuthContext`.
9
9
  *
10
- * `verify` is THREE-STATE, exactly like `@fabrika/proxy`'s verifier: "this token is bad" (the
11
- * caller's fault, 401) and "we could not check" (ours, 503) are different denials. Both deny; only
12
- * the second is an incident. Collapsing them would let a JWKS outage look like a forged token.
13
- *
14
- * The JWKS is cached PER BINDING in a module-level WeakMap rather than per instance: `@fabrika/app`
15
- * calls its `middleware(env)` factory on every request, so an SDK object built there is per-request
16
- * while the binding lives for the isolate/process. An unknown `kid` (a key rotated in) forces exactly
17
- * one refetch, and a stale verifier is never reused after a fetch failure — the previous key set may
18
- * have been rotated out precisely because it was compromised.
10
+ * The verification itself is `@fabrika/jwks`, shared byte-for-byte with the proxy's enforcement path.
11
+ * What this file owns is the SDK's construction shape: the audience is fixed at construction, and the
12
+ * key set plus the unknown-`kid` cooldown are cached PER BINDING in a module-level WeakMap rather than
13
+ * per instance — `@fabrika/app` calls its `middleware(env)` factory on every request, so an SDK object
14
+ * built there is per-request while the binding lives for the isolate/process.
19
15
  */
20
16
 
21
- import { type AccessTokenClaims, type IamRpc, type Jwks, parseAccessClaims } from '@fabrika/auth-core'
22
- import { createLocalJWKSet, errors as joseErrors, jwtVerify } from 'jose'
23
-
24
- export type VerifyResult =
25
- | { ok: true; claims: AccessTokenClaims }
26
- /** Signature, issuer, audience, expiry or claim-shape failure — the token is not trustworthy. */
27
- | { ok: false; reason: 'invalid' }
28
- /** The key set could not be obtained, so nothing could be checked. Deny, but say why. */
29
- | { ok: false; reason: 'unavailable' }
17
+ import type { IamRpc } from '@fabrika/auth-core'
18
+ import { createJwksCache, type JwksCache, TokenVerifier as JwksVerifier, type VerifyResult } from '@fabrika/jwks'
30
19
 
31
- /** How long a fetched JWKS is reused before a refetch (a rotation also forces one on demand). */
32
- const JWKS_TTL_SECONDS = 600
20
+ export type { VerifyResult }
33
21
 
34
- /** Sentinel distinguishing "no signing key matched (kid rotation)" from a real verification failure. */
35
- const NO_KEY = Symbol('no-matching-key')
22
+ /** Key set + cooldown cached per binding, so they survive the per-request SDK objects built over them. */
23
+ const jwksCache = new WeakMap<IamRpc, JwksCache>()
36
24
 
37
- type JwksVerifier = ReturnType<typeof createLocalJWKSet>
38
-
39
- /** Raised only by `jwks()`, so `verify` can tell an unfetchable key set from an untrustworthy token. */
40
- class JwksUnavailableError extends Error {
41
- constructor() {
42
- super('the IAM key set could not be fetched')
43
- this.name = 'JwksUnavailableError'
25
+ function cacheFor(binding: IamRpc): JwksCache {
26
+ let cache = jwksCache.get(binding)
27
+ if (cache === undefined) {
28
+ cache = createJwksCache()
29
+ jwksCache.set(binding, cache)
44
30
  }
31
+ return cache
45
32
  }
46
33
 
47
- /** JWKS verifier cached per binding, so it survives the per-request SDK objects built over it. */
48
- const jwksCache = new WeakMap<IamRpc, { verifier: JwksVerifier; expiresAt: number }>()
49
-
50
34
  /** Verifies tokens for ONE (binding, issuer, audience) triple. Cheap to construct; the cache is shared. */
51
35
  export class TokenVerifier {
36
+ private readonly inner: JwksVerifier
37
+
52
38
  constructor(
53
- private readonly binding: IamRpc,
54
- private readonly issuer: string,
39
+ binding: IamRpc,
40
+ issuer: string,
55
41
  private readonly audience: string,
56
- private readonly now: () => number = () => Math.floor(Date.now() / 1000),
57
- ) {}
58
-
59
- async verify(token: string): Promise<VerifyResult> {
60
- let payload: unknown
61
- try {
62
- payload = await this.verifyWith(token, false)
63
- if (payload === NO_KEY) {
64
- // Unknown kid — a key was rotated in. Refetch the JWKS once and retry.
65
- payload = await this.verifyWith(token, true)
66
- }
67
- } catch (err) {
68
- if (err instanceof JwksUnavailableError) {
69
- return { ok: false, reason: 'unavailable' }
70
- }
71
- throw err
72
- }
73
- if (payload === NO_KEY || payload === null) {
74
- return { ok: false, reason: 'invalid' }
75
- }
76
- const claims = parseAccessClaims(payload)
77
- return claims === null ? { ok: false, reason: 'invalid' } : { ok: true, claims }
78
- }
79
-
80
- /** Returns the payload, `null` on a genuine verification failure, or `NO_KEY` on a kid miss. */
81
- private async verifyWith(token: string, force: boolean): Promise<unknown> {
82
- const jwks = await this.jwks(force)
83
- try {
84
- const { payload } = await jwtVerify(token, jwks, { issuer: this.issuer, audience: this.audience })
85
- return payload
86
- } catch (err) {
87
- return err instanceof joseErrors.JWKSNoMatchingKey ? NO_KEY : null
88
- }
42
+ now: () => number = () => Math.floor(Date.now() / 1000),
43
+ ) {
44
+ this.inner = new JwksVerifier({ getJwks: () => binding.getJwks(), issuer, now, cache: cacheFor(binding) })
89
45
  }
90
46
 
91
- private async jwks(force: boolean): Promise<JwksVerifier> {
92
- const now = this.now()
93
- const cached = jwksCache.get(this.binding)
94
- if (!force && cached !== undefined && cached.expiresAt > now) {
95
- return cached.verifier
96
- }
97
- let keys: Jwks
98
- try {
99
- keys = await this.binding.getJwks()
100
- } catch {
101
- // Never re-wrap the cause: it can stringify a request URL or a transport credential.
102
- throw new JwksUnavailableError()
103
- }
104
- const verifier = createLocalJWKSet(keys)
105
- jwksCache.set(this.binding, { verifier, expiresAt: now + JWKS_TTL_SECONDS })
106
- return verifier
47
+ verify(token: string): Promise<VerifyResult> {
48
+ return this.inner.verify(token, this.audience)
107
49
  }
108
50
  }