@fabrika/proxy-core 0.6.0 → 0.8.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.6.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -37,9 +37,9 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "zod": "^3.25.76",
40
- "@fabrika/auth-core": "0.6.0",
41
- "@fabrika/jwks": "0.6.0",
40
+ "@fabrika/auth-core": "0.8.0",
41
+ "@fabrika/jwks": "0.8.0",
42
42
  "jose": "^5.9.0",
43
- "@fabrika/platform": "0.6.0"
43
+ "@fabrika/platform": "0.8.0"
44
44
  }
45
45
  }
package/src/authorize.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  callerOf,
28
28
  type CompiledEndpointTable,
29
29
  FORM_MULTIPART_TYPE,
30
+ FORM_URLENCODED_TYPE,
30
31
  formatEndpointHeader,
31
32
  planEndpoint,
32
33
  satisfies,
@@ -36,7 +37,7 @@ import {
36
37
  } from '@fabrika/auth-core'
37
38
  import { cacheKey, type TokenCache } from './cache'
38
39
  import { LOGIN_REDIRECT_PARAM } from './constants'
39
- import { readBearer, readCookie } from './credentials'
40
+ import { carriesPlatformKey, readBearer, readCookie } from './credentials'
40
41
  import { createHandoffAttempt, type HandoffAttempt, handoffCookieName, setHandoffParams } from './handoff'
41
42
  import { type DelegationIssuer, type IamGateway, IamUnavailableError } from './iam'
42
43
  import {
@@ -109,6 +110,11 @@ export type Decision =
109
110
  delegation: string | null
110
111
  /** The `Access-Control-Allow-Origin` the answer carries: the request's admitted origin, `*`, or none. */
111
112
  crossOrigin: string | null
113
+ /**
114
+ * Whether the request's own `Authorization` reaches the application: on a webhook row that forwards it, unless it
115
+ * carries a platform key. Every other anonymous request loses it (ADR-0022 rule 3).
116
+ */
117
+ forwardsAuthorization: boolean
112
118
  }
