@fabrika/proxy-core 0.8.2 → 0.9.0-cloudflare.2

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
@@ -35,9 +35,11 @@ import {
35
35
  } from '@fabrika/auth-core'
36
36
  import { uuidv7 } from '@fabrika/platform'
37
37
  import { Authorizer, type AuthorizerOptions, type Decision, type DenyReason, type ForwardedRequest, type ResolvedApp } from './authorize'
38
+ import type { InstallationBrand } from './brand'
38
39
  import type { TokenCache } from './cache'
39
40
  import {
40
41
  APP_QUERY_PARAM,
42
+ AUTH_SIGNOUT_PATH,
41
43
  DEFAULT_HEALTH_PATH,
42
44
  DEFAULT_VERIFY_PATH,
43
45
  FORWARD_AUTHORIZATION_HEADER,
@@ -66,6 +68,7 @@ import type { DelegationIssuer, IamGateway } from './iam'
66
68
  import { type LogFields, type ProxyLogger, redactPath, silentLogger } from './log'
67
69
  import { formatOAuthBearerChallenge, isApplicationEntry, type ManifestEndpointRow, type ProxyApp, type ProxyManifest } from './manifest'
68
70
  import { findOAuthMetadata, OAUTH_METADATA_VERIFY_PATH, oauthMetadataResponse } from './oauth'
71
+ import { errorAnswer, isPageNavigation, type PageContent, pageResponse, renderPage } from './pages'
69
72
 
70
73
  export interface VerifyServiceConfig {
71
74
  manifest: ProxyManifest
@@ -99,6 +102,8 @@ export function createVerifyService(config: VerifyServiceConfig): VerifyService
99
102
  const byId = new Map<string, ResolvedApp>()
100
103
  const byHost = new Map<string, ResolvedApp>()
101
104
  const policies = new Map<string, HeaderPolicy>()
105
+ // The client's logo on every page this service answers with (ADR-0066); the fabrika mark without one.
106
+ const brand = config.manifest.brand ?? null
102
107
  for (const app of config.manifest.apps) {
103
108
  const resolved = resolveApp(app)
104
109
  byId.set(app.id, resolved)
@@ -139,17 +144,19 @@ export function createVerifyService(config: VerifyServiceConfig): VerifyService
139
144
  return withPolicy(textResponse(405, 'method not allowed'), BASE_HEADER_POLICY)
140
145
  }
141
146
 
147
+ // The forwarded request's headers are this request's: `forward_auth` and the Worker copy the original ones.
148
+ const asPage = isPageNavigation(request.headers)
142
149
  const read = readForwardedRequest(request, requestId)
143
150
  if (!read.ok) {
144
151
  const status = read.reason === 'non_canonical_path' ? 400 : 403
145
- return withPolicy(denied(logger, { requestId }, { outcome: 'deny', status, reason: read.reason }), earlyPolicy(request, url))
152
+ return withPolicy(denied(logger, { requestId }, { outcome: 'deny', status, reason: read.reason }, { asPage, brand }), earlyPolicy(request, url))
146
153
  }
147
154
  const forwarded = read.request
148
155
 
149
156
  const resolved = selectApp(url, forwarded.url.host, byId, byHost)
150
157
  if (resolved === null) {
151
158
  const fields = { requestId, host: forwarded.url.host, path: redactPath(forwarded.url.pathname) }
152
- return withPolicy(denied(logger, fields, { outcome: 'deny', status: 403, reason: 'no_app' }), BASE_HEADER_POLICY)
159
+ return withPolicy(denied(logger, fields, { outcome: 'deny', status: 403, reason: 'no_app' }, { asPage, brand }), BASE_HEADER_POLICY)
153
160
  }
154
161
 
155
162
  if (url.pathname === OAUTH_METADATA_VERIFY_PATH) {
@@ -162,9 +169,12 @@ export function createVerifyService(config: VerifyServiceConfig): VerifyService
162
169
  // The handoff callback is checked BEFORE the endpoint table: it is how a browser obtains a
163
170
  // session, so it cannot itself be behind an endpoint. Because Caddy returns a non-2xx auth
164
171
  // response verbatim, answering 302 + Set-Cookie here reaches the client and never the app.
172
+ // The sign-out is answered here for the same reason: a browser whose session is gone must still reach it.
165
173
  const isCallback = forwarded.url.pathname === AUTH_CALLBACK_PATH
166
174
  const decision = isCallback
167
175
  ? await authorizer.authorizeCallback(resolved, forwarded)
176
+ : forwarded.url.pathname === AUTH_SIGNOUT_PATH
177
+ ? authorizer.signOut(resolved, forwarded)
168
178
  : await authorizer.authorize(resolved, forwarded)
169
179
  const fields: LogFields = {
170
180
  requestId,
@@ -202,8 +212,14 @@ export function createVerifyService(config: VerifyServiceConfig): VerifyService
202
212
  applyCrossOriginHeaders(answer.headers, preflightHeaders(decision.allowOrigin, decision.method))
203
213
  return answer
204
214
  }
205
- const answer = decision.outcome === 'handoff' ? handoff(logger, fields, decision) : denied(logger, fields, decision, isCallback)
206
- const treated = isDownload(resolved, forwarded) ? withFileTreatment(answer, appFileTreatment(resolved.app)) : withPolicy(answer, policy)
215
+ // A download's every answer is an attachment, so its refusal stays the text: a page would be saved, not shown.
216
+ const download = isDownload(resolved, forwarded)
217
+ const answer = decision.outcome === 'handoff'
218
+ ? handoff(logger, fields, decision)
219
+ : decision.outcome === 'signout'
220
+ ? signedOut(logger, fields, decision)
221
+ : denied(logger, fields, decision, { isCallback, asPage: asPage && !download, brand })
222
+ const treated = download ? withFileTreatment(answer, appFileTreatment(resolved.app)) : withPolicy(answer, policy)
207
223
  if (decision.outcome === 'deny' && decision.crossOrigin !== undefined) {
208
224
  applyCrossOriginHeaders(treated.headers, crossOriginHeaders(decision.crossOrigin))
209
225
  }
@@ -212,7 +228,7 @@ export function createVerifyService(config: VerifyServiceConfig): VerifyService
212
228
  // The last line of the fail-closed guarantee. No `err` is inspected or logged: a caught
213
229
  // error can stringify a URL, a header or a body, any of which may carry a credential.
214
230
  logger.error('verify failed', { requestId })
215
- return withPolicy(textResponse(403, 'forbidden'), earlyPolicy(request, null))
231
+ return withPolicy(errorAnswer(403, DENY_TEXT[403], isPageNavigation(request.headers), brand), earlyPolicy(request, null))
216
232
  }
217
233
  }
218
234
 
@@ -238,6 +254,17 @@ function handoff(logger: ProxyLogger, fields: LogFields, decision: Extract<Decis
238
254
  return handoffResponse(decision)
239
255
  }
240
256
 
257
+ /**
258
+ * Clear the app session with the attributes it was set with, so the browser matches and drops it. The cookie is
259
+ * cleared whether or not the request carried one: the answer is the same for a browser already signed out.
260
+ */
261
+ function signedOut(logger: ProxyLogger, fields: LogFields, decision: Extract<Decision, { outcome: 'signout' }>): Response {
262
+ logger.info('signout', fields)
263
+ const headers = new Headers({ location: decision.location, 'cache-control': 'no-store' })
264
+ headers.append('set-cookie', secureCookie(SESSION_COOKIE, '', 0))
265
+ return new Response(null, { status: 302, headers })
266
+ }
267
+
241
268
  /** An answer the proxy writes reaches the client as-is, so it carries the policy the application's own answers do. */
242
269
  function withPolicy(response: Response, policy: HeaderPolicy): Response {
243
270
  applyHeaderPolicy(response.headers, policy)
@@ -262,7 +289,7 @@ function isDownload(resolved: ResolvedApp, forwarded: ForwardedRequest): boolean
262
289
  * Render a redeemed handoff: set the app session on THIS host and send the browser where IAM said.
263
290
  *
264
291
  * This is the ONE place the proxy writes the long-lived app session. It separately writes a
265
- * short-lived handoff verifier when starting login, then clears that verifier here.
292
+ * short-lived handoff verifier when starting login, then clears that verifier here. The sign-out clears the session.
266
293
  *
267
294
  * Every attribute is fixed by `SESSION_COOKIE`'s `__Host-` prefix: `Secure`, `Path=/`, and no
268
295
  * `Domain`. The absent `Domain` is the substance — the session belongs to this app's host and no
@@ -284,12 +311,15 @@ function handoffResponse(decision: Extract<Decision, { outcome: 'handoff' }>): R
284
311
  })
