@fabrika/proxy-core 0.5.2 → 0.5.3

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.5.2",
3
+ "version": "0.5.3",
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.5.2",
41
- "@fabrika/jwks": "0.5.2",
40
+ "@fabrika/auth-core": "0.5.3",
41
+ "@fabrika/jwks": "0.5.3",
42
42
  "jose": "^5.9.0",
43
- "@fabrika/platform": "0.5.2"
43
+ "@fabrika/platform": "0.5.3"
44
44
  }
45
45
  }
package/src/authorize.ts CHANGED
@@ -2,7 +2,8 @@
2
2
  * The proxy authorization core ([ADR-0022](../../../docs/decisions/0022-the-proxy-is-the-only-enforcement-point.md)).
3
3
  *
4
4
  * Every entry — a contract application or an installation service — is protected by its endpoint table, in one pass
5
- * over ONE selected endpoint: `planEndpoint` → a `px_` key whose caller kind the endpoint admits, or else the session
5
+ * over ONE selected endpoint. A contract application's preflight is decided first, from the endpoint the requested
6
+ * method selects, and never reaches a credential. Then: `planEndpoint` → a `px_` key whose caller kind the endpoint admits, or else the session
6
7
  * when `human` may call → the requirement (`satisfies`) → admission → the delegation grant. There is no fall-through
7
8
  * to another endpoint. On an installation row that admits `oauth`, a bearer that is not a `px_` key is an OAuth
8
9
  * resource token, verified locally. A key or a session is exchanged with IAM once, cached, and thereafter verified
@@ -37,7 +38,14 @@ import { LOGIN_REDIRECT_PARAM } from './constants'
37
38
  import { readBearer, readCookie } from './credentials'
38
39
  import { createHandoffAttempt, type HandoffAttempt, handoffCookieName, setHandoffParams } from './handoff'
39
40
  import { type DelegationIssuer, type IamGateway, IamUnavailableError } from './iam'
40
- import { type ManifestEndpointRow, type OAuthBearerChallenge, type OAuthProtectedResource, oauthResourceOfRow, type ProxyApp } from './manifest'
41
+ import {
42
+ isApplicationEntry,
43
+ type ManifestEndpointRow,
44
+ type OAuthBearerChallenge,
45
+ type OAuthProtectedResource,
46
+ oauthResourceOfRow,
47
+ type ProxyApp,
48
+ } from './manifest'
41
49
  import { TokenVerifier } from './verifier'
42
50
 
43
51
  /** Every way a request can be refused. Coarse on purpose — these are logged, not returned to clients. */
@@ -69,6 +77,8 @@ export type DenyReason =
69
77
  | 'cross_site'
70
78
  /** The request's `Content-Type` is not one the endpoint admits. */
71
79
  | 'content_type'
80
+ /** A preflight for an endpoint the contract does not open to another origin, or from an origin it does not admit. */
81
+ | 'cross_origin'
72
82
  /** IAM refused the delegation grant a membership or scope-creating endpoint needs. */
73
83
  | 'delegation_refused'
74
84
  /** The `Host` maps to no app in the manifest. */
@@ -93,6 +103,8 @@ export type Decision =
93
103
  endpoint: string
94
104
  /** The single-use delegation grant for `X-Fabrika-Delegation`, when the endpoint spends one. */
95
105
  delegation: string | null
106
+ /** The `Access-Control-Allow-Origin` the answer carries: the request's admitted origin, `*`, or none. */
107
+ crossOrigin: string | null
96
108
  }
97
109
  | {
98
110
  outcome: 'deny'
@@ -101,7 +113,14 @@ export type Decision =
101
113
  clearHandoffState?: string
102
114
  /** Set on an `oauth` row's 401/403: rendered as the RFC 6750 `WWW-Authenticate` challenge. */
103
115
  challenge?: OAuthBearerChallenge
116
+ /** Set when the request's origin is admitted, so the page can read why it was refused. */
117
+ crossOrigin?: string
104
118
  }
