@fabrika/proxy-core 0.0.1
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 +33 -0
- package/src/authorize.ts +406 -0
- package/src/cache.ts +0 -0
- package/src/constants.ts +48 -0
- package/src/gates.ts +65 -0
- package/src/handoff.ts +61 -0
- package/src/iam.ts +257 -0
- package/src/index.ts +43 -0
- package/src/json.ts +44 -0
- package/src/log.ts +58 -0
- package/src/service.ts +312 -0
- package/src/verifier.ts +105 -0
package/src/service.ts
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `forward_auth` endpoint — a fetch-style `Request → Response` service, written the way
|
|
3
|
+
* `@fabrika/iam` is so the same code runs on Bun, Node and Workers.
|
|
4
|
+
*
|
|
5
|
+
* Caddy's contract (verified against `modules/caddyhttp/reverseproxy/forwardauth/caddyfile.go`):
|
|
6
|
+
* - the subrequest is rewritten to `GET /verify` with the ORIGINAL headers, plus
|
|
7
|
+
* `X-Forwarded-Method` / `X-Forwarded-Uri`, plus reverse_proxy's `X-Forwarded-{For,Proto,Host}`;
|
|
8
|
+
* - a **2xx** response lets the request continue, and each `copy_headers` field is copied onto the
|
|
9
|
+
* UPSTREAM request — but ONLY when non-empty (Caddy guards each copy with a `not vars ""` matcher);
|
|
10
|
+
* - **any other status** is returned to the client verbatim — status, headers AND body, verified
|
|
11
|
+
* against caddy 2.10.2 — so both a 302 to `/auth/login` and a 401 carrying a JSON login envelope
|
|
12
|
+
* reach the caller and neither reaches the app.
|
|
13
|
+
*
|
|
14
|
+
* The two consequences that shape this file:
|
|
15
|
+
* 1. the request being authorized is described ENTIRELY by the forwarded headers. Never look at
|
|
16
|
+
* this request's own method or path — a missing `X-Forwarded-Uri` must deny, not fall back;
|
|
17
|
+
* 2. because an empty copy header is a no-op rather than a delete, the generated Caddy config
|
|
18
|
+
* strips the token header AND the request id from the inbound request BEFORE `forward_auth`
|
|
19
|
+
* runs. Otherwise a client could present its own token header on a `public` path and have it
|
|
20
|
+
* reach the app, or choose the correlation id that lands in IAM's audit trail. Both are handed
|
|
21
|
+
* back on a 2xx, so the edge is the only thing that mints either.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { AUTH_CALLBACK_PATH, HANDOFF_COOKIE_TTL_SECONDS, SESSION_COOKIE, uuidv7 } from '@fabrika/auth-core'
|
|
25
|
+
import type { ProxyManifest } from '@fabrika/proxy-contract'
|
|
26
|
+
import { Authorizer, type AuthorizerOptions, type Decision, type DenyReason, type ForwardedRequest, type ResolvedApp } from './authorize'
|
|
27
|
+
import type { TokenCache } from './cache'
|
|
28
|
+
import {
|
|
29
|
+
APP_QUERY_PARAM,
|
|
30
|
+
DEFAULT_HEALTH_PATH,
|
|
31
|
+
DEFAULT_VERIFY_PATH,
|
|
32
|
+
FORWARDED_HOST_HEADER,
|
|
33
|
+
FORWARDED_METHOD_HEADER,
|
|
34
|
+
FORWARDED_PROTO_HEADER,
|
|
35
|
+
FORWARDED_URI_HEADER,
|
|
36
|
+
PROXY_TOKEN_HEADER,
|
|
37
|
+
REQUEST_ID_HEADER,
|
|
38
|
+
} from './constants'
|
|
39
|
+
import { compileGates } from './gates'
|
|
40
|
+
import { type HandoffAttempt, handoffCookieName } from './handoff'
|
|
41
|
+
import type { IamGateway } from './iam'
|
|
42
|
+
import { type LogFields, type ProxyLogger, redactPath, silentLogger } from './log'
|
|
43
|
+
|
|
44
|
+
export interface VerifyServiceConfig {
|
|
45
|
+
manifest: ProxyManifest
|
|
46
|
+
iam: IamGateway
|
|
47
|
+
/** IAM's origin — token `iss` and the base of the login bounce. */
|
|
48
|
+
issuer: string
|
|
49
|
+
/** `null`/omitted disables the token cache. Same decisions, more IAM calls. */
|
|
50
|
+
cache?: TokenCache | null
|
|
51
|
+
logger?: ProxyLogger
|
|
52
|
+
/** Liveness probe path on the auth service's own port. Default `/healthz`. */
|
|
53
|
+
healthPath?: string
|
|
54
|
+
/** Unix seconds. Injectable for tests. */
|
|
55
|
+
now?: () => number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type VerifyService = (request: Request) => Promise<Response>
|
|
59
|
+
|
|
60
|
+
/** A hostname we are willing to build a URL from. Anything else is a malformed forward → deny. */
|
|
61
|
+
const HOST_PATTERN = /^[a-z0-9.\-[\]]+(:\d{1,5})?$/i
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build the `/verify` service. Gates are compiled once here, not per request — the matcher is on the
|
|
65
|
+
* hot path of every request to every app.
|
|
66
|
+
*/
|
|
67
|
+
export function createVerifyService(config: VerifyServiceConfig): VerifyService {
|
|
68
|
+
const logger = config.logger ?? silentLogger
|
|
69
|
+
const healthPath = config.healthPath ?? DEFAULT_HEALTH_PATH
|
|
70
|
+
|
|
71
|
+
const byId = new Map<string, ResolvedApp>()
|
|
72
|
+
const byHost = new Map<string, ResolvedApp>()
|
|
73
|
+
for (const app of config.manifest.apps) {
|
|
74
|
+
const resolved: ResolvedApp = { app, gates: compileGates(app.gates) }
|
|
75
|
+
byId.set(app.id, resolved)
|
|
76
|
+
for (const host of app.hosts) {
|
|
77
|
+
// Normalized exactly as a forwarded host is, so the two sides cannot disagree about a port.
|
|
78
|
+
byHost.set(normalizeHost(host), resolved)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const authorizerOptions: AuthorizerOptions = { iam: config.iam, issuer: config.issuer }
|
|
83
|
+
if (config.cache !== undefined) {
|
|
84
|
+
authorizerOptions.cache = config.cache
|
|
85
|
+
}
|
|
86
|
+
if (config.now !== undefined) {
|
|
87
|
+
authorizerOptions.now = config.now
|
|
88
|
+
}
|
|
89
|
+
const authorizer = new Authorizer(authorizerOptions)
|
|
90
|
+
|
|
91
|
+
return async (request: Request): Promise<Response> => {
|
|
92
|
+
// Only the edge may supply this: it is written to IAM's `auth_log` and `audit_events`, and the
|
|
93
|
+
// generated Caddy route (and the Cloudflare Worker) delete a client-supplied one before we run.
|
|
94
|
+
const suppliedRequestId = request.headers.get(REQUEST_ID_HEADER)
|
|
95
|
+
const requestId = suppliedRequestId === null || suppliedRequestId === '' ? uuidv7() : suppliedRequestId
|
|
96
|
+
try {
|
|
97
|
+
const url = new URL(request.url)
|
|
98
|
+
if (url.pathname === healthPath) {
|
|
99
|
+
return new Response('ok', { status: 200, headers: { 'cache-control': 'no-store' } })
|
|
100
|
+
}
|
|
101
|
+
if (url.pathname !== DEFAULT_VERIFY_PATH) {
|
|
102
|
+
return textResponse(404, 'not found')
|
|
103
|
+
}
|
|
104
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
105
|
+
// `forward_auth` always rewrites to GET; anything else did not come from our config.
|
|
106
|
+
return textResponse(405, 'method not allowed')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const forwarded = readForwardedRequest(request, requestId)
|
|
110
|
+
if (forwarded === null) {
|
|
111
|
+
return denied(logger, { requestId }, { outcome: 'deny', status: 403, reason: 'bad_forward' })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const resolved = selectApp(url, forwarded.url.host, byId, byHost)
|
|
115
|
+
if (resolved === null) {
|
|
116
|
+
return denied(logger, { requestId, host: forwarded.url.host, path: redactPath(forwarded.url.pathname) }, {
|
|
117
|
+
outcome: 'deny',
|
|
118
|
+
status: 403,
|
|
119
|
+
reason: 'no_app',
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The handoff callback is checked BEFORE the gates: it is how a browser becomes able to
|
|
124
|
+
// satisfy one, so it cannot itself be behind one. Because Caddy returns a non-2xx auth
|
|
125
|
+
// response verbatim, answering 302 + Set-Cookie here reaches the client and never the app.
|
|
126
|
+
const decision = forwarded.url.pathname === AUTH_CALLBACK_PATH
|
|
127
|
+
? await authorizer.authorizeCallback(resolved, forwarded)
|
|
128
|
+
: await authorizer.authorize(resolved, forwarded)
|
|
129
|
+
const fields: LogFields = {
|
|
130
|
+
requestId,
|
|
131
|
+
app: resolved.app.id,
|
|
132
|
+
host: forwarded.url.host,
|
|
133
|
+
method: forwarded.method,
|
|
134
|
+
path: redactPath(forwarded.url.pathname),
|
|
135
|
+
}
|
|
136
|
+
if (decision.outcome === 'allow') {
|
|
137
|
+
logger.info('allow', { ...fields, gate: decision.gate, subject: decision.subject })
|
|
138
|
+
const headers = new Headers({ 'cache-control': 'no-store', [REQUEST_ID_HEADER]: requestId })
|
|
139
|
+
if (decision.token !== null) {
|
|
140
|
+
headers.set(PROXY_TOKEN_HEADER, decision.token)
|
|
141
|
+
}
|
|
142
|
+
return new Response(null, { status: 204, headers })
|
|
143
|
+
}
|
|
144
|
+
if (decision.outcome === 'handoff') {
|
|
145
|
+
logger.info('handoff', fields)
|
|
146
|
+
return handoffResponse(decision)
|
|
147
|
+
}
|
|
148
|
+
return denied(logger, fields, decision)
|
|
149
|
+
} catch {
|
|
150
|
+
// The last line of the fail-closed guarantee. No `err` is inspected or logged: a caught
|
|
151
|
+
// error can stringify a URL, a header or a body, any of which may carry a credential.
|
|
152
|
+
logger.error('verify failed', { requestId })
|
|
153
|
+
return textResponse(403, 'forbidden')
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Render a redeemed handoff: set the app session on THIS host and send the browser where IAM said.
|
|
160
|
+
*
|
|
161
|
+
* This is the ONE place the proxy writes the long-lived app session. It separately writes a
|
|
162
|
+
* short-lived handoff verifier when starting login, then clears that verifier here.
|
|
163
|
+
*
|
|
164
|
+
* Every attribute is fixed by `SESSION_COOKIE`'s `__Host-` prefix: `Secure`, `Path=/`, and no
|
|
165
|
+
* `Domain`. The absent `Domain` is the substance — the session belongs to this app's host and no
|
|
166
|
+
* sibling can read it — and the prefix is the browser-enforced restatement, which additionally stops a
|
|
167
|
+
* sibling under a shared registrable domain from planting a second cookie of this name. `Secure` is
|
|
168
|
+
* unconditional rather than following the manifest's scheme: the prefix requires it, so a conditional
|
|
169
|
+
* could only produce a cookie the browser discards. It stays correct behind a terminating balancer
|
|
170
|
+
* (the socket is plain HTTP, the browser spoke HTTPS) and on `*.localhost`, which browsers treat as
|
|
171
|
+
* potentially trustworthy.
|
|
172
|
+
*/
|
|
173
|
+
function handoffResponse(decision: Extract<Decision, { outcome: 'handoff' }>): Response {
|
|
174
|
+
const maxAge = Math.max(0, decision.expiresAt - Math.floor(Date.now() / 1000))
|
|
175
|
+
const headers = new Headers({ location: decision.location, 'cache-control': 'no-store' })
|
|
176
|
+
headers.append('set-cookie', secureCookie(SESSION_COOKIE, decision.session, maxAge))
|
|
177
|
+
headers.append('set-cookie', clearHandoffCookie(decision.handoffState))
|
|
178
|
+
return new Response(null, {
|
|
179
|
+
status: 302,
|
|
180
|
+
headers,
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Emit the log line for a refusal and render its response. The reason is logged, never returned. */
|
|
185
|
+
function denied(logger: ProxyLogger, fields: LogFields, decision: Extract<Decision, { outcome: 'deny' | 'login' }>): Response {
|
|
186
|
+
const reason: DenyReason = decision.reason
|
|
187
|
+
if (decision.outcome === 'login') {
|
|
188
|
+
logger.info('login', { ...fields, reason, status: decision.status })
|
|
189
|
+
return decision.status === 302
|
|
190
|
+
? loginRedirect(decision.location, decision.handoff)
|
|
191
|
+
: loginRequired(decision.location, decision.handoff)
|
|
192
|
+
}
|
|
193
|
+
logger.warn('deny', { ...fields, reason, status: decision.status })
|
|
194
|
+
const response = textResponse(decision.status, decision.status === 401 ? 'unauthorized' : decision.status === 503 ? 'unavailable' : 'forbidden')
|
|
195
|
+
if (decision.clearHandoffState !== undefined) {
|
|
196
|
+
response.headers.append('set-cookie', clearHandoffCookie(decision.clearHandoffState))
|
|
197
|
+
}
|
|
198
|
+
return response
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The bounce a document navigation gets: the browser follows it and comes back signed in. */
|
|
202
|
+
function loginRedirect(location: string, handoff: HandoffAttempt): Response {
|
|
203
|
+
return new Response(null, {
|
|
204
|
+
status: 302,
|
|
205
|
+
headers: { location, 'cache-control': 'no-store', 'set-cookie': handoffCookie(handoff) },
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The answer everything else gets — an XHR, a `fetch`, the console's own RPC POST. The envelope is
|
|
211
|
+
* the one `@fabrika/app`'s browser client already parses (`bounceOnAuth` acts on a 401 whose
|
|
212
|
+
* `error.type` is `auth` and which carries a `loginUrl`), so an expired console session becomes a
|
|
213
|
+
* sign-in rather than a redirect `fetch` cannot follow.
|
|
214
|
+
*
|
|
215
|
+
* The message is a fixed string: the deny REASON stays coarse and stays in the log. The login URL
|
|
216
|
+
* now rides in a body as well as in `Location`, which adds no way for a credential to reach a log —
|
|
217
|
+
* Caddy logs request URIs and response headers, never bodies, and the access log already redacts
|
|
218
|
+
* `?redirect=` out of both.
|
|
219
|
+
*/
|
|
220
|
+
function loginRequired(loginUrl: string, handoff: HandoffAttempt): Response {
|
|
221
|
+
return new Response(JSON.stringify({ error: { type: 'auth', message: 'Authentication required', loginUrl } }), {
|
|
222
|
+
status: 401,
|
|
223
|
+
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'set-cookie': handoffCookie(handoff) },
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function handoffCookie(handoff: HandoffAttempt): string {
|
|
228
|
+
const name = handoffCookieName(handoff.state)
|
|
229
|
+
if (name === null) throw new Error('generated invalid handoff state')
|
|
230
|
+
return secureCookie(name, handoff.verifier, HANDOFF_COOKIE_TTL_SECONDS)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function clearHandoffCookie(state: string): string {
|
|
234
|
+
const name = handoffCookieName(state)
|
|
235
|
+
if (name === null) throw new Error('invalid handoff state')
|
|
236
|
+
return secureCookie(name, '', 0)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function secureCookie(name: string, value: string, maxAge: number): string {
|
|
240
|
+
return [`${name}=${value}`, 'Path=/', `Max-Age=${maxAge}`, 'HttpOnly', 'SameSite=Lax', 'Secure'].join('; ')
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function textResponse(status: number, body: string): Response {
|
|
244
|
+
return new Response(body, { status, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } })
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Rebuild the original request from the forwarded headers. Returns null — a DENY — whenever the
|
|
249
|
+
* description is incomplete or suspicious, because a proxy that guesses what was asked for is a proxy
|
|
250
|
+
* that authorizes the wrong thing.
|
|
251
|
+
*
|
|
252
|
+
* The URL is assembled by assignment rather than by `new URL(uri, base)`: a request line of
|
|
253
|
+
* `GET //evil.example/x` is a legal path, and relative resolution would read it as a protocol-relative
|
|
254
|
+
* URL, silently changing the host — which would both mis-match the gates and turn the login bounce
|
|
255
|
+
* into an open redirect.
|
|
256
|
+
*/
|
|
257
|
+
function readForwardedRequest(request: Request, requestId: string): ForwardedRequest | null {
|
|
258
|
+
const uri = request.headers.get(FORWARDED_URI_HEADER)
|
|
259
|
+
if (uri === null || !uri.startsWith('/')) {
|
|
260
|
+
return null
|
|
261
|
+
}
|
|
262
|
+
const host = request.headers.get(FORWARDED_HOST_HEADER) ?? request.headers.get('Host')
|
|
263
|
+
if (host === null || !HOST_PATTERN.test(host)) {
|
|
264
|
+
return null
|
|
265
|
+
}
|
|
266
|
+
const proto = request.headers.get(FORWARDED_PROTO_HEADER) === 'http' ? 'http' : 'https'
|
|
267
|
+
|
|
268
|
+
const query = uri.indexOf('?')
|
|
269
|
+
const url = new URL(`${proto}://${host}`)
|
|
270
|
+
url.pathname = query === -1 ? uri : uri.slice(0, query)
|
|
271
|
+
url.search = query === -1 ? '' : uri.slice(query)
|
|
272
|
+
if (url.host.toLowerCase() !== host.toLowerCase()) {
|
|
273
|
+
// Defensive: the assignment above must not have been able to move the host.
|
|
274
|
+
return null
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
method: request.headers.get(FORWARDED_METHOD_HEADER) ?? 'GET',
|
|
279
|
+
url,
|
|
280
|
+
headers: request.headers,
|
|
281
|
+
requestId,
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Which app is this? The generated Caddy route pins `?app=<id>`, because the route already knows.
|
|
287
|
+
* Host lookup is the fallback for hand-written configs. Neither resolving is a deny — there is no
|
|
288
|
+
* "default app".
|
|
289
|
+
*
|
|
290
|
+
* A PINNED app is still cross-checked against the forwarded host. `X-Forwarded-Host` is
|
|
291
|
+
* client-controllable once `trusted_proxies` is set (ADR-0010), and the pin is what stops it choosing
|
|
292
|
+
* the app — but the same header then builds the login bounce and the handoff callback, so a request
|
|
293
|
+
* pinned to app A claiming app B's host would send the browser somewhere the manifest never declared.
|
|
294
|
+
* The authoritative host list is right here; use it.
|
|
295
|
+
*/
|
|
296
|
+
function selectApp(verifyUrl: URL, host: string, byId: Map<string, ResolvedApp>, byHost: Map<string, ResolvedApp>): ResolvedApp | null {
|
|
297
|
+
const hostname = normalizeHost(host)
|
|
298
|
+
const pinned = verifyUrl.searchParams.get(APP_QUERY_PARAM)
|
|
299
|
+
if (pinned !== null) {
|
|
300
|
+
const resolved = byId.get(pinned)
|
|
301
|
+
if (resolved === undefined || !resolved.app.hosts.some((declared) => normalizeHost(declared) === hostname)) {
|
|
302
|
+
return null
|
|
303
|
+
}
|
|
304
|
+
return resolved
|
|
305
|
+
}
|
|
306
|
+
return byHost.get(hostname) ?? null
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Lower-case, port stripped — the one normalization every host comparison in this file uses. */
|
|
310
|
+
function normalizeHost(host: string): string {
|
|
311
|
+
return host.toLowerCase().replace(/:\d+$/, '')
|
|
312
|
+
}
|
package/src/verifier.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local token verification — the warm path. Fetch the JWKS once, then verify every token with no
|
|
3
|
+
* network call on the warm path.
|
|
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.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { type AccessTokenClaims, type Jwks, parseAccessClaims } from '@fabrika/auth-core'
|
|
10
|
+
import { createLocalJWKSet, errors as joseErrors, jwtVerify } from 'jose'
|
|
11
|
+
import { JWKS_REFETCH_COOLDOWN_SECONDS, JWKS_TTL_SECONDS } from './constants'
|
|
12
|
+
import { IamUnavailableError } from './iam'
|
|
13
|
+
|
|
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')
|
|
23
|
+
|
|
24
|
+
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
|
|
28
|
+
|
|
29
|
+
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 }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
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.
|
|
66
|
+
*/
|
|
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
|
|
104
|
+
}
|
|
105
|
+
}
|