285
312
  }
286
313
 
287
- /** Emit the log line for a refusal and render its response. The reason is logged, never returned. */
314
+ /**
315
+ * Emit the log line for a refusal and render its response. The reason is logged, never returned. `asPage` is a
316
+ * navigation that is not a download (`./pages`), which reads the status's page in place of its text.
317
+ */
288
318
  function denied(
289
319
  logger: ProxyLogger,
290
320
  fields: LogFields,
291
321
  decision: Extract<Decision, { outcome: 'deny' | 'login' }>,
292
- isCallback = false,
322
+ { isCallback = false, asPage, brand }: { isCallback?: boolean; asPage: boolean; brand: InstallationBrand | null },
293
323
  ): Response {
294
324
  const reason: DenyReason = decision.reason
295
325
  if (decision.outcome === 'login') {
@@ -299,7 +329,7 @@ function denied(
299
329
  : loginRequired(decision.location, decision.handoff)
300
330
  }
301
331
  logger.warn('deny', { ...fields, reason, status: decision.status })
302
- const response = isCallback ? callbackFailure(decision.status) : textResponse(decision.status, DENY_TEXT[decision.status])
332
+ const response = isCallback ? callbackFailure(decision.status, brand) : errorAnswer(decision.status, DENY_TEXT[decision.status], asPage, brand)
303
333
  if (decision.clearHandoffState !== undefined) {
304
334
  response.headers.append('set-cookie', clearHandoffCookie(decision.clearHandoffState))
305
335
  }
@@ -310,9 +340,8 @@ function denied(
310
340
  }
311
341
 
312
342
  /**
313
- * The callback refusal is the one denial a person reads. Every other one answers a program — an XHR,
314
- * an app request, Caddy itself — but this path is only ever reached by a browser following IAM's
315
- * redirect, and a viewport containing `forbidden` describes nothing the reader can act on.
343
+ * The callback refusal is always a page, also without `Sec-Fetch-Mode`: this path is only ever reached by
344
+ * a browser following IAM's redirect, and its text says what the sign-in did rather than the status's.
316
345
  *
317
346
  * It deliberately does NOT bounce back into login. A browser that dropped the handoff cookie on this
318
347
  * host while keeping IAM's session would be sent straight back here, and the automatic retry becomes
@@ -322,42 +351,14 @@ function denied(
322
351
  * out here anyway: `invalid_session` covers an expired verifier and a code IAM refused alike. Coarse
323
352
  * deny reasons stay in the log (`../proxy/CLAUDE.md`).
324
353
  */
325
- function callbackFailure(status: number): Response {
326
- const [title, detail] = status === 503
327
- ? ['Sign-in is unavailable', 'The sign-in service could not be reached. Please try again in a moment.']
328
- : ['Sign-in did not complete', 'The sign-in link has expired or has already been used. Signing in again will issue a new one.']
329
- // No script and no external reference: this page is served on the application's own origin, and a
330
- // deployment may sit behind a strict CSP that would blank anything else.
331
- const body = `<!doctype html>
332
- <html lang="en">
333
- <head>
334
- <meta charset="utf-8">
335
- <meta name="viewport" content="width=device-width, initial-scale=1">
336
- <title>${title}</title>
337
- <style>
338
- body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #f4f4f5; color: #18181b;
339
- font: 16px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif; }
340
- main { max-width: 27rem; padding: 2rem; }
341
- h1 { margin: 0 0 .5rem; font-size: 1.25rem; }
342
- p { margin: 0 0 1.5rem; color: #52525b; }
343
- a { display: inline-block; padding: .55rem 1.1rem; border-radius: .375rem; background: #18181b; color: #fafafa; text-decoration: none; }
344
- @media (prefers-color-scheme: dark) {
345
- body { background: #18181b; color: #fafafa; }
346
- p { color: #a1a1aa; }
347
- a { background: #fafafa; color: #18181b; }
348
- }
349
- </style>
350
- </head>
351
- <body>
352
- <main>
353
- <h1>${title}</h1>
354
- <p>${detail}</p>
355
- <a href="/">Start again</a>
356
- </main>
357
- </body>
358
- </html>
359
- `
360
- return new Response(body, { status, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' } })
354
+ function callbackFailure(status: number, brand: InstallationBrand | null): Response {
355
+ const content: Omit<PageContent, 'action'> = status === 503
356
+ ? { title: 'Sign-in is unavailable', detail: 'The sign-in service could not be reached. Please try again in a moment.' }
357
+ : {
358
+ title: 'Sign-in did not complete',
359
+ detail: 'The sign-in link has expired or has already been used. Signing in again will issue a new one.',
360
+ }
361
+ return pageResponse(status, renderPage({ ...content, action: { href: '/', label: 'Start again' } }, String(status), brand))
361
362
  }
362
363
 
363
364
  /** The bounce a document navigation gets: the browser follows it and comes back signed in. */
@@ -0,0 +1,253 @@
1
+ /**
2
+ * The proxy authorization core ([ADR-0022](../../../docs/decisions/0022-the-proxy-is-the-only-enforcement-point.md)).
3
+ *
4
+ * Every entry — a contract application or an installation service — is protected by its endpoint table, in one pass
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
7
+ * when `human` may call → the requirement (`satisfies`) → admission → the delegation grant. There is no fall-through
8
+ * to another endpoint. On an installation row that admits `oauth`, a bearer that is not a `px_` key is an OAuth
9
+ * resource token, verified locally. A key or a session is exchanged with IAM once, cached, and thereafter verified
10
+ * LOCALLY against the JWKS.
11
+ *
12
+ * A human miss becomes a bounce to IAM's `/auth/login`, in whichever shape the caller can act on: a
13
+ * 302 for a document navigation, a 401 carrying the same URL in a JSON body for everything else (see
14
+ * `isDocumentNavigation`). Caddy returns a non-2xx auth response — status, headers and body — to the
15
+ * client verbatim, so both reach the caller and neither reaches the app. A request with no session
16
+ * cookie needs no IAM call.
17
+ *
18
+ * NOTHING here returns an allow on an unexpected condition. Every `catch` maps to a deny.
19
+ */
20
+ import { type CompiledEndpointTable } from '@fabrika/auth-core';
21
+ import { type TokenCache } from './cache';
22
+ import { type HandoffAttempt } from './handoff';
23
+ import { type DelegationIssuer, type IamGateway } from './iam';
24
+ import { type ManifestEndpointRow, type OAuthBearerChallenge, type ProxyApp } from './manifest';
25
+ /** Every way a request can be refused. Coarse on purpose — these are logged, not returned to clients. */
26
+ export type DenyReason =
27
+ /** The endpoint admits no person, and no credential was presented. */
28
+ 'no_credential'
29
+ /** A bearer was presented and is not a valid `px_` key. */
30
+ | 'invalid_key'
31
+ /** An `oauth` row's bearer is malformed, unverifiable, or minted for another resource or app. */
32
+ | 'invalid_token'
33
+ /** An `oauth` row's bearer verified but lacks a required scope. */
34
+ | 'insufficient_scope' | 'no_session' | 'invalid_session' | 'unknown_principal' | 'disabled'
35
+ /** IAM refused to sign a token above its size bound: the principal holds more than fits in a header. */
36
+ | 'token_too_large'
37
+ /** The entry's table has no endpoint for the method and path. The fail-closed default; no fall-through. */
38
+ | 'no_endpoint'
39
+ /**
40
+ * The credential's caller kind is not one of the endpoint's callers, is no caller kind at all (a job or a webhook
41
+ * key), or is a connector caller off an export path.
42
+ */
43
+ | 'caller_kind'
44
+ /** The token does not satisfy the endpoint's requirement. */
45
+ | 'insufficient_permission'
46
+ /** A session request that is not a `GET`, or a sign-out, did not come from the app's own origin. */
47
+ | 'cross_site'
48
+ /** A sign-out's return is absent or not a path on the app's own origin. */
49
+ | 'bad_redirect'
50
+ /** The request's `Content-Type` is not one the endpoint admits. */
51
+ | 'content_type'
52
+ /**
53
+ * A preflight for an endpoint the contract does not open to another origin, or from an origin it does not admit; or a
54
+ * multipart form's request from such an origin, which a browser sends without a preflight.
55
+ */
56
+ | 'cross_origin'
57
+ /** IAM refused the delegation grant a membership or scope-creating endpoint needs. */
58
+ | 'delegation_refused'
59
+ /** The `Host` maps to no app in the manifest. */
60
+ | 'no_app'
61
+ /** The forwarded-request headers are missing or malformed — we cannot know what was asked for. */
62
+ | 'bad_forward'
63
+ /** The forwarded path is not canonical (`isCanonicalPath`); refused before any endpoint is planned. */
64
+ | 'non_canonical_path'
65
+ /** IAM could not be consulted, or its keys could not be fetched. We do not know; we refuse. */
66
+ | 'iam_unavailable'
67
+ /** Anything that escaped. Always a deny. */
68
+ | 'internal_error';
69
+ export type Decision = {
70
+ outcome: 'allow';
71
+ /** The verified token to inject upstream. `null` on an anonymous endpoint — no credential, no token. */
72
+ token: string | null;
73
+ /** Principal id (or credential id) for the log line. Never a secret. */
74
+ subject: string | null;
75
+ /** `<id>@<digest>` of the selected endpoint, for `X-Fabrika-Endpoint`. */
76
+ endpoint: string;
77
+ /** The single-use delegation grant for `X-Fabrika-Delegation`, when the endpoint spends one. */
78
+ delegation: string | null;
79
+ /** The `Access-Control-Allow-Origin` the answer carries: the request's admitted origin, `*`, or none. */
80
+ crossOrigin: string | null;
81
+ /**
82
+ * Whether the request's own `Authorization` reaches the application: on the ops row, and on a webhook row that
83
+ * forwards it unless it carries a platform key. Every other anonymous request loses it (ADR-0022 rule 3).
84
+ */
85
+ forwardsAuthorization: boolean;
86
+ } | {
87
+ outcome: 'deny';
88
+ status: 400 | 401 | 403 | 503;
89
+ reason: DenyReason;
90
+ clearHandoffState?: string;
91
+ /** Set on an `oauth` row's 401/403: rendered as the RFC 6750 `WWW-Authenticate` challenge. */
92
+ challenge?: OAuthBearerChallenge;
93
+ /** Set when the request's origin is admitted, so the page can read why it was refused. */
94
+ crossOrigin?: string;
95
+ }
96
+ /**
97
+ * A contract application's preflight that the selected endpoint admits (ADR-0022 "Cross-origin endpoints"). The
98
+ * proxy answers it itself; it never reaches the application. `method` is the endpoint's.
99
+ */
100
+ | {
101
+ outcome: 'preflight';
102
+ allowOrigin: string;
103
+ method: string;
104
+ }
105
+ /**
106
+ * A human reached an endpoint `human` may call without a usable session. `location` is where they must go to sign
107
+ * in; `status` is only how they are TOLD — 302 for a document navigation, 401 for anything else,
108
+ * whose body carries the same URL. See `isDocumentNavigation` for why the two differ.
109
+ */
110
+ | {
111
+ outcome: 'login';
112
+ status: 302 | 401;
113
+ location: string;
114
+ reason: DenyReason;
115
+ handoff: HandoffAttempt;
116
+ }
117
+ /**
118
+ * A handoff code was redeemed (ADR-0022). The only outcome that writes the long-lived app
119
+ * session: `session` is host-only on this app's host, and `location` is where IAM said the browser
120
+ * was going — read from the code server-side, never from the request.
121
+ */
122
+ | {
123
+ outcome: 'handoff';
124
+ status: 302;
125
+ location: string;
126
+ session: string;
127
+ expiresAt: number;
128
+ handoffState: string;
129
+ }
130
+ /**
131
+ * A same-origin sign-out (ADR-0067 §5): the app session cookie is cleared and the browser goes to IAM's release,
132
+ * which returns it to `location`'s own redirect on this app's origin.
133
+ */
134
+ | {
135
+ outcome: 'signout';
136
+ status: 302;
137
+ location: string;
138
+ };
139
+ /** The original request, reconstructed from what `forward_auth` forwarded. */
140
+ export interface ForwardedRequest {
141
+ /** Original method; the endpoint table selects by it. */
142
+ method: string;
143
+ /** Original absolute URL; the whole URL is the login return. */
144
+ url: URL;
145
+ /** The forwarded path exactly as the upstream receives it, already checked by `isCanonicalPath`. */
146
+ rawPath: string;
147
+ /** The original request's headers, forwarded verbatim by Caddy. */
148
+ headers: Headers;
149
+ requestId: string;
150
+ }
151
+ /** An entry plus its precompiled endpoint table, built once at startup. */
152
+ export interface ResolvedApp {
153
+ readonly app: ProxyApp;
154
+ readonly table: CompiledEndpointTable<ManifestEndpointRow>;
155
+ }
156
+ export interface AuthorizerOptions {
157
+ iam: IamGateway;
158
+ /** IAM's origin — verifies the token `iss` and is the base for the `/auth/login` bounce. */
159
+ issuer: string;
160
+ /** `null` disables caching entirely. Decisions are identical either way; only the IAM call count changes. */
161
+ cache?: TokenCache | null;
162
+ /** Unix seconds. Injectable for tests. */
163
+ now?: () => number;
164
+ /**
165
+ * Issues the single-use grant a membership or scope-creating endpoint spends at IAM. Unset, such a request is
166
+ * forwarded without one and IAM refuses the membership operation unless the framework obtained a grant itself.
167
+ */
168
+ delegations?: DelegationIssuer | null;
169
+ }
170
+ export declare class Authorizer {
171
+ private readonly options;
172
+ private readonly verifier;
173
+ private readonly cache;
174
+ private readonly now;
175
+ constructor(options: AuthorizerOptions);
176
+ /**
177
+ * Decide one request. NEVER throws and NEVER returns an allow it did not derive from a verified
178
+ * token — the outer `catch` is the last line of the fail-closed guarantee.
179
+ */
180
+ authorize(resolved: ResolvedApp, request: ForwardedRequest): Promise<Decision>;
181
+ /**
182
+ * One pass over the endpoint `planEndpoint` selects (ADR-0022 rule 3). An anonymous kind reads no credential and
183
+ * mints no token. A credentialed one takes a `px_` key when one is presented — terminal, whatever its verdict —
184
+ * an OAuth resource token on a row that admits `oauth`, and the session only when `human` may call; then the
185
+ * requirement, then admission.
186
+ */
187
+ private decideEndpoint;
188
+ /**
189
+ * A preflight is decided from the endpoint the REQUESTED method selects, before any credential: it carries none
190
+ * (ADR-0022 "Cross-origin endpoints"). Only a contract application's: an installation row of method `*` serves
191
+ * `OPTIONS` itself.
192
+ */
193
+ private decidePreflight;
194
+ /** The selected row's checks. `crossOrigin` is the admitted origin of a cross-origin request, `*` or none. */
195
+ private decideRow;
196
+ /**
197
+ * A `px_` key wherever the request carries an `Authorization` header, and on a row that admits `oauth` any other
198
+ * credential there as an OAuth resource token; otherwise the session, if `human` may call.
199
+ */
200
+ private endpointCaller;
201
+ /** A fresh grant for every request, never cached: IAM spends it once, for this request id. */
202
+ private obtainDelegation;
203
+ /**
204
+ * An OAuth resource token for one registered resource of this app, carrying every scope required. Verified
205
+ * locally — no IAM call, no cache. RFC 6750 §3.1: a request without a bearer (none, or another scheme) gets a
206
+ * challenge with no error code.
207
+ */
208
+ private verifyOAuthBearer;
209
+ /** Exchange a `px_` key for a verified token, through the cache. A non-`px_` bearer is refused with no IAM call. */
210
+ private exchangeKey;
211
+ /**
212
+ * Resolve a human. Two tiers, cheapest first:
213
+ * 1. a cached token minted earlier from the SAME session — no IAM call;
214
+ * 2. exchange the `px_session` SSO cookie via `mintToken`.
215
+ *
216
+ * Every tier additionally requires `ptype: 'user'`: a session asserts that a resolved USER principal
217
+ * exists. Without the check a broken IAM could turn a service token into a human allow.
218
+ */
219
+ private authorizeSession;
220
+ /**
221
+ * Every IAM call goes through here, so ANY failure — a rejected fetch, a Workers RPC error, a
222
+ * gateway that does not honour the `IamUnavailableError` contract — becomes the same deny. The
223
+ * gateway is an injected interface; the fail-closed guarantee must not depend on its manners.
224
+ */
225
+ private callIam;
226
+ /**
227
+ * Redeem a handoff code (ADR-0022). Called for the reserved callback path only, BEFORE the endpoint
228
+ * is planned — the callback is how a browser obtains a session, so it cannot be behind an endpoint.
229
+ *
230
+ * A failed redemption is an ordinary deny with no cookie. It is deliberately not a fresh bounce to
231
+ * IAM: a code that will not redeem twice would turn a retry into a loop.
232
+ */
233
+ authorizeCallback(resolved: ResolvedApp, request: ForwardedRequest): Promise<Decision>;
234
+ private redeem;
235
+ /**
236
+ * Sign the browser out of this host (ADR-0067 §5). Called for the reserved sign-out path only, BEFORE the endpoint
237
+ * is planned, for any entry and any session. It drops this session's cached token, so the next request re-mints
238
+ * from IAM, which has revoked the session by then or still holds it; the cookie itself is cleared by the response.
239
+ *
240
+ * Admission is `Sec-Fetch-Site: same-origin`, absent included: the request changes state, and another page must not
241
+ * sign a person out, as for a session request that is not a `GET` (ADR-0022 rule 3, step 6).
242
+ */
243
+ signOut(resolved: ResolvedApp, request: ForwardedRequest): Decision;
244
+ private loginDecision;
245
+ /**
246
+ * Where to send the browser to log in, returning to the originally requested URL afterwards.
247
+ *
248
+ * The scheme comes from the MANIFEST, not from the forwarded request: behind a TLS-terminating
249
+ * balancer the proxy is reached over plain HTTP and `X-Forwarded-Proto` says so, which would send
250
+ * the browser back to an `http://` URL for an origin only served over `https://`.
251
+ */
252
+ private loginUrl;
253
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The client's brand on fabrika's own pages (ADR-0066). An installation serves one client, so its logo is one setting
3
+ * of the installation: IAM keeps it, the console's Settings writes it, and the proxy manifest carries it to the pages the
4
+ * proxy answers with on every application host. A page shows it where it would otherwise draw the fabrika mark.
5
+ *
6
+ * A logo is an image a page embeds as a `data:` URI in an `<img>`, never markup: an SVG in an image runs no script, and
7
+ * a page that carries it loads nothing. Every consumer checks it here, the IAM write, the manifest parser and the
8
+ * console's own pre-check, so a logo that one of them admits passes the others. This module imports zod and nothing
9
+ * else, so the browser can use it.
10
+ */
11
+ import { z } from 'zod';
12
+ export declare const BRAND_IMAGE_TYPES: readonly ["image/png", "image/webp", "image/svg+xml"];
13
+ export type BrandImageType = (typeof BRAND_IMAGE_TYPES)[number];
14
+ /** The most one image may weigh, decoded. A page carries it in every copy Caddy serves, so it stays a logo's size. */
15
+ export declare const MAX_BRAND_IMAGE_BYTES: number;
16
+ /** One image of the brand: its type and its bytes as canonical base64. */
17
+ export interface BrandImage {
18
+ readonly type: BrandImageType;
19
+ readonly base64: string;
20
+ }
21
+ /** The logo for a light page, and the one for a dark page when the light one would not read there. */
22
+ export interface InstallationBrand {
23
+ readonly light: BrandImage;
24
+ readonly dark?: BrandImage;
25
+ }
26
+ export type BrandImageProblem = 'type' | 'encoding' | 'size' | 'content';
27
+ /** Why an image cannot be the brand, or null. The type must be one of three and must be what the bytes are. */
28
+ export declare function brandImageProblem(image: {
29
+ readonly type: string;
30
+ readonly base64: string;
31
+ }): BrandImageProblem | null;
32
+ export declare function isBrandImageType(value: string): value is BrandImageType;
33
+ export declare const brandImageSchema: z.ZodEffects<z.ZodObject<{
34
+ type: z.ZodEnum<["image/png", "image/webp", "image/svg+xml"]>;
35
+ base64: z.ZodString;
36
+ }, "strict", z.ZodTypeAny, {
37
+ base64: string;
38
+ type: "image/png" | "image/webp" | "image/svg+xml";
39
+ }, {
40
+ base64: string;
41
+ type: "image/png" | "image/webp" | "image/svg+xml";
42
+ }>, {
43
+ base64: string;
44
+ type: "image/png" | "image/webp" | "image/svg+xml";
45
+ }, {
46
+ base64: string;
47
+ type: "image/png" | "image/webp" | "image/svg+xml";
48
+ }>;
49
+ export declare const installationBrandSchema: z.ZodObject<{
50
+ light: z.ZodEffects<z.ZodObject<{
51
+ type: z.ZodEnum<["image/png", "image/webp", "image/svg+xml"]>;
52
+ base64: z.ZodString;
53
+ }, "strict", z.ZodTypeAny, {
54
+ base64: string;
55
+ type: "image/png" | "image/webp" | "image/svg+xml";
56
+ }, {
57
+ base64: string;
58
+ type: "image/png" | "image/webp" | "image/svg+xml";
59
+ }>, {
60
+ base64: string;
61
+ type: "image/png" | "image/webp" | "image/svg+xml";
62
+ }, {
63
+ base64: string;
64
+ type: "image/png" | "image/webp" | "image/svg+xml";
65
+ }>;
66
+ dark: z.ZodOptional<z.ZodEffects<z.ZodObject<{
67
+ type: z.ZodEnum<["image/png", "image/webp", "image/svg+xml"]>;
68
+ base64: z.ZodString;
69
+ }, "strict", z.ZodTypeAny, {
70
+ base64: string;
71
+ type: "image/png" | "image/webp" | "image/svg+xml";
72
+ }, {
73
+ base64: string;
74
+ type: "image/png" | "image/webp" | "image/svg+xml";
75
+ }>, {
76
+ base64: string;
77
+ type: "image/png" | "image/webp" | "image/svg+xml";
78
+ }, {
79
+ base64: string;
80
+ type: "image/png" | "image/webp" | "image/svg+xml";
81
+ }>>;
82
+ }, "strict", z.ZodTypeAny, {
83
+ light: {
84
+ base64: string;
85
+ type: "image/png" | "image/webp" | "image/svg+xml";
86
+ };
87
+ dark?: {
88
+ base64: string;
89
+ type: "image/png" | "image/webp" | "image/svg+xml";
90
+ } | undefined;
91
+ }, {
92
+ light: {
93
+ base64: string;
94
+ type: "image/png" | "image/webp" | "image/svg+xml";
95
+ };
96
+ dark?: {
97
+ base64: string;
98
+ type: "image/png" | "image/webp" | "image/svg+xml";
99
+ } | undefined;
100
+ }>;
101
+ /** The image as the `src` of an `<img>`. Base64 holds no `{`, so a Caddy `static_response` body keeps it as it is. */
102
+ export declare function brandImageDataUri(image: BrandImage): string;
103
+ /** Canonical base64 of `bytes`, the spelling the check requires. Chunked, so a large file stays within `fromCharCode`'s arguments. */
104
+ export declare function brandImageBase64(bytes: Uint8Array): string;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The minted-token cache — the thing that makes the warm path free.
3
+ *
4
+ * It matters MORE here than it did in the SDK. In-process, a human's freshly minted `px_token` went
5
+ * back to the browser as a `Set-Cookie`, so the browser carried the warm credential itself. Behind
6
+ * `forward_auth` there is no such channel: Caddy copies only the named headers of a 2xx auth response
7
+ * onto the UPSTREAM request and discards the rest, so the proxy cannot hand the browser a cookie on
8
+ * an allowed request. The server-side cache is what replaces it.
9
+ *
10
+ * It is nonetheless BEST-EFFORT. Every entry is re-verified against the JWKS before it is trusted,
11
+ * and `null` (no cache at all) is a supported configuration that changes only how many times IAM is
12
+ * called — never which requests are allowed.
13
+ *
14
+ * The cache is per-process and in-memory ON PURPOSE: ADR-0022 requires the proxy to stay stateless so
15
+ * any instance can serve any request. A shared/persistent cache would be state, and would put minted
16
+ * tokens somewhere they outlive the process.
17
+ */
18
+ export interface CachedToken {
19
+ token: string;
20
+ /** Unix seconds. */
21
+ expiresAt: number;
22
+ }
23
+ export interface TokenCache {
24
+ get(key: string): CachedToken | undefined;
25
+ set(key: string, value: CachedToken): void;
26
+ delete(key: string): void;
27
+ }
28
+ /**
29
+ * A bounded in-memory cache. Insertion-ordered eviction (Map iteration order) keeps it to `maxEntries` and to
30
+ * `maxBytes` of keys and tokens together, so a flood of distinct credentials cannot grow it without bound. The byte
31
+ * budget is what holds on Cloudflare: 10,000 application tokens of 24 KiB would pass a Worker isolate's 128 MB.
32
+ *
33
+ * The key is derived from a raw credential (see `cacheKey`). It is never logged, never serialized
34
+ * and never leaves the process.
35
+ */
36
+ export declare class MemoryTokenCache implements TokenCache {
37
+ private readonly maxEntries;
38
+ private readonly maxBytes;
39
+ private readonly entries;
40
+ private bytes;
41
+ constructor(maxEntries?: number, maxBytes?: number);
42
+ get(key: string): CachedToken | undefined;
43
+ set(key: string, value: CachedToken): void;
44
+ delete(key: string): void;
45
+ /** Test/introspection helper — the number of live entries. Never exposes keys or tokens. */
46
+ get size(): number;
47
+ }
48
+ /**
49
+ * Namespace a cache key by app and credential kind. Without the app id a proxy fronting two apps
50
+ * could serve app A's token for app B — the `aud` check would still reject it (so it is a correctness
51
+ * non-issue), but it would turn a warm hit into a needless deny.
52
+ */
53
+ export declare function cacheKey(app: string, kind: 'session' | 'key', credential: string): string;