119
+ /**
120
+ * A contract application's preflight that the selected endpoint admits (ADR-0022 "Cross-origin endpoints"). The
121
+ * proxy answers it itself; it never reaches the application. `method` is the endpoint's.
122
+ */
123
+ | { outcome: 'preflight'; allowOrigin: string; method: string }
105
124
  /**
106
125
  * A human reached an endpoint `human` may call without a usable session. `location` is where they must go to sign
107
126
  * in; `status` is only how they are TOLD — 302 for a document navigation, 401 for anything else,
@@ -159,6 +178,12 @@ const FETCH_MODE_HEADER = 'Sec-Fetch-Mode'
159
178
  /** Fetch metadata the browser writes and page script cannot: `same-origin` only for a request from this origin. */
160
179
  const FETCH_SITE_HEADER = 'Sec-Fetch-Site'
161
180
 
181
+ /** The origin a browser names on every CORS request; page script cannot set it. */
182
+ const ORIGIN_HEADER = 'Origin'
183
+
184
+ /** What makes an `OPTIONS` request a preflight: the method the page wants to send. */
185
+ const PREFLIGHT_METHOD_HEADER = 'Access-Control-Request-Method'
186
+
162
187
  /** Why a session attempt failed — mirrors `mintToken`'s reasons. */
163
188
  type SessionFailureReason = 'no_session' | 'invalid_session' | 'unknown_principal' | 'disabled'
164
189
 
