@fabrika/proxy-core 0.3.2 → 0.4.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/src/headers.ts ADDED
@@ -0,0 +1,210 @@
1
+ // The one response header policy of a contract application and the file treatment of its downloads (ADR-0022,
2
+ // "Response headers"). The proxy sets them on every answer, its own denials included; the framework sets the same
3
+ // definition in-process. Exported as `@fabrika/proxy-core/headers`, which imports nothing at runtime.
4
+
5
+ import type { CspExtensions } from '@fabrika/auth-core'
6
+
7
+ /** Every powerful feature off. ADR-0022 lists the same names; change both together. */
8
+ export const PERMISSIONS_POLICY_FEATURES: readonly string[] = [
9
+ 'accelerometer',
10
+ 'ambient-light-sensor',
11
+ 'autoplay',
12
+ 'bluetooth',
13
+ 'browsing-topics',
14
+ 'camera',
15
+ 'clipboard-read',
16
+ 'display-capture',
17
+ 'encrypted-media',
18
+ 'fullscreen',
19
+ 'gamepad',
20
+ 'geolocation',
21
+ 'gyroscope',
22
+ 'hid',
23
+ 'identity-credentials-get',
24
+ 'idle-detection',
25
+ 'local-fonts',
26
+ 'magnetometer',
27
+ 'microphone',
28
+ 'midi',
29
+ 'otp-credentials',
30
+ 'payment',
31
+ 'picture-in-picture',
32
+ 'publickey-credentials-create',
33
+ 'publickey-credentials-get',
34
+ 'screen-wake-lock',
35
+ 'serial',
36
+ 'storage-access',
37
+ 'usb',
38
+ 'web-share',
39
+ 'window-management',
40
+ 'xr-spatial-tracking',
41
+ ]
42
+
43
+ const PERMISSIONS_POLICY = PERMISSIONS_POLICY_FEATURES.map((feature) => `${feature}=()`).join(', ')
44
+
45
+ export type HeaderPolicy = readonly (readonly [name: string, value: string])[]
46
+
47
+ /** The named origins a contract adds to seven CSP directives: exact `https://` origins, never patterns. */
48
+ export interface CspOrigins {
49
+ readonly connectSrc: readonly string[]
50
+ readonly imgSrc: readonly string[]
51
+ readonly frameAncestors: readonly string[]
52
+ readonly scriptSrc: readonly string[]
53
+ /** `frame-src 'self' <origins>` is emitted only when it names an origin; without it frames fall back to `default-src 'self'`. */
54
+ readonly frameSrc: readonly string[]
55
+ readonly styleSrc: readonly string[]
56
+ readonly fontSrc: readonly string[]
57
+ }
58
+
59
+ const HOSTNAME = /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/
60
+
61
+ /**
62
+ * An exact origin with a literal hostname: no path, no credentials and no wildcard (`new URL` accepts `*` in a
63
+ * hostname), so it means one site and is safe inside a header value. CSP origins are `https:` only.
64
+ */
65
+ export function isPolicyOrigin(value: string, protocols: readonly string[] = ['https:']): boolean {
66
+ try {
67
+ const url = new URL(value)
68
+ return protocols.includes(url.protocol) && url.origin === value && HOSTNAME.test(url.hostname)
69
+ } catch {
70
+ return false
71
+ }
72
+ }
73
+
74
+ /** The most named origins one directive may carry: the contract build and the manifest parser refuse more. */
75
+ export const MAX_CSP_ORIGINS = 16
76
+
77
+ export const NO_CSP_ORIGINS: CspOrigins = Object.freeze({
78
+ connectSrc: [],
79
+ imgSrc: [],
80
+ frameAncestors: [],
81
+ scriptSrc: [],
82
+ frameSrc: [],
83
+ styleSrc: [],
84
+ fontSrc: [],
85
+ })
86
+
87
+ export function cspOrigins(csp: CspExtensions): CspOrigins {
88
+ return {
89
+ connectSrc: csp.connectSrc.map((source) => source.origin),
90
+ imgSrc: csp.imgSrc.map((source) => source.origin),
91
+ frameAncestors: csp.frameAncestors.map((source) => source.origin),
92
+ scriptSrc: (csp.scriptSrc ?? []).map((source) => source.origin),
93
+ frameSrc: (csp.frameSrc ?? []).map((source) => source.origin),
94
+ styleSrc: (csp.styleSrc ?? []).map((source) => source.origin),
95
+ fontSrc: (csp.fontSrc ?? []).map((source) => source.origin),
96
+ }
97
+ }
98
+
99
+ /**
100
+ * The policy for one application: the fixed CSP, extended by the contract's named origins and by the Operations
101
+ * ingest origin the browser reports errors to.
102
+ */
103
+ export function headerPolicy(origins: CspOrigins, operationsOrigin: string | null): HeaderPolicy {
104
+ const connect = ["'self'", ...(operationsOrigin === null ? [] : [operationsOrigin]), ...origins.connectSrc]
105
+ const frameAncestors = origins.frameAncestors.length === 0 ? ["'none'"] : origins.frameAncestors
106
+ const directives = [
107
+ "default-src 'self'",
108
+ `script-src ${["'self'", ...origins.scriptSrc].join(' ')}`,
109
+ `style-src ${["'self'", "'unsafe-inline'", ...origins.styleSrc].join(' ')}`,
110
+ `img-src ${["'self'", 'data:', 'blob:', ...origins.imgSrc].join(' ')}`,
111
+ `font-src ${["'self'", 'data:', ...origins.fontSrc].join(' ')}`,
112
+ `connect-src ${connect.join(' ')}`,
113
+ "form-action 'self'",
114
+ "object-src 'none'",
115
+ "base-uri 'none'",
116
+ `frame-ancestors ${frameAncestors.join(' ')}`,
117
+ // Without it frames fall back to `default-src 'self'`; a declared origin only extends that, so `'self'` stays.
118
+ ...(origins.frameSrc.length === 0 ? [] : [`frame-src ${["'self'", ...origins.frameSrc].join(' ')}`]),
119
+ ]
120
+ return Object.freeze([
121
+ ['Content-Security-Policy', directives.join('; ')],
122
+ ['X-Content-Type-Options', 'nosniff'],
123
+ ['Cache-Control', 'no-store'],
124
+ ['Referrer-Policy', 'no-referrer'],
125
+ ['Cross-Origin-Opener-Policy', 'same-origin'],
126
+ ['Permissions-Policy', PERMISSIONS_POLICY],
127
+ ])
128
+ }
129
+
130
+ /** The browser-facing scheme of a manifest entry's hosts; behind a TLS-terminating balancer no header can supply it. */
131
+ export type PublicScheme = 'http' | 'https'
132
+
133
+ /** One year. No `includeSubDomains` and no `preload`: applications share parent domains. */
134
+ export const STRICT_TRANSPORT_SECURITY = 'max-age=31536000'
135
+
136
+ /**
137
+ * What every answer of an application host carries for its transport: HSTS on an `https` host, nothing on `http` (the
138
+ * local stack). Only the proxy knows the scheme, from the manifest, so the framework's in-process policy has none.
139
+ */
140
+ export function transportPolicy(scheme: PublicScheme): HeaderPolicy {
141
+ return scheme === 'https' ? [['Strict-Transport-Security', STRICT_TRANSPORT_SECURITY]] : []
142
+ }
143
+
144
+ /**
145
+ * The transport headers the policy owns: an `https` entry's policy sets them, and every other answer loses the
146
+ * application's own, so an `http` host never pins a browser to a scheme it does not serve.
147
+ */
148
+ export const TRANSPORT_HEADERS: readonly string[] = ['Strict-Transport-Security']
149
+
150
+ /** The policy of a contract application's manifest entry, its transport header included. */
151
+ export function appHeaderPolicy(app: { readonly csp: CspOrigins; readonly operationsOrigin?: string; readonly scheme: PublicScheme }): HeaderPolicy {
152
+ return Object.freeze([...headerPolicy(app.csp, app.operationsOrigin ?? null), ...transportPolicy(app.scheme)])
153
+ }
154
+
155
+ /** The policy of an answer that belongs to no contract application: no named origin, no ingest. */
156
+ export const BASE_HEADER_POLICY: HeaderPolicy = headerPolicy(NO_CSP_ORIGINS, null)
157
+
158
+ /** Raster formats a browser only ever renders as an image; every other download, SVG and HTML included, is an attachment. */
159
+ export const INLINE_FILE_TYPES: readonly string[] = ['image/png', 'image/jpeg', 'image/gif', 'image/webp']
160
+
161
+ /** What every answer of a download endpoint carries in place of the policy, its errors included. */
162
+ export const FILE_TREATMENT: HeaderPolicy = Object.freeze([
163
+ ['X-Content-Type-Options', 'nosniff'],
164
+ ['Content-Security-Policy', "sandbox; default-src 'none'"],
165
+ // A revoked user must not read a cached copy.
166
+ ['Cache-Control', 'private, no-store'],
167
+ ])
168
+
169
+ /** The file treatment of a manifest entry's downloads, its transport header included. */
170
+ export function appFileTreatment(app: { readonly scheme: PublicScheme }): HeaderPolicy {
171
+ return Object.freeze([...FILE_TREATMENT, ...transportPolicy(app.scheme)])
172
+ }
173
+
174
+ /**
175
+ * Headers an application may not send at all: each names a reporting endpoint the policy never approved, and a
176
+ * report-only CSP reports there whatever the enforced one says (`document-uri`, `blocked-uri`).
177
+ */
178
+ export const REMOVED_RESPONSE_HEADERS: readonly string[] = ['Content-Security-Policy-Report-Only', 'Report-To', 'Reporting-Endpoints', 'NEL']
179
+
180
+ /** `null` and anything but one allowlisted value is an attachment; `Headers` joins repeated fields with `, `, so two never match. */
181
+ export function fileDisposition(contentType: string | null): 'inline' | 'attachment' {
182
+ return contentType !== null && INLINE_FILE_TYPES.includes(contentType) ? 'inline' : 'attachment'
183
+ }
184
+
185
+ /** Whatever the handler or an asset server set for these names is replaced; the removed and transport headers are dropped first. */
186
+ export function applyHeaderPolicy(headers: Headers, policy: HeaderPolicy): void {
187
+ for (const name of [...REMOVED_RESPONSE_HEADERS, ...TRANSPORT_HEADERS]) {
188
+ headers.delete(name)
189
+ }
190
+ for (const [name, value] of policy) {
191
+ headers.set(name, value)
192
+ }
193
+ }
194
+
195
+ /**
196
+ * The file treatment over headers a download already carries: the disposition type follows the `Content-Type`
197
+ * exactly, and the parameters after it (the file name) are kept. The proxy passes `appFileTreatment` of its entry.
198
+ */
199
+ export function applyFileTreatment(headers: Headers, treatment: HeaderPolicy = FILE_TREATMENT): void {
200
+ applyHeaderPolicy(headers, treatment)
201
+ const parameters = headers.get('Content-Disposition')?.replace(/^[^;]*/, '') ?? ''
202
+ headers.set('Content-Disposition', `${fileDisposition(headers.get('Content-Type'))}${parameters}`)
203
+ }
204
+
205
+ /** A copy of `response` whose headers `apply` rewrote; status and body are unchanged. */
206
+ export function withResponseHeaders(response: Response, apply: (headers: Headers) => void): Response {
207
+ const headers = new Headers(response.headers)
208
+ apply(headers)
209
+ return new Response(response.body, { status: response.status, statusText: response.statusText, headers })
210
+ }
package/src/iam.ts CHANGED
@@ -29,6 +29,10 @@ const mintResultSchema = z.discriminatedUnion('ok', [
29
29
  z.object({ ok: z.literal(true), token: z.string().min(1), expiresAt: z.number().finite() }),
30
30
  denialSchema,
31
31
  ])
