@fabrika/proxy-core 0.3.3 → 0.4.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 CHANGED
@@ -21,7 +21,17 @@
21
21
  * back on a 2xx, so the edge is the only thing that mints either.
22
22
  */
23
23
 
24
- import { AUTH_CALLBACK_PATH, HANDOFF_COOKIE_TTL_SECONDS, SESSION_COOKIE } from '@fabrika/auth-core'
24
+ import {
25
+ AUTH_CALLBACK_PATH,
26
+ compileEndpointTable,
27
+ DELEGATION_HEADER,
28
+ ENDPOINT_HEADER,
29
+ HANDOFF_COOKIE_TTL_SECONDS,
30
+ isCanonicalPath,
31
+ planEndpoint,
32
+ SESSION_COOKIE,
33
+ splitRequestTarget,
34
+ } from '@fabrika/auth-core'
25
35
  import { uuidv7 } from '@fabrika/platform'
26
36
  import { Authorizer, type AuthorizerOptions, type Decision, type DenyReason, type ForwardedRequest, type ResolvedApp } from './authorize'
27
37
  import type { TokenCache } from './cache'
@@ -36,11 +46,20 @@ import {
36
46
  PROXY_TOKEN_HEADER,
37
47
  REQUEST_ID_HEADER,
38
48
  } from './constants'
39
- import { compileGates } from './gates'
40
49
  import { type HandoffAttempt, handoffCookieName } from './handoff'
41
- import type { IamGateway } from './iam'
50
+ import {
51
+ appFileTreatment,
52
+ appHeaderPolicy,
53
+ applyFileTreatment,
54
+ applyHeaderPolicy,
55
+ BASE_HEADER_POLICY,
56
+ type HeaderPolicy,
57
+ transportPolicy,
58
+ } from './headers'
59
+ import type { DelegationIssuer, IamGateway } from './iam'
42
60
  import { type LogFields, type ProxyLogger, redactPath, silentLogger } from './log'
43
- import type { ProxyManifest } from './manifest'
61
+ import { formatOAuthBearerChallenge, isApplicationEntry, type ManifestEndpointRow, type ProxyApp, type ProxyManifest } from './manifest'
62
+ import { findOAuthMetadata, OAUTH_METADATA_VERIFY_PATH, oauthMetadataResponse } from './oauth'
44
63
 