113
119
  | {
114
120
  outcome: 'deny'
@@ -306,7 +312,15 @@ export class Authorizer {
306
312
  const endpoint = formatEndpointHeader(row.id, row.digest)
307
313
  if (row.requirement === null) {
308
314
  return admitBody(row, request.headers) ?? admitFormOrigin(app, row, request, crossOrigin)
309
- ?? { outcome: 'allow', token: null, subject: null, endpoint, delegation: null, crossOrigin }
315
+ ?? {
316
+ outcome: 'allow',
317
+ token: null,
318
+ subject: null,
319
+ endpoint,
320
+ delegation: null,
321
+ crossOrigin,
322
+ forwardsAuthorization: forwardsAuthorization(row, request),
323
+ }
310
324
  }
311
325
  const oauth = oauthBindingOf(app, row)
312
326
  if (oauth === 'unregistered') {
@@ -344,6 +358,7 @@ export class Authorizer {
344
358
  endpoint,
345
359
  delegation: delegation?.grant ?? null,
346
360
  crossOrigin,
361
+ forwardsAuthorization: false,
347
362
  }
348
363
  }
349
364
 
@@ -661,27 +676,41 @@ function withBearerChallenge(denial: Denial, oauth: OAuthBinding | null): Denial
661
676
  return { ...denial, challenge: denial.status === 401 ? { ...challenge, error: 'invalid_token' } : challenge }
662
677
  }
663
678
 
679
+ /** The media types a browser sends from any page without a CORS preflight, which therefore carry no origin check of their own. */
680
+ const PREFLIGHTLESS_TYPES: readonly string[] = [FORM_MULTIPART_TYPE, FORM_URLENCODED_TYPE]
681
+
664
682
  /**
665
- * A multipart form admits a browser's request only from the application's own origin or one the row admits (ADR-0022
666
- * "Cross-origin endpoints"). A page on any origin sends `multipart/form-data` without a preflight, so the proxy refuses
667
- * at the request what it refuses at the preflight of a JSON form. A request without `Origin` is no browser's
668
- * cross-origin request.
683
+ * An anonymous body a browser sends without a preflight is admitted only from the application's own origin or one the
684
+ * row admits (ADR-0022 "Cross-origin endpoints", the browser admission matrix): a multipart form, and a public
685
+ * procedure's multipart or urlencoded body. The proxy refuses at the request what it refuses at the preflight of a JSON
686
+ * body. A request without `Origin` is no browser's cross-origin request; `Origin: null` names no origin and is refused.
669
687
  */
670
688
  function admitFormOrigin(app: ProxyApp, row: ManifestEndpointRow, request: ForwardedRequest, crossOrigin: string | null): Denial | null {
671
689
  const origin = request.headers.get(ORIGIN_HEADER)
672
- if (row.kind !== 'form' || origin === null || crossOrigin !== null || !(row.admission?.contentTypes.includes(FORM_MULTIPART_TYPE) ?? false)) {
690
+ if (origin === null || crossOrigin !== null || !preflightless(row, request.headers)) {
673
691
  return null
674
692
  }
675
693
  return origin === `${app.scheme}://${request.url.host}` ? null : { outcome: 'deny', status: 403, reason: 'cross_origin' }
676
694
  }
677
695
 
696
+ /** A form that takes files (whatever this request sends), or a public procedure's request of a preflightless type. */
697
+ function preflightless(row: ManifestEndpointRow, headers: Headers): boolean {
698
+ if (row.kind === 'form') {
699
+ return row.admission?.contentTypes.includes(FORM_MULTIPART_TYPE) ?? false
700
+ }
701
+ return row.kind === 'public' && PREFLIGHTLESS_TYPES.includes(mediaTypeOf(headers))
702
+ }
703
+
704
+ function mediaTypeOf(headers: Headers): string {
705
+ return headers.get('Content-Type')?.split(';')[0]?.trim().toLowerCase() ?? ''
706
+ }
707
+
678
708
  /** An endpoint that admits a body admits only its declared media types (ADR-0022 rule 3, admission). */
679
709
  function admitBody(row: ManifestEndpointRow, headers: Headers): Denial | null {
680
710
  if (row.admission === null) {
681
711
  return null
682
712
  }
683
- const type = headers.get('Content-Type')?.split(';')[0]?.trim().toLowerCase() ?? ''
684
- return row.admission.contentTypes.includes(type) ? null : { outcome: 'deny', status: 400, reason: 'content_type' }
713
+ return row.admission.contentTypes.includes(mediaTypeOf(headers)) ? null : { outcome: 'deny', status: 400, reason: 'content_type' }
685
714
  }
686
715
 
687
716
  /**
@@ -707,3 +736,12 @@ function isDocumentNavigation(headers: Headers): boolean {
707
736
  const mode = headers.get(FETCH_MODE_HEADER)
708
737
  return mode === null || mode === '' || mode === 'navigate'
709
738
  }
739
+
740
+ /**
741
+ * The `Authorization` of an anonymous request is the application's only on a row that forwards it, and never when it
742
+ * carries a platform key. The proxy reads no credential from it either way: no key lookup, no session.
743
+ */
744
+ function forwardsAuthorization(row: ManifestEndpointRow, request: ForwardedRequest): boolean {
745
+ const authorization = request.headers.get('Authorization')
746
+ return 'authorization' in row && row.authorization === 'forward' && authorization !== null && !carriesPlatformKey(authorization, API_KEY_PREFIX)
747
+ }
package/src/constants.ts CHANGED
@@ -27,6 +27,13 @@ export const DEFAULT_VERIFY_PATH = '/verify'
27
27
  */
28
28
  export const PREFLIGHT_HEADER = 'X-Fabrika-Preflight'
29
29
 
30
+ /**
31
+ * Set on the auth service's 2xx when the request's own `Authorization` goes on to the application: an anonymous webhook
32
+ * row that forwards it. Every composition deletes `Authorization` from an anonymous request without it, and passes the
33
+ * marker on to no one.
34
+ */
35
+ export const FORWARD_AUTHORIZATION_HEADER = 'X-Fabrika-Forward-Authorization'
36
+
30
37
  /** Liveness endpoint on the auth service itself — never routed publicly. */
31
38
  export const DEFAULT_HEALTH_PATH = '/healthz'
32
39
  /**
@@ -9,6 +9,27 @@ export function readBearer(header: string | null): string | null {
9
9
  return match ? (match[1]?.trim() ?? null) : null
10
10
  }
11
11
 
12
+ /**
13
+ * Whether an `Authorization` value carries a platform key anywhere: the header itself, or the user or password of Basic
14
+ * credentials. Such a value is never forwarded to an application, whatever its row says: a key sent to a webhook URL
15
+ * by mistake must not reach application code.
16
+ */
17
+ export function carriesPlatformKey(header: string, keyPrefix: string): boolean {
18
+ if (header.includes(keyPrefix)) {
19
+ return true
20
+ }
21
+ const basic = /^Basic\s+([A-Za-z0-9+/=]+)$/i.exec(header.trim())
22
+ if (basic?.[1] === undefined) {
23
+ return false
24
+ }
25
+ try {
26
+ return atob(basic[1]).includes(keyPrefix)
27
+ } catch {
28
+ // Not base64: the application refuses it as a verifier would, and it holds no readable key.
29
+ return false
30
+ }
31
+ }
32
+
12
33
  /** Read a single cookie value out of a raw `Cookie` header. Null when absent or malformed. */
13
34
  export function readCookie(header: string | null, name: string): string | null {
14
35
  if (!header) {
package/src/headers.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // "Response headers"). The proxy sets them on every answer, its own denials included; the framework sets the same
3
3
  // definition in-process. Exported as `@fabrika/proxy-core/headers`, which imports nothing at runtime.
4
4
 
5
- import type { CspExtensions } from '@fabrika/auth-core'
5
+ import type { CspExtensions, EndpointCache } from '@fabrika/auth-core'
6
6
 
7
7
  /** Every powerful feature off. ADR-0022 lists the same names; change both together. */
8
8
  export const PERMISSIONS_POLICY_FEATURES: readonly string[] = [
@@ -54,8 +54,17 @@ export interface CspOrigins {
54
54
  readonly frameSrc: readonly string[]
55
55
  readonly styleSrc: readonly string[]
56
56
  readonly fontSrc: readonly string[]
57
+ /**
58
+ * `worker-src 'self' blob:` is emitted only when it holds `blob:`, its one source (a challenge widget that starts its
59
+ * workers from `Blob` URLs); without it workers fall back to `script-src`. Absent from every manifest without it, so a
60
+ * proxy that does not know the directive still parses those.
61
+ */
62
+ readonly workerSrc?: readonly string[]
57
63
  }
58
64
 
65
+ /** The one source a contract's `workerSrc` holds: a worker the page's own script built from a `Blob`. */
66
+ export const WORKER_BLOB_SOURCE = 'blob:'
67
+
59
68
  const HOSTNAME = /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/
60
69
 
61
70
  /**
@@ -93,6 +102,7 @@ export function cspOrigins(csp: CspExtensions): CspOrigins {
93
102
  frameSrc: (csp.frameSrc ?? []).map((source) => source.origin),
94
103
  styleSrc: (csp.styleSrc ?? []).map((source) => source.origin),
95
104
  fontSrc: (csp.fontSrc ?? []).map((source) => source.origin),
105
+ ...(csp.workerSrc === undefined ? {} : { workerSrc: csp.workerSrc.map((source) => source.origin) }),
96
106
  }
97
107
  }
98
108
 
@@ -116,6 +126,7 @@ export function headerPolicy(origins: CspOrigins, operationsOrigin: string | nul
116
126
  `frame-ancestors ${frameAncestors.join(' ')}`,
117
127
  // Without it frames fall back to `default-src 'self'`; a declared origin only extends that, so `'self'` stays.
118
128
  ...(origins.frameSrc.length === 0 ? [] : [`frame-src ${["'self'", ...origins.frameSrc].join(' ')}`]),
129
+ ...(origins.workerSrc?.includes(WORKER_BLOB_SOURCE) === true ? [`worker-src 'self' ${WORKER_BLOB_SOURCE}`] : []),
119
130
  ]
120
131
  return Object.freeze([
121
132
  ['Content-Security-Policy', directives.join('; ')],
@@ -257,6 +268,21 @@ export function applyFileTreatment(headers: Headers, treatment: HeaderPolicy = F
257
268
  headers.set('Content-Disposition', `${fileDisposition(headers.get('Content-Type'))}${parameters}`)
258
269
  }
259
270
 
271
+ /**
272
+ * A cacheable row's successful answer (`http.cache`): its `Cache-Control` replaces the `no-store` of the policy or the
273
+ * file treatment. Every other answer, an error or a redirect included, keeps `no-store`.
274
+ */
275
+ export function applyAnswerCache(headers: Headers, row: { readonly cache?: EndpointCache }, status: number): void {
276
+ if (row.cache !== undefined && status === 200) {
277
+ headers.set('Cache-Control', cacheControlOf(row.cache))
278
+ }
279
+ }
280
+
281
+ /** The `Cache-Control` of a cacheable row's successful answer: `immutable` is reused for a year. */
282
+ export function cacheControlOf(cache: EndpointCache): string {
283
+ return cache === 'immutable' ? 'public, max-age=31536000, immutable' : `public, max-age=${cache.maxAgeSeconds}`
284
+ }
285
+
260
286
  /** A copy of `response` whose headers `apply` rewrote; status and body are unchanged. */
261
287
  export function withResponseHeaders(response: Response, apply: (headers: Headers) => void): Response {
262
288
  const headers = new Headers(response.headers)
package/src/index.ts CHANGED
@@ -22,6 +22,7 @@ export {
22
22
  DEFAULT_VERIFY_PATH,
23
23
  DELEGATION_HEADER,
24
24
  ENDPOINT_HEADER,
25
+ FORWARD_AUTHORIZATION_HEADER,
25
26
  FORWARDED_HOST_HEADER,
26
27
  FORWARDED_METHOD_HEADER,
27
28
  FORWARDED_PROTO_HEADER,
@@ -33,7 +34,7 @@ export {
33
34
  REQUEST_ID_HEADER,
34
35
  UNTRUSTED_FORWARD_HEADERS,
35
36
  } from './constants'
36
- export { readBearer, readCookie } from './credentials'
37
+ export { carriesPlatformKey, readBearer, readCookie } from './credentials'
37
38
  export { stripFabrikaCookies } from './handoff'
38
39
  export { HttpIamGateway, IamUnavailableError } from './iam'
39
40
  export type { DelegationIssuer, DelegationRequest, HttpIamGatewayOptions, IamGateway } from './iam'
package/src/manifest.ts CHANGED
@@ -12,14 +12,16 @@ import {
12
12
  endpointDelegation,
13
13
  type EndpointRow,
14
14
  EndpointTableError,
15
+ FILE_RESPONSE_KINDS,
15
16
  type InstallationEndpointRow,
16
17
  type InstallationEndpointTable,
17
18
  isDigest,
19
+ MAX_CACHE_AGE_SECONDS,
18
20
  parseAppEnvironmentId,
19
21
  requirementDigest,
20
22
  } from '@fabrika/auth-core'
21
23
  import { z } from 'zod'
22
- import { type CspOrigins, cspOrigins, isCrossOrigin, isPolicyOrigin, MAX_CROSS_ORIGINS, MAX_CSP_ORIGINS } from './headers'
24
+ import { type CspOrigins, cspOrigins, isCrossOrigin, isPolicyOrigin, MAX_CROSS_ORIGINS, MAX_CSP_ORIGINS, WORKER_BLOB_SOURCE } from './headers'
23
25
  import { type OAuthProtectedResource, oauthProtectedResourceSchema, oauthScopesSchema } from './oauth-resource'
24
26
 
25
27
  export * from './oauth-resource'
@@ -92,9 +94,15 @@ interface ProxyEndpointExtras {
92
94
 
93
95
  /** An endpoint row as the proxy applies it: the rules without the schemas, plus the digest and the delegation. */
94
96
  export type ProxyEndpointRow =
95
- | (Pick<CredentialedEndpoint, 'id' | 'kind' | 'method' | 'path' | 'callers' | 'requirement' | 'admission' | 'crossOrigin'> & ProxyEndpointExtras)
96
97
  | (
97
- & Pick<Extract<EndpointRow, { requirement: null }>, 'id' | 'kind' | 'method' | 'path' | 'callers' | 'requirement' | 'admission' | 'crossOrigin'>
98
+ & Pick<CredentialedEndpoint, 'id' | 'kind' | 'method' | 'path' | 'callers' | 'requirement' | 'admission' | 'crossOrigin' | 'response'>
99
+ & ProxyEndpointExtras
100
+ )
101
+ | (
102
+ & Pick<
103
+ Extract<EndpointRow, { requirement: null }>,
104
+ 'id' | 'kind' | 'method' | 'path' | 'callers' | 'requirement' | 'admission' | 'crossOrigin' | 'response' | 'cache' | 'authorization'
105
+ >
98
106
  & {
99
107
  digest: string
100
108
  delegation: null
@@ -156,9 +164,19 @@ const accessFormSchema = z.discriminatedUnion('form', [
156
164
  z.object({ form: z.literal('creates'), dimension: nameSchema }).strict(),
157
165
  z.object({ form: z.literal('app') }).strict(),
158
166
  ])
167
+ const capabilityFormSchema = z.discriminatedUnion('form', [
168
+ z.object({ form: z.literal('over'), dimension: nameSchema }).strict(),
169
+ z.object({ form: z.literal('in'), dimension: nameSchema, from: nameSchema }).strict(),
170
+ z.object({ form: z.literal('on'), dimension: nameSchema, from: nameSchema }).strict(),
171
+ ])
172
+ const capabilitySchema = z.object({ action: nameSchema, access: capabilityFormSchema }).strict()
159
173
  const requirementSchema = z.discriminatedUnion('kind', [
160
174
  z.object({ kind: z.literal('member') }).strict(),
161
175
  z.object({ kind: z.literal('action'), action: nameSchema, access: accessFormSchema }).strict(),
176
+ z.object({
177
+ kind: z.literal('capabilities'),
178
+ capabilities: z.record(nameSchema, capabilitySchema).refine((capabilities) => Object.keys(capabilities).length >= 2, 'at least two capabilities'),
179
+ }).strict(),
162
180
  ])
163
181
  const admissionSchema = z.object({
164
182
  contentTypes: z.array(nameSchema).nonempty(),
@@ -180,14 +198,18 @@ export const proxyEndpointRowSchema = z.union([
180
198
  requirement: requirementSchema,
181
199
  delegation: z.enum(['members', 'creates']).nullable(),
182
200
  crossOrigin: z.literal('environment').optional(),
201
+ response: z.literal('file').optional(),
183
202
  }).strict(),
184
203
  z.object({
185
204
  ...endpointRuleFields,
186
- kind: z.enum(['health', 'form', 'webhook', 'link-spa']),
205
+ kind: z.enum(['health', 'form', 'webhook', 'link-spa', 'public']),
187
206
  callers: z.tuple([]),
188
207
  requirement: z.null(),
189
208
  delegation: z.null(),
190
209
  crossOrigin: z.enum(['environment', 'any']).optional(),
210
+ response: z.literal('file').optional(),
211
+ cache: z.union([z.literal('immutable'), z.object({ maxAgeSeconds: z.number().int().min(1).max(MAX_CACHE_AGE_SECONDS) }).strict()]).optional(),
212
+ authorization: z.literal('forward').optional(),
191
213
  }).strict(),
192
214
  // A public SPA is a fixed static row: it serves GET only and admits no body.
193
215
  z.object({
@@ -202,6 +224,15 @@ export const proxyEndpointRowSchema = z.union([
202
224
  ]).refine(
203
225
  (row) => crossOriginAllowed(row.kind, 'crossOrigin' in row ? row.crossOrigin : undefined),
204
226
  'this endpoint kind cannot be called cross-origin',
227
+ ).refine(
228
+ (row) => !('response' in row) || row.response === undefined || FILE_RESPONSE_KINDS.includes(row.kind),
229
+ 'only a procedure answers a stored file',
230
+ ).refine(
231
+ (row) => !('cache' in row) || row.cache === undefined || (row.kind === 'public' && row.method === 'GET'),
232
+ 'only a public GET query caches its answer',
233
+ ).refine(
234
+ (row) => !('authorization' in row) || row.authorization === undefined || row.kind === 'webhook',
235
+ "only a webhook forwards the request's Authorization",
205
236
  ).refine(
206
237
  (row) => row.digest === requirementDigest(row),
207
238
  'endpoint digest does not match its rules',
@@ -211,7 +242,7 @@ export const proxyEndpointRowSchema = z.union([
211
242
  unknown
212
243
  >
213
244
 
214
- /** Only an RPC procedure and a form open to another origin, and only a form to any origin (ADR-0022). */
245
+ /** Only an RPC procedure, a public procedure and a form open to another origin, and only the anonymous two to any (ADR-0022). */
215
246
  function crossOriginAllowed(kind: ProxyEndpointRow['kind'], form: CrossOriginForm | undefined): boolean {
216
247
  return form === undefined || (CROSS_ORIGIN_KINDS.includes(kind) && (form !== 'any' || ANY_ORIGIN_KINDS.includes(kind)))
217
248
  }
@@ -236,6 +267,8 @@ const cspOriginsSchema = z.object({
236
267
  frameSrc: cspDirectiveSchema.default([]),
237
268
  styleSrc: cspDirectiveSchema.default([]),
238
269
  fontSrc: cspDirectiveSchema.default([]),
270
+ // Only a challenge widget's workers need it, and its one source is `blob:`; absent everywhere else.
271
+ workerSrc: z.tuple([z.literal(WORKER_BLOB_SOURCE)]).optional(),
239
272
  }).strict() satisfies z.ZodType<CspOrigins, z.ZodTypeDef, unknown>
240
273
 
241
274
  // Strict: a key the proxy does not read (`gates`, or an installation entry's field) is refused, never ignored.
@@ -339,8 +372,10 @@ export const proxyAppSchema = z.union([contractProxyAppSchema, installationProxy
339
372
  /**
340
373
  * The manifest form of a compiled contract: every endpoint's rules with their digest and delegation, the reserved
341
374
  * prefixes, the contract digest and the named CSP origins. Producers call this; they never assemble rows by hand.
375
+ * `digest` is the one the application's own release computes; Control passes it for an artifact it upcast from an
376
+ * older release's (ADR-0062).
342
377
  */
343
- export function contractProxyTable(artifact: ContractArtifact): ContractProxyTable {
378
+ export function contractProxyTable(artifact: ContractArtifact, digest: string = contractDigest(artifact)): ContractProxyTable {
344
379
  return {
345
380
  endpoints: artifact.endpoints.map((row): ProxyEndpointRow => {
346
381
  const rules = {
@@ -349,9 +384,12 @@ export function contractProxyTable(artifact: ContractArtifact): ContractProxyTab
349
384
  path: row.path,
350
385
  admission: row.admission,
351
386
  ...(row.crossOrigin === undefined ? {} : { crossOrigin: row.crossOrigin }),
387
+ ...(row.response === undefined ? {} : { response: row.response }),
352
388
  }
353
389
  if (row.requirement === null) {
354
- const anonymous = { ...rules, kind: row.kind, callers: row.callers, requirement: null, delegation: null }
390
+ const cache = row.cache === undefined ? {} : { cache: row.cache }
391
+ const authorization = row.authorization === undefined ? {} : { authorization: row.authorization }
392
+ const anonymous = { ...rules, ...cache, ...authorization, kind: row.kind, callers: row.callers, requirement: null, delegation: null }
355
393
  return { ...anonymous, digest: requirementDigest(anonymous) }
356
394
  }
357
395
  const credentialed = {
@@ -364,7 +402,7 @@ export function contractProxyTable(artifact: ContractArtifact): ContractProxyTab
364
402
  return { ...credentialed, digest: requirementDigest(credentialed) }
365
403
  }),
366
404
  reservedPrefixes: [...artifact.reservedPrefixes],
367
- snapshot: contractDigest(artifact),
405
+ snapshot: digest,
368
406
  csp: cspOrigins(artifact.csp),
369
407
  }
370
408
  }
package/src/service.ts CHANGED
@@ -22,6 +22,7 @@
22
22
  */
23
23
 
24
24
  import {
25
+ answersFile,
25
26
  AUTH_CALLBACK_PATH,
26
27
  compileEndpointTable,
27
28
  DELEGATION_HEADER,
@@ -39,6 +40,7 @@ import {
39
40
  APP_QUERY_PARAM,
40
41
  DEFAULT_HEALTH_PATH,
41
42
  DEFAULT_VERIFY_PATH,
43
+ FORWARD_AUTHORIZATION_HEADER,
42
44
  FORWARDED_HOST_HEADER,
43
45
  FORWARDED_METHOD_HEADER,
44
46
  FORWARDED_PROTO_HEADER,
@@ -183,6 +185,9 @@ export function createVerifyService(config: VerifyServiceConfig): VerifyService
183
185
  if (decision.delegation !== null) {
184
186
  headers.set(DELEGATION_HEADER, decision.delegation)
185
187
  }
188
+ if (decision.forwardsAuthorization) {
189
+ headers.set(FORWARD_AUTHORIZATION_HEADER, '1')
190
+ }
186
191
  // The composition writes it onto the application's answer, after the policy deleted the application's own.
187
192
  if (decision.crossOrigin !== null) {
188
193
  applyCrossOriginHeaders(headers, crossOriginHeaders(decision.crossOrigin))
@@ -244,10 +249,13 @@ function withFileTreatment(response: Response, treatment: HeaderPolicy): Respons
244
249
  return response
245
250
  }
246
251
 
247
- /** Whether the request selects a download endpoint, whose every answer gets the file treatment instead of the policy. */
252
+ /**
253
+ * Whether the request selects a download endpoint or a procedure that answers a stored file, whose every answer gets
254
+ * the file treatment instead of the policy.
255
+ */
248
256
  function isDownload(resolved: ResolvedApp, forwarded: ForwardedRequest): boolean {
249
257
  const plan = planEndpoint(resolved.table, forwarded.method, forwarded.rawPath)
250
- return plan.outcome === 'endpoint' && plan.endpoint.kind === 'download'
258
+ return plan.outcome === 'endpoint' && answersFile(plan.endpoint)
251
259
  }
252
260
 
253
261
  /**