32
+ const delegationResultSchema = z.discriminatedUnion('ok', [
33
+ z.object({ ok: z.literal(true), grant: z.string().min(1), expiresAt: z.number().finite() }),
34
+ denialSchema,
35
+ ])
32
36
  const exchangeResultSchema = z.discriminatedUnion('ok', [
33
37
  z.object({ ok: z.literal(true), session: z.string().min(1), returnUrl: z.string().min(1), expiresAt: z.number().finite() }),
34
38
  denialSchema,
@@ -47,6 +51,22 @@ export interface IamGateway {
47
51
  getJwks(): Promise<Jwks>
48
52
  }
49
53
 
54
+ /**
55
+ * Issues the single-use delegation grant a membership or scope-creating endpoint spends (ADR-0022 rule 6). A
56
+ * structural subset of IAM's membership RPC, so its binding drops in like `IamGateway`'s does. `credential` is the
57
+ * token the proxy just minted for this request; the grant is bound to `endpoint` and `requestId`.
58
+ */
59
+ export interface DelegationIssuer {
60
+ requestDelegation(input: DelegationRequest): Promise<{ ok: true; grant: string; expiresAt: number } | { ok: false; reason: string }>
61
+ }
62
+
63
+ export interface DelegationRequest {
64
+ app: string
65
+ credential: string
66
+ requestId: string
67
+ endpoint: string
68
+ }
69
+
50
70
  /**
51
71
  * IAM could not be consulted — network failure, timeout, non-200, or a body we cannot read. Distinct
52
72
  * from a decided denial: we do not know the answer, so the request is denied with 503 rather than
@@ -89,9 +109,10 @@ export type FetchLike = (input: string, init: RequestInit) => Promise<Response>
89
109
  * POST {origin}/auth/mint/session {app, session, requestId} -> MintTokenResult
90
110
  * POST {origin}/auth/mint/key {app, key, requestId} -> MintFromKeyResult
91
111
  * POST {origin}/auth/mint/exchange {app, code, requestId} -> ExchangeAuthCodeResult (ADR-0022)
112
+ * POST {origin}/auth/mint/delegation {app, credential, requestId, endpoint} -> a grant or a denial (rule 6)
92
113
  * GET {origin}/.well-known/jwks.json -> Jwks (public)
93
114
  *
94
- * The three POST endpoints are the `/auth/mint/*` PROXY surface, gated by IAM's `proxyKey` — the
115
+ * The four POST endpoints are the `/auth/mint/*` PROXY surface, authenticated by IAM's `proxyKey` — the
95
116
  * least-privilege split ADR-0022 requires, kept apart from the management `/rpc/*` surface. They
96
117
  * answer **200 on any decided outcome, including a denial**: `{ ok: false, reason }` is "IAM answered:
97
118
  * no", a non-200 is "IAM could not be consulted". Both deny; only the second is an incident.
@@ -99,7 +120,7 @@ export type FetchLike = (input: string, init: RequestInit) => Promise<Response>
99
120
  * Credentials always ride in the POST body, never in a URL — a URL ends up in access logs and error
100
121
  * strings; a body does not.
101
122
  */
102
- export class HttpIamGateway implements IamGateway {
123
+ export class HttpIamGateway implements IamGateway, DelegationIssuer {
103
124
  private readonly origin: string
104
125
  private readonly timeoutMs: number
105
126
  private readonly key: string | undefined
@@ -121,7 +142,10 @@ export class HttpIamGateway implements IamGateway {
121
142
  if (parsed.ok) {
122
143
  return parsed
123
144
  }
124
- return { ok: false, reason: reasonOf(parsed.reason, ['no_session', 'invalid_session', 'unknown_principal', 'disabled'], 'invalid_session') }
145
+ return {
146
+ ok: false,
147
+ reason: reasonOf(parsed.reason, ['no_session', 'invalid_session', 'unknown_principal', 'disabled', 'token_too_large'], 'invalid_session'),
148
+ }
125
149
  }
126
150
 
127
151
  async mintFromKey(input: MintFromKeyInput): Promise<MintFromKeyResult> {
@@ -133,7 +157,7 @@ export class HttpIamGateway implements IamGateway {
133
157
  if (parsed.ok) {
134
158
  return parsed
135
159
  }
136
- return { ok: false, reason: reasonOf(parsed.reason, ['invalid_key', 'unknown_principal', 'disabled'], 'invalid_key') }
160
+ return { ok: false, reason: reasonOf(parsed.reason, ['invalid_key', 'unknown_principal', 'disabled', 'token_too_large'], 'invalid_key') }
137
161
  }
138
162
 
139
163
  async exchangeAuthCode(input: ExchangeAuthCodeInput): Promise<ExchangeAuthCodeResult> {
@@ -152,6 +176,14 @@ export class HttpIamGateway implements IamGateway {
152
176
  }
153
177
  }
154
178
 
179
+ async requestDelegation(input: DelegationRequest): Promise<{ ok: true; grant: string; expiresAt: number } | { ok: false; reason: string }> {
180
+ const result = delegationResultSchema.safeParse(await this.post('/auth/mint/delegation', input, 'requestDelegation'))
181
+ if (!result.success) {
182
+ throw new IamUnavailableError('requestDelegation')
183
+ }
184
+ return result.data.ok ? result.data : { ok: false, reason: result.data.reason }
185
+ }
186
+
155
187
  async getJwks(): Promise<Jwks> {
156
188
  const response = await this.send('/.well-known/jwks.json', undefined, 'getJwks')
157
189
  const body: unknown = await response.json().catch(() => null)
package/src/index.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  //
8
8
  // The deployment that fronts it on Zerops — the Caddy configuration and the compiled binary — is
9
9
  // `@fabrika/proxy`, which is private because it is an artifact rather than an API. The manifest wire
10
- // contract and its strict parser are the `./manifest` subpath; the gate matcher and the token claims
10
+ // contract and its strict parser are the `./manifest` subpath; the endpoint planner and the token claims
11
11
  // are `@fabrika/auth-core`.
12
12
 
13
13
  export { Authorizer } from './authorize'
@@ -20,6 +20,8 @@ export {
20
20
  DEFAULT_HEALTH_PATH,
21
21
  DEFAULT_IAM_TIMEOUT_MS,
22
22
  DEFAULT_VERIFY_PATH,
23
+ DELEGATION_HEADER,
24
+ ENDPOINT_HEADER,
23
25
  FORWARDED_HOST_HEADER,
24
26
  FORWARDED_METHOD_HEADER,
25
27
  FORWARDED_PROTO_HEADER,
@@ -30,13 +32,13 @@ export {
30
32
  REQUEST_ID_HEADER,
31
33
  UNTRUSTED_FORWARD_HEADERS,
32
34
  } from './constants'
33
- export { applicableGates, compileGates, readBearer, readCookie, readServiceCredential } from './gates'
34
- export type { CompiledGate } from './gates'
35
+ export { readBearer, readCookie } from './credentials'
35
36
  export { stripFabrikaCookies } from './handoff'
36
37
  export { HttpIamGateway, IamUnavailableError } from './iam'
37
- export type { HttpIamGatewayOptions, IamGateway } from './iam'
38
+ export type { DelegationIssuer, DelegationRequest, HttpIamGatewayOptions, IamGateway } from './iam'
38
39
  export { consoleLogger, redactPath, redactUrl, silentLogger } from './log'
39
40
  export type { LogFields, LogValue, ProxyLogger } from './log'
41
+ export { findOAuthMetadata, OAUTH_METADATA_VERIFY_PATH, oauthMetadataResponse } from './oauth'
40
42
  export { createVerifyService } from './service'
41
43
  export type { VerifyService, VerifyServiceConfig } from './service'
42
44
  export { TokenVerifier } from './verifier'