@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/package.json +19 -8
- package/src/authorize.ts +77 -21
- package/src/brand.ts +121 -0
- package/src/cache.ts +0 -0
- package/src/constants.ts +9 -0
- package/src/handoff.ts +8 -0
- package/src/index.ts +4 -0
- package/src/manifest.ts +140 -9
- package/src/page-style.ts +505 -0
- package/src/pages.ts +150 -0
- package/src/service.ts +49 -48
- package/types/src/authorize.d.ts +253 -0
- package/types/src/brand.d.ts +104 -0
- package/types/src/cache.d.ts +53 -0
- package/types/src/constants.d.ts +48 -0
- package/types/src/credentials.d.ts +10 -0
- package/types/src/handoff.d.ts +20 -0
- package/types/src/headers.d.ts +112 -0
- package/types/src/iam.d.ts +115 -0
- package/types/src/index.d.ts +18 -0
- package/types/src/log.d.ts +29 -0
- package/types/src/manifest.d.ts +9222 -0
- package/types/src/oauth-resource.d.ts +52 -0
- package/types/src/oauth.d.ts +12 -0
- package/types/src/page-style.d.ts +40 -0
- package/types/src/pages.d.ts +62 -0
- package/types/src/service.d.ts +47 -0
- package/types/src/verifier.d.ts +22 -0
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
|
-
|
|
206
|
-
const
|
|
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(
|
|
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
|
-
/**
|
|
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) :
|
|
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
|
|
314
|
-
*
|
|
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
|
|
327
|
-
?
|
|
328
|
-
:
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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;
|