@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/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
+ }
@@ -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
+ }