45
64
  export interface VerifyServiceConfig {
46
65
  manifest: ProxyManifest
@@ -54,6 +73,8 @@ export interface VerifyServiceConfig {
54
73
  healthPath?: string
55
74
  /** Unix seconds. Injectable for tests. */
56
75
  now?: () => number
76
+ /** Issues delegation grants for membership and scope-creating endpoints; see `AuthorizerOptions.delegations`. */
77
+ delegations?: DelegationIssuer | null
57
78
  }
58
79
 
59
80
  export type VerifyService = (request: Request) => Promise<Response>
@@ -62,8 +83,8 @@ export type VerifyService = (request: Request) => Promise<Response>
62
83
  const HOST_PATTERN = /^[a-z0-9.\-[\]]+(:\d{1,5})?$/i
63
84
 
64
85
  /**
65
- * Build the `/verify` service. Gates are compiled once here, not per request — the matcher is on the
66
- * hot path of every request to every app.
86
+ * Build the `/verify` service. Endpoint tables are compiled once here, not per request — the planner is
87
+ * on the hot path of every request to every app.
67
88
  */
68
89
  export function createVerifyService(config: VerifyServiceConfig): VerifyService {
69
90
  const logger = config.logger ?? silentLogger
@@ -71,9 +92,11 @@ export function createVerifyService(config: VerifyServiceConfig): VerifyService
71
92
 
72
93
  const byId = new Map<string, ResolvedApp>()
73
94
  const byHost = new Map<string, ResolvedApp>()
95
+ const policies = new Map<string, HeaderPolicy>()
74
96
  for (const app of config.manifest.apps) {
75
- const resolved: ResolvedApp = { app, gates: compileGates(app.gates) }
97
+ const resolved = resolveApp(app)
76
98
  byId.set(app.id, resolved)
99
+ policies.set(app.id, isApplicationEntry(app) ? appHeaderPolicy(app) : BASE_HEADER_POLICY)
77
100
  for (const host of app.hosts) {
78
101
  // Normalized exactly as a forwarded host is, so the two sides cannot disagree about a port.
79
102
  byHost.set(normalizeHost(host), resolved)
@@ -87,6 +110,9 @@ export function createVerifyService(config: VerifyServiceConfig): VerifyService
87
110
  if (config.now !== undefined) {
88
111
  authorizerOptions.now = config.now
89
112
  }
113
+ if (config.delegations !== undefined) {
114
+ authorizerOptions.delegations = config.delegations
115
+ }
90
116
  const authorizer = new Authorizer(authorizerOptions)
91
117
 
92
118
  return async (request: Request): Promise<Response> => {
@@ -99,30 +125,36 @@ export function createVerifyService(config: VerifyServiceConfig): VerifyService
99
125
  if (url.pathname === healthPath) {
100
126
  return new Response('ok', { status: 200, headers: { 'cache-control': 'no-store' } })
101
127
  }
102
- if (url.pathname !== DEFAULT_VERIFY_PATH) {
103
- return textResponse(404, 'not found')
128
+ if (url.pathname !== DEFAULT_VERIFY_PATH && url.pathname !== OAUTH_METADATA_VERIFY_PATH) {
129
+ return withPolicy(textResponse(404, 'not found'), BASE_HEADER_POLICY)
104
130
  }
105
131
  if (request.method !== 'GET' && request.method !== 'HEAD') {
106
132
  // `forward_auth` always rewrites to GET; anything else did not come from our config.
107
- return textResponse(405, 'method not allowed')
133
+ return withPolicy(textResponse(405, 'method not allowed'), BASE_HEADER_POLICY)
108
134
  }
109
135
 
110
- const forwarded = readForwardedRequest(request, requestId)
111
- if (forwarded === null) {
112
- return denied(logger, { requestId }, { outcome: 'deny', status: 403, reason: 'bad_forward' })
136
+ const read = readForwardedRequest(request, requestId)
137
+ if (!read.ok) {
138
+ const status = read.reason === 'non_canonical_path' ? 400 : 403
139
+ return withPolicy(denied(logger, { requestId }, { outcome: 'deny', status, reason: read.reason }), earlyPolicy(request, url))
113
140
  }
141
+ const forwarded = read.request
114
142
 
115
143
  const resolved = selectApp(url, forwarded.url.host, byId, byHost)
116
144
  if (resolved === null) {
117
- return denied(logger, { requestId, host: forwarded.url.host, path: redactPath(forwarded.url.pathname) }, {
118
- outcome: 'deny',
119
- status: 403,
120
- reason: 'no_app',
121
- })
145
+ const fields = { requestId, host: forwarded.url.host, path: redactPath(forwarded.url.pathname) }
146
+ return withPolicy(denied(logger, fields, { outcome: 'deny', status: 403, reason: 'no_app' }), BASE_HEADER_POLICY)
147
+ }
148
+
149
+ if (url.pathname === OAUTH_METADATA_VERIFY_PATH) {
150
+ const metadata = findOAuthMetadata(resolved.app, forwarded.url)
151
+ return metadata === null
152
+ ? withPolicy(textResponse(404, 'not found'), policies.get(resolved.app.id) ?? BASE_HEADER_POLICY)
153
+ : oauthMetadataResponse(metadata, forwarded.method)
122
154
  }
123
155
 
124
- // The handoff callback is checked BEFORE the gates: it is how a browser becomes able to
125
- // satisfy one, so it cannot itself be behind one. Because Caddy returns a non-2xx auth
156
+ // The handoff callback is checked BEFORE the endpoint table: it is how a browser obtains a
157
+ // session, so it cannot itself be behind an endpoint. Because Caddy returns a non-2xx auth
126
158
  // response verbatim, answering 302 + Set-Cookie here reaches the client and never the app.
127
159
  const isCallback = forwarded.url.pathname === AUTH_CALLBACK_PATH
128
160
  const decision = isCallback
@@ -136,25 +168,68 @@ export function createVerifyService(config: VerifyServiceConfig): VerifyService
136
168
  path: redactPath(forwarded.url.pathname),
137
169
  }
138
170
  if (decision.outcome === 'allow') {
139
- logger.info('allow', { ...fields, gate: decision.gate, subject: decision.subject })
171
+ logger.info('allow', { ...fields, subject: decision.subject, endpoint: decision.endpoint })
140
172
  const headers = new Headers({ 'cache-control': 'no-store', [REQUEST_ID_HEADER]: requestId })
141
173
  if (decision.token !== null) {
142
174
  headers.set(PROXY_TOKEN_HEADER, decision.token)
143
175
  }
176
+ if (decision.endpoint !== null) {
177
+ headers.set(ENDPOINT_HEADER, decision.endpoint)
178
+ }
179
+ if (decision.delegation !== null) {
180
+ headers.set(DELEGATION_HEADER, decision.delegation)
181
+ }
144
182
  return new Response(null, { status: 204, headers })
145
183
  }
146
- if (decision.outcome === 'handoff') {
147
- logger.info('handoff', fields)
148
- return handoffResponse(decision)
149
- }
150
- return denied(logger, fields, decision, isCallback)
184
+ const answer = decision.outcome === 'handoff' ? handoff(logger, fields, decision) : denied(logger, fields, decision, isCallback)
185
+ return isDownload(resolved, forwarded)
186
+ ? withFileTreatment(answer, appFileTreatment(resolved.app))
187
+ : withPolicy(answer, policies.get(resolved.app.id) ?? BASE_HEADER_POLICY)
151
188
  } catch {
152
189
  // The last line of the fail-closed guarantee. No `err` is inspected or logged: a caught
153
190
  // error can stringify a URL, a header or a body, any of which may carry a credential.
154
191
  logger.error('verify failed', { requestId })
155
- return textResponse(403, 'forbidden')
192
+ return withPolicy(textResponse(403, 'forbidden'), earlyPolicy(request, null))
156
193
  }
157
194
  }
195
+
196
+ /**
197
+ * A refusal before the request is read: the base policy, plus the transport header of the application host it
198
+ * names, when the forwarded host (and the pinned app) resolves to one. Nothing of the host decides anything else.
199
+ */
200
+ function earlyPolicy(request: Request, verifyUrl: URL | null): HeaderPolicy {
201
+ try {
202
+ const host = request.headers.get(FORWARDED_HOST_HEADER) ?? request.headers.get('Host') ?? ''
203
+ const resolved = selectApp(verifyUrl ?? new URL(request.url), host, byId, byHost)
204
+ return resolved === null || !isApplicationEntry(resolved.app)
205
+ ? BASE_HEADER_POLICY
206
+ : [...BASE_HEADER_POLICY, ...transportPolicy(resolved.app.scheme)]
207
+ } catch {
208
+ return BASE_HEADER_POLICY
209
+ }
210
+ }
211
+ }
212
+
213
+ function handoff(logger: ProxyLogger, fields: LogFields, decision: Extract<Decision, { outcome: 'handoff' }>): Response {
214
+ logger.info('handoff', fields)
215
+ return handoffResponse(decision)
216
+ }
217
+
218
+ /** An answer the proxy writes reaches the client as-is, so it carries the policy the application's own answers do. */
219
+ function withPolicy(response: Response, policy: HeaderPolicy): Response {
220
+ applyHeaderPolicy(response.headers, policy)
221
+ return response
222
+ }
223
+
224
+ function withFileTreatment(response: Response, treatment: HeaderPolicy): Response {
225
+ applyFileTreatment(response.headers, treatment)
226
+ return response
227
+ }
228
+
229
+ /** Whether the request selects a download endpoint, whose every answer gets the file treatment instead of the policy. */
230
+ function isDownload(resolved: ResolvedApp, forwarded: ForwardedRequest): boolean {
231
+ const plan = planEndpoint(resolved.table, forwarded.method, forwarded.rawPath)
232
+ return plan.outcome === 'endpoint' && plan.endpoint.kind === 'download'
158
233
  }
159
234
 
160
235
  /**
@@ -198,12 +273,13 @@ function denied(
198
273
  : loginRequired(decision.location, decision.handoff)
199
274
  }
200
275
  logger.warn('deny', { ...fields, reason, status: decision.status })
201
- const response = isCallback
202
- ? callbackFailure(decision.status)
203
- : textResponse(decision.status, decision.status === 401 ? 'unauthorized' : decision.status === 503 ? 'unavailable' : 'forbidden')
276
+ const response = isCallback ? callbackFailure(decision.status) : textResponse(decision.status, DENY_TEXT[decision.status])
204
277
  if (decision.clearHandoffState !== undefined) {
205
278
  response.headers.append('set-cookie', clearHandoffCookie(decision.clearHandoffState))
206
279
  }
280
+ if (decision.challenge !== undefined) {
281
+ response.headers.set('WWW-Authenticate', formatOAuthBearerChallenge(decision.challenge))
282
+ }
207
283
  return response
208
284
  }
209
285
 
@@ -300,46 +376,57 @@ function secureCookie(name: string, value: string, maxAge: number): string {
300
376
  return [`${name}=${value}`, 'Path=/', `Max-Age=${maxAge}`, 'HttpOnly', 'SameSite=Lax', 'Secure'].join('; ')
301
377
  }
302
378
 
379
+ const DENY_TEXT: Record<Extract<Decision, { outcome: 'deny' }>['status'], string> = {
380
+ 400: 'bad request',
381
+ 401: 'unauthorized',
382
+ 403: 'forbidden',
383
+ 503: 'unavailable',
384
+ }
385
+
303
386
  function textResponse(status: number, body: string): Response {
304
387
  return new Response(body, { status, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } })
305
388
  }
306
389
 
390
+ type ForwardedRead = { ok: true; request: ForwardedRequest } | { ok: false; reason: 'bad_forward' | 'non_canonical_path' }
391
+
307
392
  /**
308
- * Rebuild the original request from the forwarded headers. Returns null — a DENY — whenever the
393
+ * Rebuild the original request from the forwarded headers. Refuses — a DENY — whenever the
309
394
  * description is incomplete or suspicious, because a proxy that guesses what was asked for is a proxy
310
- * that authorizes the wrong thing.
395
+ * that authorizes the wrong thing. That includes a missing method: there is no default to guess.
396
+ *
397
+ * The path is checked RAW, before any URL is built: assigning a pathname resolves `..` and `%2e%2e`, so
398
+ * `/a/../admin` would otherwise be matched as `/admin` while the upstream receives the original.
311
399
  *
312
400
  * The URL is assembled by assignment rather than by `new URL(uri, base)`: a request line of
313
401
  * `GET //evil.example/x` is a legal path, and relative resolution would read it as a protocol-relative
314
- * URL, silently changing the host — which would both mis-match the gates and turn the login bounce
402
+ * URL, silently changing the host — which would both select the wrong app and turn the login bounce
315
403
  * into an open redirect.
316
404
  */
317
- function readForwardedRequest(request: Request, requestId: string): ForwardedRequest | null {
405
+ function readForwardedRequest(request: Request, requestId: string): ForwardedRead {
318
406
  const uri = request.headers.get(FORWARDED_URI_HEADER)
319
- if (uri === null || !uri.startsWith('/')) {
320
- return null
407
+ const method = request.headers.get(FORWARDED_METHOD_HEADER)
408
+ if (uri === null || !uri.startsWith('/') || method === null || method === '') {
409
+ return { ok: false, reason: 'bad_forward' }
410
+ }
411
+ const target = splitRequestTarget(uri)
412
+ if (!isCanonicalPath(target.pathname)) {
413
+ return { ok: false, reason: 'non_canonical_path' }
321
414
  }
322
415
  const host = request.headers.get(FORWARDED_HOST_HEADER) ?? request.headers.get('Host')
323
416
  if (host === null || !HOST_PATTERN.test(host)) {
324
- return null
417
+ return { ok: false, reason: 'bad_forward' }
325
418
  }
326
419
  const proto = request.headers.get(FORWARDED_PROTO_HEADER) === 'http' ? 'http' : 'https'
327
420
 
328
- const query = uri.indexOf('?')
329
421
  const url = new URL(`${proto}://${host}`)
330
- url.pathname = query === -1 ? uri : uri.slice(0, query)
331
- url.search = query === -1 ? '' : uri.slice(query)
422
+ url.pathname = target.pathname
423
+ url.search = target.search
332
424
  if (url.host.toLowerCase() !== host.toLowerCase()) {
333
425
  // Defensive: the assignment above must not have been able to move the host.
334
- return null
426
+ return { ok: false, reason: 'bad_forward' }
335
427
  }
336
428
 
337
- return {
338
- method: request.headers.get(FORWARDED_METHOD_HEADER) ?? 'GET',
339
- url,
340
- headers: request.headers,
341
- requestId,
342
- }
429
+ return { ok: true, request: { method, url, rawPath: target.pathname, headers: request.headers, requestId } }
343
430
  }
344
431
 
345
432
  /**
@@ -366,6 +453,11 @@ function selectApp(verifyUrl: URL, host: string, byId: Map<string, ResolvedApp>,
366
453
  return byHost.get(hostname) ?? null
367
454
  }
368
455
 
456
+ /** The compiled table an app is protected by. The manifest parser already refused an ambiguous table. */
457
+ function resolveApp(app: ProxyApp): ResolvedApp {
458
+ return { app, table: compileEndpointTable<ManifestEndpointRow>(app) }
459
+ }
460
+
369
461
  /** Lower-case, port stripped — the one normalization every host comparison in this file uses. */
370
462
  function normalizeHost(host: string): string {
371
463
  return host.toLowerCase().replace(/:\d+$/, '')
package/src/verifier.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * not per verifier.
9
9
  */
10
10
 
11
- import type { Jwks } from '@fabrika/auth-core'
11
+ import type { Jwks, TokenAudienceProfile } from '@fabrika/auth-core'
12
12
  import { TokenVerifier as JwksVerifier, type VerifyResult } from '@fabrika/jwks'
13
13
  import { JWKS_REFETCH_COOLDOWN_SECONDS, JWKS_TTL_SECONDS } from './constants'
14
14
 
@@ -28,11 +28,11 @@ export class TokenVerifier {
28
28
  }
29
29
 
30
30
  /**
31
- * Verify `token` for `audience`. An unknown `kid` forces one JWKS refetch (a key rotated in), then a
31
+ * Verify `token` for `audience` (a bare string is an app id). An unknown `kid` forces one JWKS refetch (a key rotated in), then a
32
32
  * retry — but at most once per cooldown, because otherwise any caller can turn every request into an
33
33
  * IAM round trip just by inventing a `kid`.
34
34
  */
35
- verify(token: string, audience: string): Promise<VerifyResult> {
35
+ verify(token: string, audience: string | TokenAudienceProfile): Promise<VerifyResult> {
36
36
  return this.inner.verify(token, audience)
37
37
  }
38
38
  }
package/src/gates.ts DELETED
@@ -1,65 +0,0 @@
1
- /**
2
- * Credential extraction for the gate evaluator relocated out of the app's process
3
- * ([ADR-0022](../../../docs/decisions/0022-the-proxy-is-the-only-enforcement-point.md)).
4
- *
5
- * The semantics are `AppGates`', verbatim (see `@fabrika/auth-core`'s `types.ts`):
6
- * - array order IS the precedence;
7
- * - a matching rule whose credential is ABSENT falls through to the next matching rule;
8
- * - a matching rule whose credential is PRESENT is terminal (valid → allow, invalid → deny);
9
- * - a request matching NO rule is denied.
10
- *
11
- * The canonical matcher and its ordered compiled representation live in `@fabrika/auth-core` so the
12
- * gate declaration and its evaluator cannot drift. Credential extraction remains proxy-owned.
13
- */
14
-
15
- import type { CredentialLocation } from '@fabrika/auth-core'
16
-
17
- export { applicableGates, compileGates } from '@fabrika/auth-core'
18
- export type { CompiledGate } from '@fabrika/auth-core'
19
-
20
- // ── Credential extraction ──────────────────────────────────────────────────────
21
-
22
- /** The raw `service`-rule credential: the declared location, else `Authorization: Bearer`. Null if absent. */
23
- export function readServiceCredential(headers: Headers, url: URL, location: CredentialLocation | undefined): string | null {
24
- if (location !== undefined) {
25
- const raw = extractCredential(headers, url, location)
26
- return raw === null || raw === '' ? null : raw
27
- }
28
- return readBearer(headers.get('Authorization'))
29
- }
30
-
31
- /** Pull the raw credential from a declared location. A header value may be bare or `Bearer <token>`. */
32
- function extractCredential(headers: Headers, url: URL, source: CredentialLocation): string | null {
33
- if (source.in === 'header') {
34
- const value = headers.get(source.name)
35
- return value === null ? null : (readBearer(value) ?? value.trim())
36
- }
37
- if (source.in === 'query') {
38
- return url.searchParams.get(source.name)
39
- }
40
- return readCookie(headers.get('Cookie'), source.name)
41
- }
42
-
43
- /** Read the token out of an `Authorization: Bearer <token>` header. Null when absent/non-bearer. */
44
- export function readBearer(header: string | null): string | null {
45
- if (header === null) {
46
- return null
47
- }
48
- const match = /^Bearer\s+(.+)$/i.exec(header.trim())
49
- return match ? (match[1]?.trim() ?? null) : null
50
- }
51
-
52
- /** Read a single cookie value out of a raw `Cookie` header. Null when absent or malformed. */
53
- export function readCookie(header: string | null, name: string): string | null {
54
- if (!header) {
55
- return null
56
- }
57
- for (const part of header.split(';')) {
58
- const eq = part.indexOf('=')
59
- if (eq !== -1 && part.slice(0, eq).trim() === name) {
60
- const value = part.slice(eq + 1).trim()
61
- return value === '' ? null : value
62
- }
63
- }
64
- return null
65
- }