@@ -230,6 +255,10 @@ export class Authorizer {
230
255
  table: CompiledEndpointTable<ManifestEndpointRow>,
231
256
  request: ForwardedRequest,
232
257
  ): Promise<Decision> {
258
+ const preflight = this.decidePreflight(table, app, request)
259
+ if (preflight !== null) {
260
+ return preflight
261
+ }
233
262
  const plan = planEndpoint(table, request.method, request.rawPath)
234
263
  if (plan.outcome === 'non-canonical') {
235
264
  return { outcome: 'deny', status: 400, reason: 'non_canonical_path' }
@@ -238,9 +267,41 @@ export class Authorizer {
238
267
  return { outcome: 'deny', status: 403, reason: 'no_endpoint' }
239
268
  }
240
269
  const row = plan.endpoint
270
+ const origin = request.headers.get(ORIGIN_HEADER)
271
+ const crossOrigin = origin === null ? null : admittedOrigin(app, row, origin)
272
+ const decision = await this.decideRow(app, row, request, crossOrigin)
273
+ return crossOrigin === null || decision.outcome !== 'deny' ? decision : { ...decision, crossOrigin }
274
+ }
275
+
276
+ /**
277
+ * A preflight is decided from the endpoint the REQUESTED method selects, before any credential: it carries none
278
+ * (ADR-0022 "Cross-origin endpoints"). Only a contract application's: an installation row of method `*` serves
279
+ * `OPTIONS` itself.
280
+ */
281
+ private decidePreflight(table: CompiledEndpointTable<ManifestEndpointRow>, app: ProxyApp, request: ForwardedRequest): Decision | null {
282
+ const origin = request.headers.get(ORIGIN_HEADER)
283
+ const method = request.headers.get(PREFLIGHT_METHOD_HEADER)
284
+ if (!isApplicationEntry(app) || request.method !== 'OPTIONS' || origin === null || method === null) {
285
+ return null
286
+ }
287
+ const plan = planEndpoint(table, method, request.rawPath)
288
+ if (plan.outcome === 'non-canonical') {
289
+ return { outcome: 'deny', status: 400, reason: 'non_canonical_path' }
290
+ }
291
+ if (plan.outcome === 'no-endpoint') {
292
+ return { outcome: 'deny', status: 403, reason: 'no_endpoint' }
293
+ }
294
+ const allowOrigin = admittedOrigin(app, plan.endpoint, origin)
295
+ return allowOrigin === null
296
+ ? { outcome: 'deny', status: 403, reason: 'cross_origin' }
297
+ : { outcome: 'preflight', allowOrigin, method: plan.endpoint.method }
298
+ }
299
+
300
+ /** The selected row's checks. `crossOrigin` is the admitted origin of a cross-origin request, `*` or none. */
301
+ private async decideRow(app: ProxyApp, row: ManifestEndpointRow, request: ForwardedRequest, crossOrigin: string | null): Promise<Decision> {
241
302
  const endpoint = formatEndpointHeader(row.id, row.digest)
242
303
  if (row.requirement === null) {
243
- return admitBody(row, request.headers) ?? { outcome: 'allow', token: null, subject: null, endpoint, delegation: null }
304
+ return admitBody(row, request.headers) ?? { outcome: 'allow', token: null, subject: null, endpoint, delegation: null, crossOrigin }
244
305
  }
245
306
  const oauth = oauthBindingOf(app, row)
246
307
  if (oauth === 'unregistered') {
@@ -248,7 +309,8 @@ export class Authorizer {
248
309
  return { outcome: 'deny', status: 403, reason: 'internal_error' }
249
310
  }
250
311
  const callers: readonly TokenCaller[] = row.callers
251
- const caller = await this.endpointCaller(app, oauth, callers, request)
312
+ // A listed origin's request is never session-authenticated: a session does not cross origins.
313
+ const caller = await this.endpointCaller(app, oauth, callers, request, crossOrigin !== null)
252
314
  if (!caller.ok) {
253
315
  return caller.decision
254
316
  }
@@ -276,6 +338,7 @@ export class Authorizer {
276
338
  subject: caller.claims.sub,
277
339
  endpoint,
278
340
  delegation: delegation?.grant ?? null,
341
+ crossOrigin,
279
342
  }
280
343
  }
281
344
 
@@ -288,6 +351,7 @@ export class Authorizer {
288
351
  oauth: OAuthBinding | null,
289
352
  callers: readonly TokenCaller[],
290
353
  request: ForwardedRequest,
354
+ crossOrigin: boolean,
291
355
  ): Promise<EndpointCaller> {
292
356
  const authorization = request.headers.get('Authorization')
293
357
  if (authorization !== null && authorization.trim() !== '') {
@@ -301,8 +365,9 @@ export class Authorizer {
301
365
  ? { ok: true, token: key.token, claims: key.claims, session: false }
302
366
  : { ok: false, decision: withBearerChallenge(key.denial, oauth) }
303
367
  }
304
- if (!callers.includes('human')) {
305
- // No session reaches an endpoint a person may not call, so there is nothing to sign in to.
368
+ if (!callers.includes('human') || crossOrigin) {
369
+ // No session reaches an endpoint a person may not call, nor one called from another origin, so there is
370
+ // nothing to sign in to.
306
371
  const denial: Denial = { outcome: 'deny', status: 401, reason: 'no_credential' }
307
372
  return { ok: false, decision: oauth === null ? denial : { ...denial, challenge: { resource_metadata: oauth.resource.resourceMetadata } } }
308
373
  }
@@ -549,6 +614,19 @@ export class Authorizer {
549
614
  }
550
615
  }
551
616
 
617
+ /**
618
+ * The `Access-Control-Allow-Origin` a request from `origin` gets on `row`: `*` on a row open to any origin, the origin
619
+ * itself when the row takes the environment's list and the entry lists it, and `null` otherwise. An installation row
620
+ * has no cross-origin form.
621
+ */
622
+ function admittedOrigin(app: ProxyApp, row: ManifestEndpointRow, origin: string): string | null {
623
+ const form = 'crossOrigin' in row ? row.crossOrigin : undefined
624
+ if (form === 'any') {
625
+ return '*'
626
+ }
627
+ return form === 'environment' && isApplicationEntry(app) && (app.crossOrigins ?? []).includes(origin) ? origin : null
628
+ }
629
+
552
630
  function invalidKey(): Denial {
553
631
  return { outcome: 'deny', status: 401, reason: 'invalid_key' }
554
632
  }
package/src/constants.ts CHANGED
@@ -21,6 +21,12 @@ export const REQUEST_ID_HEADER = 'X-Request-Id'
21
21
 
22
22
  /** The auth service's decision endpoint (Caddy's `uri` subdirective). */
23
23
  export const DEFAULT_VERIFY_PATH = '/verify'
24
+ /**
25
+ * Set on the auth service's 2xx to a preflight it admitted (ADR-0022 "Cross-origin endpoints"). The answer is the
26
+ * preflight's whole answer: the composition returns it to the browser, without the marker, and never dials the app.
27
+ */
28
+ export const PREFLIGHT_HEADER = 'X-Fabrika-Preflight'
29
+
24
30
  /** Liveness endpoint on the auth service itself — never routed publicly. */
25
31
  export const DEFAULT_HEALTH_PATH = '/healthz'
26
32
  /**
package/src/headers.ts CHANGED
@@ -175,7 +175,62 @@ export function appFileTreatment(app: { readonly scheme: PublicScheme }): Header
175
175
  * Headers an application may not send at all: each names a reporting endpoint the policy never approved, and a
176
176
  * report-only CSP reports there whatever the enforced one says (`document-uri`, `blocked-uri`).
177
177
  */
178
- export const REMOVED_RESPONSE_HEADERS: readonly string[] = ['Content-Security-Policy-Report-Only', 'Report-To', 'Reporting-Endpoints', 'NEL']
178
+ export const REMOVED_RESPONSE_HEADERS: readonly string[] = [
179
+ 'Content-Security-Policy-Report-Only',
180
+ 'Report-To',
181
+ 'Reporting-Endpoints',
182
+ 'NEL',
183
+ // Only the proxy opens an endpoint to another origin (ADR-0022 "Cross-origin endpoints"); a handler's own never passes.
184
+ 'Access-Control-Allow-Origin',
185
+ 'Access-Control-Allow-Credentials',
186
+ 'Access-Control-Allow-Methods',
187
+ 'Access-Control-Allow-Headers',
188
+ 'Access-Control-Expose-Headers',
189
+ 'Access-Control-Max-Age',
190
+ ]
191
+
192
+ /** The most origins one environment may list for its cross-origin endpoints: Control and the manifest parser refuse more. */
193
+ export const MAX_CROSS_ORIGINS = 16
194
+
195
+ /**
196
+ * An origin an environment may list for its cross-origin endpoints: exact, a literal host, `https:` (or `http:`, which
197
+ * only a local host is served over). `null`, an opaque origin, never matches.
198
+ */
199
+ export function isCrossOrigin(value: string): boolean {
200
+ return isPolicyOrigin(value, ['https:', 'http:'])
201
+ }
202
+
203
+ /** What a cross-origin page may send beside the safelisted headers: the key, and the JSON body's type. */
204
+ export const CROSS_ORIGIN_REQUEST_HEADERS = 'Authorization, Content-Type'
205
+
206
+ /** How long a browser may reuse an answered preflight, in seconds. */
207
+ export const CROSS_ORIGIN_MAX_AGE = '600'
208
+
209
+ /**
210
+ * The headers an answer to an admitted cross-origin request carries: the admitted origin, and `Vary: Origin` unless every
211
+ * origin is admitted. Never `Access-Control-Allow-Credentials`: a session does not cross origins.
212
+ */
213
+ export function crossOriginHeaders(allowOrigin: string): HeaderPolicy {
214
+ return allowOrigin === '*' ? [['Access-Control-Allow-Origin', '*']] : [['Access-Control-Allow-Origin', allowOrigin], ['Vary', 'Origin']]
215
+ }
216
+
217
+ /** A preflight's answer on top of `crossOriginHeaders`: the endpoint's one method, the two headers, the cache lifetime. */
218
+ export function preflightHeaders(allowOrigin: string, method: string): HeaderPolicy {
219
+ return [
220
+ ...crossOriginHeaders(allowOrigin),
221
+ ['Access-Control-Allow-Methods', method],
222
+ ['Access-Control-Allow-Headers', CROSS_ORIGIN_REQUEST_HEADERS],
223
+ ['Access-Control-Max-Age', CROSS_ORIGIN_MAX_AGE],
224
+ ]
225
+ }
226
+
227
+ /** `crossOriginHeaders` over headers that already carry the answer's own policy; `Vary` is added to, never replaced. */
228
+ export function applyCrossOriginHeaders(headers: Headers, policy: HeaderPolicy): void {
229
+ for (const [name, value] of policy) {
230
+ if (name === 'Vary') headers.append(name, value)
231
+ else headers.set(name, value)
232
+ }
233
+ }
179
234
 
180
235
  /** `null` and anything but one allowlisted value is an attachment; `Headers` joins repeated fields with `, `, so two never match. */
181
236
  export function fileDisposition(contentType: string | null): 'inline' | 'attachment' {
package/src/index.ts CHANGED
@@ -28,6 +28,7 @@ export {
28
28
  FORWARDED_URI_HEADER,
29
29
  JWKS_TTL_SECONDS,
30
30
  LOGIN_REDIRECT_PARAM,
31
+ PREFLIGHT_HEADER,
31
32
  PROXY_TOKEN_HEADER,
32
33
  REQUEST_ID_HEADER,
33
34
  UNTRUSTED_FORWARD_HEADERS,
package/src/manifest.ts CHANGED
@@ -1,10 +1,13 @@
1
1
  import {
2
+ ANY_ORIGIN_KINDS,
2
3
  APP_ENVIRONMENT_SEPARATOR,
3
4
  type Caller,
4
5
  compileEndpointTable,
5
6
  type ContractArtifact,
6
7
  contractDigest,
7
8
  type CredentialedEndpoint,
9
+ CROSS_ORIGIN_KINDS,
10
+ type CrossOriginForm,
8
11
  type EndpointDelegation,
9
12
  endpointDelegation,
10
13
  type EndpointRow,
@@ -16,7 +19,7 @@ import {
16
19
  requirementDigest,
17
20
  } from '@fabrika/auth-core'
18
21
  import { z } from 'zod'
19
- import { type CspOrigins, cspOrigins, isPolicyOrigin, MAX_CSP_ORIGINS } from './headers'
22
+ import { type CspOrigins, cspOrigins, isCrossOrigin, isPolicyOrigin, MAX_CROSS_ORIGINS, MAX_CSP_ORIGINS } from './headers'
20
23
  import { type OAuthProtectedResource, oauthProtectedResourceSchema, oauthScopesSchema } from './oauth-resource'
21
24
 
22
25
  export * from './oauth-resource'
@@ -46,6 +49,11 @@ export interface ContractProxyApp extends ProxyAppBase {
46
49
  csp: CspOrigins
47
50
  /** The installation's Operations ingest origin, admitted by `connect-src`; absent when the producer knows none. */
48
51
  operationsOrigin?: string
52
+ /**
53
+ * The origins the environment lists for its `crossOrigin: 'environment'` endpoints (ADR-0022 "Cross-origin
54
+ * endpoints"), from Control's registry, never from the contract; absent when it lists none.
55
+ */
56
+ crossOrigins?: string[]
49
57
  }
50
58
 
51
59
  /**
@@ -84,11 +92,14 @@ interface ProxyEndpointExtras {
84
92
 
85
93
  /** An endpoint row as the proxy applies it: the rules without the schemas, plus the digest and the delegation. */
86
94
  export type ProxyEndpointRow =
87
- | (Pick<CredentialedEndpoint, 'id' | 'kind' | 'method' | 'path' | 'callers' | 'requirement' | 'admission'> & ProxyEndpointExtras)
88
- | (Pick<Extract<EndpointRow, { requirement: null }>, 'id' | 'kind' | 'method' | 'path' | 'callers' | 'requirement' | 'admission'> & {
89
- digest: string
90
- delegation: null
91
- })
95
+ | (Pick<CredentialedEndpoint, 'id' | 'kind' | 'method' | 'path' | 'callers' | 'requirement' | 'admission' | 'crossOrigin'> & ProxyEndpointExtras)
96
+ | (
97
+ & Pick<Extract<EndpointRow, { requirement: null }>, 'id' | 'kind' | 'method' | 'path' | 'callers' | 'requirement' | 'admission' | 'crossOrigin'>
98
+ & {
99
+ digest: string
100
+ delegation: null
101
+ }
102
+ )
92
103
 
93
104
  /** An installation row as the proxy applies it: the row with its digest and no delegation. */
94
105
  export type InstallationProxyEndpointRow = InstallationEndpointRow & { digest: string; delegation: null }
@@ -168,6 +179,7 @@ export const proxyEndpointRowSchema = z.union([
168
179
  callers: z.array(callerSchema).nonempty(),
169
180
  requirement: requirementSchema,
170
181
  delegation: z.enum(['members', 'creates']).nullable(),
182
+ crossOrigin: z.literal('environment').optional(),
171
183
  }).strict(),
172
184
  z.object({
173
185
  ...endpointRuleFields,
@@ -175,6 +187,7 @@ export const proxyEndpointRowSchema = z.union([
175
187
  callers: z.tuple([]),
176
188
  requirement: z.null(),
177
189
  delegation: z.null(),
190
+ crossOrigin: z.enum(['environment', 'any']).optional(),
178
191
  }).strict(),
179
192
  // A public SPA is a fixed static row: it serves GET only and admits no body.
180
193
  z.object({
@@ -186,12 +199,32 @@ export const proxyEndpointRowSchema = z.union([
186
199
  requirement: z.null(),
187
200
  delegation: z.null(),
188
201
  }).strict(),
189
- ]).refine((row) => row.digest === requirementDigest(row), 'endpoint digest does not match its rules') satisfies z.ZodType<
202
+ ]).refine(
203
+ (row) => crossOriginAllowed(row.kind, 'crossOrigin' in row ? row.crossOrigin : undefined),
204
+ 'this endpoint kind cannot be called cross-origin',
205
+ ).refine(
206
+ (row) => row.digest === requirementDigest(row),
207
+ 'endpoint digest does not match its rules',
208
+ ) satisfies z.ZodType<
190
209
  ProxyEndpointRow,
191
210
  z.ZodTypeDef,
192
211
  unknown
193
212
  >
194
213
 
214
+ /** Only an RPC procedure and a form open to another origin, and only a form to any origin (ADR-0022). */
215
+ function crossOriginAllowed(kind: ProxyEndpointRow['kind'], form: CrossOriginForm | undefined): boolean {
216
+ return form === undefined || (CROSS_ORIGIN_KINDS.includes(kind) && (form !== 'any' || ANY_ORIGIN_KINDS.includes(kind)))
217
+ }
218
+
219
+ /** Exact origins, each once, never one of the entry's own hosts: its own pages are not cross-origin. */
220
+ function checkCrossOrigins(app: Pick<ContractProxyApp, 'hosts' | 'crossOrigins'>, ctx: z.RefinementCtx): void {
221
+ const origins = app.crossOrigins ?? []
222
+ // A refinement runs over an origin its element check already refused, so the host is read without throwing.
223
+ if (new Set(origins).size !== origins.length || origins.some((origin) => !isCrossOrigin(origin) || app.hosts.includes(new URL(origin).hostname))) {
224
+ ctx.addIssue({ code: 'custom', message: 'invalid cross-origin list' })
225
+ }
226
+ }
227
+
195
228
  const cspOriginSchema = z.string().refine((value) => isPolicyOrigin(value))
196
229
  const cspDirectiveSchema = z.array(cspOriginSchema).max(MAX_CSP_ORIGINS)
197
230
  const cspOriginsSchema = z.object({
@@ -214,7 +247,9 @@ export const contractProxyAppSchema = z.object({
214
247
  snapshot: z.string().refine(isDigest),
215
248
  csp: cspOriginsSchema,
216
249
  operationsOrigin: z.string().refine((value) => isPolicyOrigin(value, ['https:', 'http:'])).optional(),
250
+ crossOrigins: z.array(z.string().refine(isCrossOrigin)).nonempty().max(MAX_CROSS_ORIGINS).optional(),
217
251
  }).strict().superRefine((app, ctx) => {
252
+ checkCrossOrigins(app, ctx)
218
253
  checkEndpointTable(app, ctx)
219
254
  }) satisfies z.ZodType<ContractProxyApp, z.ZodTypeDef, unknown>
220
255
 
@@ -308,7 +343,13 @@ export const proxyAppSchema = z.union([contractProxyAppSchema, installationProxy
308
343
  export function contractProxyTable(artifact: ContractArtifact): ContractProxyTable {
309
344
  return {
310
345
  endpoints: artifact.endpoints.map((row): ProxyEndpointRow => {
311
- const rules = { id: row.id, method: row.method, path: row.path, admission: row.admission }
346
+ const rules = {
347
+ id: row.id,
348
+ method: row.method,
349
+ path: row.path,
350
+ admission: row.admission,
351
+ ...(row.crossOrigin === undefined ? {} : { crossOrigin: row.crossOrigin }),
352
+ }
312
353
  if (row.requirement === null) {
313
354
  const anonymous = { ...rules, kind: row.kind, callers: row.callers, requirement: null, delegation: null }
314
355
  return { ...anonymous, digest: requirementDigest(anonymous) }
package/src/service.ts CHANGED
@@ -43,6 +43,7 @@ import {
43
43
  FORWARDED_METHOD_HEADER,
44
44
  FORWARDED_PROTO_HEADER,
45
45
  FORWARDED_URI_HEADER,
46
+ PREFLIGHT_HEADER,
46
47
  PROXY_TOKEN_HEADER,
47
48
  REQUEST_ID_HEADER,
48
49
  } from './constants'
@@ -50,10 +51,13 @@ import { type HandoffAttempt, handoffCookieName } from './handoff'
50
51
  import {
51
52
  appFileTreatment,
52
53
  appHeaderPolicy,
54
+ applyCrossOriginHeaders,
53
55
  applyFileTreatment,
54
56
  applyHeaderPolicy,
55
57
  BASE_HEADER_POLICY,
58
+ crossOriginHeaders,
56
59
  type HeaderPolicy,
60
+ preflightHeaders,
57
61
  transportPolicy,
58
62
  } from './headers'
59
63
  import type { DelegationIssuer, IamGateway } from './iam'
@@ -179,12 +183,26 @@ export function createVerifyService(config: VerifyServiceConfig): VerifyService
179
183
  if (decision.delegation !== null) {
180
184
  headers.set(DELEGATION_HEADER, decision.delegation)
181
185
  }
186
+ // The composition writes it onto the application's answer, after the policy deleted the application's own.
187
+ if (decision.crossOrigin !== null) {
188
+ applyCrossOriginHeaders(headers, crossOriginHeaders(decision.crossOrigin))
189
+ }
182
190
  return new Response(null, { status: 204, headers })
183
191
  }
192
+ const policy = policies.get(resolved.app.id) ?? BASE_HEADER_POLICY
193
+ if (decision.outcome === 'preflight') {
194
+ logger.info('preflight', fields)
195
+ // A 2xx the composition answers as-is, so the preflight never reaches the application.
196
+ const answer = withPolicy(new Response(null, { status: 204, headers: { 'cache-control': 'no-store', [PREFLIGHT_HEADER]: '1' } }), policy)
197
+ applyCrossOriginHeaders(answer.headers, preflightHeaders(decision.allowOrigin, decision.method))
198
+ return answer
199
+ }
184
200
  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)
201
+ const treated = isDownload(resolved, forwarded) ? withFileTreatment(answer, appFileTreatment(resolved.app)) : withPolicy(answer, policy)
202
+ if (decision.outcome === 'deny' && decision.crossOrigin !== undefined) {
203
+ applyCrossOriginHeaders(treated.headers, crossOriginHeaders(decision.crossOrigin))
204
+ }
205
+ return treated
188
206
  } catch {
189
207
  // The last line of the fail-closed guarantee. No `err` is inspected or logged: a caught
190
208
  // error can stringify a URL, a header or a body, any of which may carry a credential.