@fabrika/proxy-core 0.8.2 → 0.9.0-cloudflare.1
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/proxy-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0-cloudflare.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -12,22 +12,33 @@
|
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
14
|
"src",
|
|
15
|
-
"!src/**/__tests__/**"
|
|
15
|
+
"!src/**/__tests__/**",
|
|
16
|
+
"types"
|
|
16
17
|
],
|
|
17
18
|
"exports": {
|
|
18
19
|
".": {
|
|
19
20
|
"bun": "./src/index.ts",
|
|
20
|
-
"types": "./src/index.ts",
|
|
21
|
+
"types": "./types/src/index.d.ts",
|
|
21
22
|
"default": "./src/index.ts"
|
|
22
23
|
},
|
|
23
24
|
"./manifest": {
|
|
24
25
|
"bun": "./src/manifest.ts",
|
|
25
|
-
"types": "./src/manifest.ts",
|
|
26
|
+
"types": "./types/src/manifest.d.ts",
|
|
26
27
|
"default": "./src/manifest.ts"
|
|
27
28
|
},
|
|
29
|
+
"./brand": {
|
|
30
|
+
"bun": "./src/brand.ts",
|
|
31
|
+
"types": "./types/src/brand.d.ts",
|
|
32
|
+
"default": "./src/brand.ts"
|
|
33
|
+
},
|
|
34
|
+
"./page-style": {
|
|
35
|
+
"bun": "./src/page-style.ts",
|
|
36
|
+
"types": "./types/src/page-style.d.ts",
|
|
37
|
+
"default": "./src/page-style.ts"
|
|
38
|
+
},
|
|
28
39
|
"./headers": {
|
|
29
40
|
"bun": "./src/headers.ts",
|
|
30
|
-
"types": "./src/headers.ts",
|
|
41
|
+
"types": "./types/src/headers.d.ts",
|
|
31
42
|
"default": "./src/headers.ts"
|
|
32
43
|
}
|
|
33
44
|
},
|
|
@@ -37,9 +48,9 @@
|
|
|
37
48
|
},
|
|
38
49
|
"dependencies": {
|
|
39
50
|
"zod": "^3.25.76",
|
|
40
|
-
"@fabrika/auth-core": "0.
|
|
41
|
-
"@fabrika/jwks": "0.
|
|
51
|
+
"@fabrika/auth-core": "0.9.0-cloudflare.1",
|
|
52
|
+
"@fabrika/jwks": "0.9.0-cloudflare.1",
|
|
42
53
|
"jose": "^5.9.0",
|
|
43
|
-
"@fabrika/platform": "0.
|
|
54
|
+
"@fabrika/platform": "0.9.0-cloudflare.1"
|
|
44
55
|
}
|
|
45
56
|
}
|
package/src/authorize.ts
CHANGED
|
@@ -36,9 +36,9 @@ import {
|
|
|
36
36
|
type TokenCaller,
|
|
37
37
|
} from '@fabrika/auth-core'
|
|
38
38
|
import { cacheKey, type TokenCache } from './cache'
|
|
39
|
-
import { LOGIN_REDIRECT_PARAM } from './constants'
|
|
39
|
+
import { LOGIN_REDIRECT_PARAM, SIGNOUT_RELEASE_PATH } from './constants'
|
|
40
40
|
import { carriesPlatformKey, readBearer, readCookie } from './credentials'
|
|
41
|
-
import { createHandoffAttempt, type HandoffAttempt, handoffCookieName, setHandoffParams } from './handoff'
|
|
41
|
+
import { createHandoffAttempt, type HandoffAttempt, handoffCookieName, setHandoffParams, unmatchedVerifier } from './handoff'
|
|
42
42
|
import { type DelegationIssuer, type IamGateway, IamUnavailableError } from './iam'
|
|
43
43
|
import {
|
|
44
44
|
isApplicationEntry,
|
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
oauthResourceOfRow,
|
|
49
49
|
type ProxyApp,
|
|
50
50
|
} from './manifest'
|
|
51
|
+
import { FETCH_MODE_HEADER } from './pages'
|
|
51
52
|
import { TokenVerifier } from './verifier'
|
|
52
53
|
|
|
53
54
|
/** Every way a request can be refused. Coarse on purpose — these are logged, not returned to clients. */
|
|
@@ -75,8 +76,10 @@ export type DenyReason =
|
|
|
75
76
|
| 'caller_kind'
|
|
76
77
|
/** The token does not satisfy the endpoint's requirement. */
|
|
77
78
|
| 'insufficient_permission'
|
|
78
|
-
/** A session request that is not a `GET
|
|
79
|
+
/** A session request that is not a `GET`, or a sign-out, did not come from the app's own origin. */
|
|
79
80
|
| 'cross_site'
|
|
81
|
+
/** A sign-out's return is absent or not a path on the app's own origin. */
|
|
82
|
+
| 'bad_redirect'
|
|
80
83
|
/** The request's `Content-Type` is not one the endpoint admits. */
|
|
81
84
|
| 'content_type'
|
|
82
85
|
/**
|
|
@@ -111,8 +114,8 @@ export type Decision =
|
|
|
111
114
|
/** The `Access-Control-Allow-Origin` the answer carries: the request's admitted origin, `*`, or none. */
|
|
112
115
|
crossOrigin: string | null
|
|
113
116
|
/**
|
|
114
|
-
* Whether the request's own `Authorization` reaches the application: on
|
|
115
|
-
* carries a platform key. Every other anonymous request loses it (ADR-0022 rule 3).
|
|
117
|
+
* Whether the request's own `Authorization` reaches the application: on the ops row, and on a webhook row that
|
|
118
|
+
* forwards it unless it carries a platform key. Every other anonymous request loses it (ADR-0022 rule 3).
|
|
116
119
|
*/
|
|
117
120
|
forwardsAuthorization: boolean
|
|
118
121
|
}
|
|
@@ -143,6 +146,11 @@ export type Decision =
|
|
|
143
146
|
* was going — read from the code server-side, never from the request.
|
|
144
147
|
*/
|
|
145
148
|
| { outcome: 'handoff'; status: 302; location: string; session: string; expiresAt: number; handoffState: string }
|
|
149
|
+
/**
|
|
150
|
+
* A same-origin sign-out (ADR-0067 §5): the app session cookie is cleared and the browser goes to IAM's release,
|
|
151
|
+
* which returns it to `location`'s own redirect on this app's origin.
|
|
152
|
+
*/
|
|
153
|
+
| { outcome: 'signout'; status: 302; location: string }
|
|
146
154
|
|
|
147
155
|
/** The original request, reconstructed from what `forward_auth` forwarded. */
|
|
148
156
|
export interface ForwardedRequest {
|
|
@@ -178,13 +186,6 @@ export interface AuthorizerOptions {
|
|
|
178
186
|
delegations?: DelegationIssuer | null
|
|
179
187
|
}
|
|
180
188
|
|
|
181
|
-
/**
|
|
182
|
-
* The browser's own description of what issued the request. Read from the FORWARDED headers like
|
|
183
|
-
* everything else a decision depends on — verified against caddy 2.10.2, whose `forward_auth`
|
|
184
|
-
* subrequest carries the original `Sec-Fetch-*` verbatim.
|
|
185
|
-
*/
|
|
186
|
-
const FETCH_MODE_HEADER = 'Sec-Fetch-Mode'
|
|
187
|
-
|
|
188
189
|
/** Fetch metadata the browser writes and page script cannot: `same-origin` only for a request from this origin. */
|
|
189
190
|
const FETCH_SITE_HEADER = 'Sec-Fetch-Site'
|
|
190
191
|
|
|
@@ -583,21 +584,56 @@ export class Authorizer {
|
|
|
583
584
|
}
|
|
584
585
|
const cookieName = handoffCookieName(state)
|
|
585
586
|
const verifier = cookieName === null ? null : readCookie(request.headers.get('Cookie'), cookieName)
|
|
587
|
+
// Through `callIam` like every other IAM call, so an unreachable service is a 503 whatever kind
|
|
588
|
+
// of error the injected gateway happened to throw. `authorize`'s outer catch maps the rest.
|
|
589
|
+
const redeem = (presented: string) =>
|
|
590
|
+
this.callIam(
|
|
591
|
+
'exchangeAuthCode',
|
|
592
|
+
() => this.options.iam.exchangeAuthCode({ app: resolved.app.id, code, verifier: presented, requestId: request.requestId }),
|
|
593
|
+
)
|
|
586
594
|
if (verifier === null) {
|
|
595
|
+
// This browser did not start the sign-in the code belongs to. The code is spent, so whoever started it cannot
|
|
596
|
+
// redeem it from the address this browser shows (ADR-0067 §3).
|
|
597
|
+
await redeem(unmatchedVerifier())
|
|
587
598
|
return { outcome: 'deny', status: 403, reason: 'invalid_session', clearHandoffState: state }
|
|
588
599
|
}
|
|
589
|
-
|
|
590
|
-
// of error the injected gateway happened to throw. `authorize`'s outer catch maps the rest.
|
|
591
|
-
const result = await this.callIam(
|
|
592
|
-
'exchangeAuthCode',
|
|
593
|
-
() => this.options.iam.exchangeAuthCode({ app: resolved.app.id, code, verifier, requestId: request.requestId }),
|
|
594
|
-
)
|
|
600
|
+
const result = await redeem(verifier)
|
|
595
601
|
if (!result.ok) {
|
|
596
602
|
return { outcome: 'deny', status: 403, reason: 'invalid_session', clearHandoffState: state }
|
|
597
603
|
}
|
|
598
604
|
return { outcome: 'handoff', status: 302, location: result.returnUrl, session: result.session, expiresAt: result.expiresAt, handoffState: state }
|
|
599
605
|
}
|
|
600
606
|
|
|
607
|
+
/**
|
|
608
|
+
* Sign the browser out of this host (ADR-0067 §5). Called for the reserved sign-out path only, BEFORE the endpoint
|
|
609
|
+
* is planned, for any entry and any session. It drops this session's cached token, so the next request re-mints
|
|
610
|
+
* from IAM, which has revoked the session by then or still holds it; the cookie itself is cleared by the response.
|
|
611
|
+
*
|
|
612
|
+
* Admission is `Sec-Fetch-Site: same-origin`, absent included: the request changes state, and another page must not
|
|
613
|
+
* sign a person out, as for a session request that is not a `GET` (ADR-0022 rule 3, step 6).
|
|
614
|
+
*/
|
|
615
|
+
signOut(resolved: ResolvedApp, request: ForwardedRequest): Decision {
|
|
616
|
+
if (request.method !== 'GET') {
|
|
617
|
+
return { outcome: 'deny', status: 403, reason: 'no_endpoint' }
|
|
618
|
+
}
|
|
619
|
+
if (request.headers.get(FETCH_SITE_HEADER) !== 'same-origin') {
|
|
620
|
+
return { outcome: 'deny', status: 403, reason: 'cross_site' }
|
|
621
|
+
}
|
|
622
|
+
const app = resolved.app
|
|
623
|
+
const target = sameOriginTarget(new URL(`${app.scheme}://${request.url.host}`).origin, request.url.searchParams.get(LOGIN_REDIRECT_PARAM))
|
|
624
|
+
if (target === null) {
|
|
625
|
+
return { outcome: 'deny', status: 400, reason: 'bad_redirect' }
|
|
626
|
+
}
|
|
627
|
+
const session = readCookie(request.headers.get('Cookie'), SESSION_COOKIE)
|
|
628
|
+
if (session !== null) {
|
|
629
|
+
this.cache?.delete(cacheKey(app.id, 'session', session))
|
|
630
|
+
}
|
|
631
|
+
const release = new URL(SIGNOUT_RELEASE_PATH, this.options.issuer.replace(/\/+$/, ''))
|
|
632
|
+
release.searchParams.set('app', app.id)
|
|
633
|
+
release.searchParams.set(LOGIN_REDIRECT_PARAM, target)
|
|
634
|
+
return { outcome: 'signout', status: 302, location: release.toString() }
|
|
635
|
+
}
|
|
636
|
+
|
|
601
637
|
private async loginDecision(
|
|
602
638
|
app: ProxyApp,
|
|
603
639
|
request: ForwardedRequest,
|
|
@@ -647,6 +683,23 @@ function admittedOrigin(app: ProxyApp, row: ManifestEndpointRow, origin: string)
|
|
|
647
683
|
return form === 'environment' && isApplicationEntry(app) && (app.crossOrigins ?? []).includes(origin) ? origin : null
|
|
648
684
|
}
|
|
649
685
|
|
|
686
|
+
/**
|
|
687
|
+
* The absolute URL of `path` on `origin`, or `null` when `path` is not a path on that origin. A leading `//` or any
|
|
688
|
+
* `\` would make the browser read a host from it, and a control character or a space is never part of a return path.
|
|
689
|
+
*/
|
|
690
|
+
function sameOriginTarget(origin: string, path: string | null): string | null {
|
|
691
|
+
if (path === null || !path.startsWith('/') || path.startsWith('//') || path.includes('\\') || [...path].some(isControlOrSpace)) {
|
|
692
|
+
return null
|
|
693
|
+
}
|
|
694
|
+
const target = new URL(path, origin)
|
|
695
|
+
return target.origin === origin ? target.toString() : null
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function isControlOrSpace(character: string): boolean {
|
|
699
|
+
const code = character.charCodeAt(0)
|
|
700
|
+
return code <= 0x20 || code === 0x7f
|
|
701
|
+
}
|
|
702
|
+
|
|
650
703
|
function invalidKey(): Denial {
|
|
651
704
|
return { outcome: 'deny', status: 401, reason: 'invalid_key' }
|
|
652
705
|
}
|
|
@@ -738,10 +791,13 @@ function isDocumentNavigation(headers: Headers): boolean {
|
|
|
738
791
|
}
|
|
739
792
|
|
|
740
793
|
/**
|
|
741
|
-
* The `Authorization` of an anonymous request
|
|
742
|
-
*
|
|
794
|
+
* The `Authorization` of an anonymous request goes on only on a `protocol` row, whose receiver authenticates the caller
|
|
795
|
+
* itself (ADR-0069: the ops key reaches framework code only), and on a webhook row that forwards it, never with a
|
|
796
|
+
* platform key. The proxy reads no credential from it either way: no key lookup, no session.
|
|
743
797
|
*/
|
|
744
798
|
function forwardsAuthorization(row: ManifestEndpointRow, request: ForwardedRequest): boolean {
|
|
745
799
|
const authorization = request.headers.get('Authorization')
|
|
746
|
-
|
|
800
|
+
if (authorization === null) return false
|
|
801
|
+
if (row.kind === 'protocol') return true
|
|
802
|
+
return 'authorization' in row && row.authorization === 'forward' && !carriesPlatformKey(authorization, API_KEY_PREFIX)
|
|
747
803
|
}
|
package/src/brand.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
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
|
+
|
|
12
|
+
import { z } from 'zod'
|
|
13
|
+
|
|
14
|
+
export const BRAND_IMAGE_TYPES = ['image/png', 'image/webp', 'image/svg+xml'] as const
|
|
15
|
+
|
|
16
|
+
export type BrandImageType = (typeof BRAND_IMAGE_TYPES)[number]
|
|
17
|
+
|
|
18
|
+
/** The most one image may weigh, decoded. A page carries it in every copy Caddy serves, so it stays a logo's size. */
|
|
19
|
+
export const MAX_BRAND_IMAGE_BYTES = 32 * 1024
|
|
20
|
+
|
|
21
|
+
/** One image of the brand: its type and its bytes as canonical base64. */
|
|
22
|
+
export interface BrandImage {
|
|
23
|
+
readonly type: BrandImageType
|
|
24
|
+
readonly base64: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The logo for a light page, and the one for a dark page when the light one would not read there. */
|
|
28
|
+
export interface InstallationBrand {
|
|
29
|
+
readonly light: BrandImage
|
|
30
|
+
readonly dark?: BrandImage
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type BrandImageProblem = 'type' | 'encoding' | 'size' | 'content'
|
|
34
|
+
|
|
35
|
+
const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
|
|
36
|
+
const MAX_BASE64_LENGTH = Math.ceil(MAX_BRAND_IMAGE_BYTES / 3) * 4
|
|
37
|
+
|
|
38
|
+
/** Why an image cannot be the brand, or null. The type must be one of three and must be what the bytes are. */
|
|
39
|
+
export function brandImageProblem(image: { readonly type: string; readonly base64: string }): BrandImageProblem | null {
|
|
40
|
+
if (!isBrandImageType(image.type)) return 'type'
|
|
41
|
+
if (image.base64.length === 0 || !BASE64.test(image.base64)) return 'encoding'
|
|
42
|
+
if (image.base64.length > MAX_BASE64_LENGTH) return 'size'
|
|
43
|
+
const bytes = decode(image.base64)
|
|
44
|
+
// Base64 with stray bits in its last character decodes to the same bytes; only the canonical spelling is one image.
|
|
45
|
+
if (bytes === null || brandImageBase64(bytes) !== image.base64) return 'encoding'
|
|
46
|
+
if (bytes.length > MAX_BRAND_IMAGE_BYTES) return 'size'
|
|
47
|
+
return sniff(bytes) === image.type ? null : 'content'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isBrandImageType(value: string): value is BrandImageType {
|
|
51
|
+
return BRAND_IMAGE_TYPES.some((type) => type === value)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const brandImageSchema = z.object({
|
|
55
|
+
type: z.enum(BRAND_IMAGE_TYPES),
|
|
56
|
+
base64: z.string(),
|
|
57
|
+
}).strict().superRefine((image, ctx) => {
|
|
58
|
+
const problem = brandImageProblem(image)
|
|
59
|
+
if (problem !== null) ctx.addIssue({ code: 'custom', message: `not a brand image: ${problem}` })
|
|
60
|
+
}) satisfies z.ZodType<BrandImage, z.ZodTypeDef, unknown>
|
|
61
|
+
|
|
62
|
+
export const installationBrandSchema = z.object({
|
|
63
|
+
light: brandImageSchema,
|
|
64
|
+
dark: brandImageSchema.optional(),
|
|
65
|
+
}).strict() satisfies z.ZodType<InstallationBrand, z.ZodTypeDef, unknown>
|
|
66
|
+
|
|
67
|
+
/** The image as the `src` of an `<img>`. Base64 holds no `{`, so a Caddy `static_response` body keeps it as it is. */
|
|
68
|
+
export function brandImageDataUri(image: BrandImage): string {
|
|
69
|
+
return `data:${image.type};base64,${image.base64}`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** What the bytes are, read from their start, never from what the uploader called them. */
|
|
73
|
+
function sniff(bytes: Uint8Array): BrandImageType | null {
|
|
74
|
+
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return 'image/png'
|
|
75
|
+
if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50])) return 'image/webp'
|
|
76
|
+
return isSvg(bytes) ? 'image/svg+xml' : null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* UTF-8 text whose first element is `<svg`, after an optional byte order mark, an XML declaration, comments and a
|
|
81
|
+
* doctype. An image of type SVG renders inert in an `<img>`, so this only has to stop another kind of file posing as one.
|
|
82
|
+
*/
|
|
83
|
+
function isSvg(bytes: Uint8Array): boolean {
|
|
84
|
+
let text: string
|
|
85
|
+
try {
|
|
86
|
+
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
|
|
87
|
+
} catch {
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
let rest = text.replace(/^/, '')
|
|
91
|
+
for (;;) {
|
|
92
|
+
const trimmed = rest.replace(/^\s+/, '')
|
|
93
|
+
const prologue = /^(?:<\?xml[\s\S]*?\?>|<!--[\s\S]*?-->|<!DOCTYPE\s+svg[^>]*>)/i.exec(trimmed)
|
|
94
|
+
if (prologue === null) return /^<svg[\s>]/.test(trimmed)
|
|
95
|
+
rest = trimmed.slice(prologue[0].length)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function startsWith(bytes: Uint8Array, prefix: readonly number[]): boolean {
|
|
100
|
+
return bytes.length >= prefix.length && prefix.every((byte, index) => bytes[index] === byte)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function decode(base64: string): Uint8Array | null {
|
|
104
|
+
try {
|
|
105
|
+
const binary = atob(base64)
|
|
106
|
+
const bytes = new Uint8Array(binary.length)
|
|
107
|
+
for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index)
|
|
108
|
+
return bytes
|
|
109
|
+
} catch {
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Canonical base64 of `bytes`, the spelling the check requires. Chunked, so a large file stays within `fromCharCode`'s arguments. */
|
|
115
|
+
export function brandImageBase64(bytes: Uint8Array): string {
|
|
116
|
+
let binary = ''
|
|
117
|
+
for (let start = 0; start < bytes.length; start += 0x8000) {
|
|
118
|
+
binary += String.fromCharCode(...bytes.subarray(start, start + 0x8000))
|
|
119
|
+
}
|
|
120
|
+
return btoa(binary)
|
|
121
|
+
}
|
package/src/cache.ts
CHANGED
|
Binary file
|
package/src/constants.ts
CHANGED
|
@@ -49,6 +49,15 @@ export const APP_QUERY_PARAM = 'app'
|
|
|
49
49
|
*/
|
|
50
50
|
export const LOGIN_REDIRECT_PARAM = 'redirect'
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Reserved on every app host beside the handoff callback, and answered before endpoint selection: it clears the app
|
|
54
|
+
* session and sends the browser to IAM's release (ADR-0067 §5). It takes the return path in `LOGIN_REDIRECT_PARAM`.
|
|
55
|
+
*/
|
|
56
|
+
export const AUTH_SIGNOUT_PATH = '/__fabrika/auth/signout'
|
|
57
|
+
|
|
58
|
+
/** IAM's path that revokes the browser's terminal sessions and returns it to the application (ADR-0067 §5). */
|
|
59
|
+
export const SIGNOUT_RELEASE_PATH = '/auth/terminals/release'
|
|
60
|
+
|
|
52
61
|
/** The JWKS cache TTL and the unknown-`kid` refetch cooldown, owned by the verifier both planes share. */
|
|
53
62
|
export { JWKS_REFETCH_COOLDOWN_SECONDS, JWKS_TTL_SECONDS } from '@fabrika/jwks'
|
|
54
63
|
|
package/src/handoff.ts
CHANGED
|
@@ -15,6 +15,14 @@ export async function createHandoffAttempt(): Promise<HandoffAttempt> {
|
|
|
15
15
|
return { state, verifier, challenge: await handoffChallenge(verifier) }
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* A verifier made for no challenge. IAM consumes a code before it compares the verifier, so redeeming a code with this
|
|
20
|
+
* one only spends the code.
|
|
21
|
+
*/
|
|
22
|
+
export function unmatchedVerifier(): string {
|
|
23
|
+
return randomToken(32)
|
|
24
|
+
}
|
|
25
|
+
|
|
18
26
|
/** S256, matching PKCE: the verifier never rides in a URL or reaches IAM through the browser. */
|
|
19
27
|
export async function handoffChallenge(verifier: string): Promise<string> {
|
|
20
28
|
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ export { cacheKey, MemoryTokenCache } from './cache'
|
|
|
16
16
|
export type { CachedToken, TokenCache } from './cache'
|
|
17
17
|
export {
|
|
18
18
|
APP_QUERY_PARAM,
|
|
19
|
+
AUTH_SIGNOUT_PATH,
|
|
19
20
|
CLIENT_ADDRESS_HEADER,
|
|
20
21
|
DEFAULT_HEALTH_PATH,
|
|
21
22
|
DEFAULT_IAM_TIMEOUT_MS,
|
|
@@ -32,6 +33,7 @@ export {
|
|
|
32
33
|
PREFLIGHT_HEADER,
|
|
33
34
|
PROXY_TOKEN_HEADER,
|
|
34
35
|
REQUEST_ID_HEADER,
|
|
36
|
+
SIGNOUT_RELEASE_PATH,
|
|
35
37
|
UNTRUSTED_FORWARD_HEADERS,
|
|
36
38
|
} from './constants'
|
|
37
39
|
export { carriesPlatformKey, readBearer, readCookie } from './credentials'
|
|
@@ -41,6 +43,8 @@ export type { DelegationIssuer, DelegationRequest, HttpIamGatewayOptions, IamGat
|
|
|
41
43
|
export { consoleLogger, redactPath, redactUrl, silentLogger } from './log'
|
|
42
44
|
export type { LogFields, LogValue, ProxyLogger } from './log'
|
|
43
45
|
export { findOAuthMetadata, OAUTH_METADATA_VERIFY_PATH, oauthMetadataResponse } from './oauth'
|
|
46
|
+
export { errorAnswer, errorPage, FETCH_MODE_HEADER, isPageNavigation, PAGE_CONTENT_TYPE, pageResponse, renderPage } from './pages'
|
|
47
|
+
export type { ErrorPageStatus, PageAction, PageContent } from './pages'
|
|
44
48
|
export { createVerifyService } from './service'
|
|
45
49
|
export type { VerifyService, VerifyServiceConfig } from './service'
|
|
46
50
|
export { TokenVerifier } from './verifier'
|
package/src/manifest.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ANY_ORIGIN_KINDS,
|
|
3
3
|
APP_ENVIRONMENT_SEPARATOR,
|
|
4
|
+
type BodyAdmission,
|
|
4
5
|
type Caller,
|
|
5
6
|
compileEndpointTable,
|
|
6
7
|
type ContractArtifact,
|
|
@@ -17,10 +18,16 @@ import {
|
|
|
17
18
|
type InstallationEndpointTable,
|
|
18
19
|
isDigest,
|
|
19
20
|
MAX_CACHE_AGE_SECONDS,
|
|
21
|
+
OPS_CONTENT_TYPE,
|
|
22
|
+
OPS_METHOD,
|
|
23
|
+
OPS_PREFIX,
|
|
24
|
+
OPS_ROW_ID,
|
|
25
|
+
OPS_ROW_PATH,
|
|
20
26
|
parseAppEnvironmentId,
|
|
21
27
|
requirementDigest,
|
|
22
28
|
} from '@fabrika/auth-core'
|
|
23
29
|
import { z } from 'zod'
|
|
30
|
+
import { type InstallationBrand, installationBrandSchema } from './brand'
|
|
24
31
|
import { type CspOrigins, cspOrigins, isCrossOrigin, isPolicyOrigin, MAX_CROSS_ORIGINS, MAX_CSP_ORIGINS, WORKER_BLOB_SOURCE } from './headers'
|
|
25
32
|
import { type OAuthProtectedResource, oauthProtectedResourceSchema, oauthScopesSchema } from './oauth-resource'
|
|
26
33
|
|
|
@@ -93,8 +100,8 @@ interface ProxyEndpointExtras {
|
|
|
93
100
|
delegation: EndpointDelegation | null
|
|
94
101
|
}
|
|
95
102
|
|
|
96
|
-
/**
|
|
97
|
-
export type
|
|
103
|
+
/** A row a contract declares, as the proxy applies it: the rules without the schemas, plus the digest and the delegation. */
|
|
104
|
+
export type DeclaredProxyEndpointRow =
|
|
98
105
|
| (
|
|
99
106
|
& Pick<CredentialedEndpoint, 'id' | 'kind' | 'method' | 'path' | 'callers' | 'requirement' | 'admission' | 'crossOrigin' | 'response'>
|
|
100
107
|
& ProxyEndpointExtras
|
|
@@ -110,6 +117,42 @@ export type ProxyEndpointRow =
|
|
|
110
117
|
}
|
|
111
118
|
)
|
|
112
119
|
|
|
120
|
+
/** An endpoint row of an application table: a declared row, or the ops row that only platform composition adds. */
|
|
121
|
+
export type ProxyEndpointRow = DeclaredProxyEndpointRow | OpsProxyEndpointRow
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The canonical ops row (ADR-0069): Control's channel to an environment's object, which platform composition adds to an
|
|
125
|
+
* application's table for a framework release that serves it. The parser admits only `opsProxyRow()`, field for field.
|
|
126
|
+
*/
|
|
127
|
+
export interface OpsProxyEndpointRow {
|
|
128
|
+
id: string
|
|
129
|
+
kind: 'protocol'
|
|
130
|
+
method: typeof OPS_METHOD
|
|
131
|
+
path: string
|
|
132
|
+
callers: []
|
|
133
|
+
requirement: null
|
|
134
|
+
admission: BodyAdmission
|
|
135
|
+
delegation: null
|
|
136
|
+
digest: string
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The ops row's body limit: every ops request is a small JSON object. */
|
|
140
|
+
export const OPS_ROW_MAX_BYTES = 64 * 1024
|
|
141
|
+
|
|
142
|
+
/** The ops row, digested like every row; the provider composes it and the framework checks `X-Fabrika-Endpoint` against it. */
|
|
143
|
+
export function opsProxyRow(): OpsProxyEndpointRow {
|
|
144
|
+
const rules = {
|
|
145
|
+
kind: 'protocol',
|
|
146
|
+
method: OPS_METHOD,
|
|
147
|
+
path: OPS_ROW_PATH,
|
|
148
|
+
callers: [],
|
|
149
|
+
requirement: null,
|
|
150
|
+
admission: { contentTypes: [OPS_CONTENT_TYPE], maxBytes: OPS_ROW_MAX_BYTES },
|
|
151
|
+
delegation: null,
|
|
152
|
+
} satisfies Omit<OpsProxyEndpointRow, 'id' | 'digest'>
|
|
153
|
+
return { id: OPS_ROW_ID, ...rules, digest: requirementDigest({ ...rules, oauth: null }) }
|
|
154
|
+
}
|
|
155
|
+
|
|
113
156
|
/** An installation row as the proxy applies it: the row with its digest and no delegation. */
|
|
114
157
|
export type InstallationProxyEndpointRow = InstallationEndpointRow & { digest: string; delegation: null }
|
|
115
158
|
|
|
@@ -118,6 +161,11 @@ export type ManifestEndpointRow = ProxyEndpointRow | InstallationProxyEndpointRo
|
|
|
118
161
|
|
|
119
162
|
export interface ProxyManifest {
|
|
120
163
|
apps: ProxyApp[]
|
|
164
|
+
/**
|
|
165
|
+
* The installation's client logo for the pages the proxy answers with (ADR-0066). Absent, a page draws the fabrika
|
|
166
|
+
* mark. A producer sends it only to a proxy whose source parses it: an older parser refuses the key.
|
|
167
|
+
*/
|
|
168
|
+
brand?: InstallationBrand
|
|
121
169
|
}
|
|
122
170
|
|
|
123
171
|
const HOST_LABEL = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/
|
|
@@ -190,7 +238,10 @@ const endpointRuleFields = {
|
|
|
190
238
|
admission: admissionSchema.nullable(),
|
|
191
239
|
digest: z.string().refine(isDigest),
|
|
192
240
|
}
|
|
193
|
-
/**
|
|
241
|
+
/**
|
|
242
|
+
* One row a contract declares, of a contract app's endpoint table; it refuses a digest that does not describe the row's
|
|
243
|
+
* rules. It never admits the ops row, which only `contractProxyAppSchema` reads.
|
|
244
|
+
*/
|
|
194
245
|
export const proxyEndpointRowSchema = z.union([
|
|
195
246
|
z.object({
|
|
196
247
|
...endpointRuleFields,
|
|
@@ -238,13 +289,28 @@ export const proxyEndpointRowSchema = z.union([
|
|
|
238
289
|
(row) => row.digest === requirementDigest(row),
|
|
239
290
|
'endpoint digest does not match its rules',
|
|
240
291
|
) satisfies z.ZodType<
|
|
241
|
-
|
|
292
|
+
DeclaredProxyEndpointRow,
|
|
242
293
|
z.ZodTypeDef,
|
|
243
294
|
unknown
|
|
244
295
|
>
|
|
245
296
|
|
|
297
|
+
const OPS_ROW = opsProxyRow()
|
|
298
|
+
|
|
299
|
+
/** The ops row exactly as `opsProxyRow()` builds it: every field a literal, its digest included. */
|
|
300
|
+
const opsProxyEndpointRowSchema = z.object({
|
|
301
|
+
id: z.literal(OPS_ROW_ID),
|
|
302
|
+
kind: z.literal('protocol'),
|
|
303
|
+
method: z.literal(OPS_METHOD),
|
|
304
|
+
path: z.literal(OPS_ROW_PATH),
|
|
305
|
+
callers: z.tuple([]),
|
|
306
|
+
requirement: z.null(),
|
|
307
|
+
admission: z.object({ contentTypes: z.tuple([z.literal(OPS_CONTENT_TYPE)]), maxBytes: z.literal(OPS_ROW_MAX_BYTES) }).strict(),
|
|
308
|
+
delegation: z.null(),
|
|
309
|
+
digest: z.literal(OPS_ROW.digest),
|
|
310
|
+
}).strict() satisfies z.ZodType<OpsProxyEndpointRow, z.ZodTypeDef, unknown>
|
|
311
|
+
|
|
246
312
|
/** Only an RPC procedure, a public procedure and a form open to another origin, and only the anonymous two to any (ADR-0022). */
|
|
247
|
-
function crossOriginAllowed(kind:
|
|
313
|
+
function crossOriginAllowed(kind: DeclaredProxyEndpointRow['kind'], form: CrossOriginForm | undefined): boolean {
|
|
248
314
|
return form === undefined || (CROSS_ORIGIN_KINDS.includes(kind) && (form !== 'any' || ANY_ORIGIN_KINDS.includes(kind)))
|
|
249
315
|
}
|
|
250
316
|
|
|
@@ -276,7 +342,7 @@ const cspOriginsSchema = z.object({
|
|
|
276
342
|
export const contractProxyAppSchema = z.object({
|
|
277
343
|
...proxyAppFields,
|
|
278
344
|
scheme: proxyAppScheme,
|
|
279
|
-
endpoints: z.array(proxyEndpointRowSchema),
|
|
345
|
+
endpoints: z.array(z.union([proxyEndpointRowSchema, opsProxyEndpointRowSchema])),
|
|
280
346
|
reservedPrefixes: z.array(z.string()),
|
|
281
347
|
snapshot: z.string().refine(isDigest),
|
|
282
348
|
csp: cspOriginsSchema,
|
|
@@ -284,6 +350,10 @@ export const contractProxyAppSchema = z.object({
|
|
|
284
350
|
crossOrigins: z.array(z.string().refine(isCrossOrigin)).nonempty().max(MAX_CROSS_ORIGINS).optional(),
|
|
285
351
|
}).strict().superRefine((app, ctx) => {
|
|
286
352
|
checkCrossOrigins(app, ctx)
|
|
353
|
+
// Only the ops row lies under the ops prefix: another row there would be an application route at a platform path.
|
|
354
|
+
if (app.endpoints.some((row) => row.path.startsWith(OPS_PREFIX) && row.kind !== 'protocol')) {
|
|
355
|
+
ctx.addIssue({ code: 'custom', message: 'only the ops row serves the ops prefix' })
|
|
356
|
+
}
|
|
287
357
|
checkEndpointTable(app, ctx)
|
|
288
358
|
}) satisfies z.ZodType<ContractProxyApp, z.ZodTypeDef, unknown>
|
|
289
359
|
|
|
@@ -374,9 +444,14 @@ export const proxyAppSchema = z.union([contractProxyAppSchema, installationProxy
|
|
|
374
444
|
* The manifest form of a compiled contract: every endpoint's rules with their digest and delegation, the reserved
|
|
375
445
|
* prefixes, the contract digest and the named CSP origins. Producers call this; they never assemble rows by hand.
|
|
376
446
|
* `digest` is the one the application's own release computes; Control passes it for an artifact it upcast from an
|
|
377
|
-
* older release's (ADR-0062).
|
|
447
|
+
* older release's (ADR-0062). `ops` adds the ops row, which only platform composition asks for and the artifact never
|
|
448
|
+
* carries.
|
|
378
449
|
*/
|
|
379
|
-
export function contractProxyTable(
|
|
450
|
+
export function contractProxyTable(
|
|
451
|
+
artifact: ContractArtifact,
|
|
452
|
+
digest: string = contractDigest(artifact),
|
|
453
|
+
options: { readonly ops?: boolean } = {},
|
|
454
|
+
): ContractProxyTable {
|
|
380
455
|
return {
|
|
381
456
|
endpoints: artifact.endpoints.map((row): ProxyEndpointRow => {
|
|
382
457
|
const rules = {
|
|
@@ -401,13 +476,68 @@ export function contractProxyTable(artifact: ContractArtifact, digest: string =
|
|
|
401
476
|
delegation: endpointDelegation(artifact, row),
|
|
402
477
|
}
|
|
403
478
|
return { ...credentialed, digest: requirementDigest(credentialed) }
|
|
404
|
-
}),
|
|
479
|
+
}).concat(options.ops === true ? [opsProxyRow()] : []),
|
|
405
480
|
reservedPrefixes: [...artifact.reservedPrefixes],
|
|
406
481
|
snapshot: digest,
|
|
407
482
|
csp: cspOrigins(artifact.csp),
|
|
408
483
|
}
|
|
409
484
|
}
|
|
410
485
|
|
|
486
|
+
/** Where and how a producer serves one contract application. */
|
|
487
|
+
export interface ContractEntryPlacement {
|
|
488
|
+
readonly id: string
|
|
489
|
+
readonly hosts: readonly string[]
|
|
490
|
+
readonly upstream: string
|
|
491
|
+
readonly scheme: 'http' | 'https'
|
|
492
|
+
/** The approved contract's table, from `contractProxyTable`. */
|
|
493
|
+
readonly table: ContractProxyTable
|
|
494
|
+
readonly operationsOrigin?: string
|
|
495
|
+
/** The environment's list from Control's registry; an empty one is omitted. */
|
|
496
|
+
readonly crossOrigins?: readonly string[]
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* What the proxy of one older platform release parses of an application's manifest entry: a row, and the entry's CSP
|
|
501
|
+
* directives, each by that release's own parser (ADR-0062); and whether it serves the sign-out (ADR-0067 §5).
|
|
502
|
+
*/
|
|
503
|
+
export interface OlderProxyReader {
|
|
504
|
+
readonly row: (row: ProxyEndpointRow) => boolean
|
|
505
|
+
readonly csp: (csp: CspOrigins) => boolean
|
|
506
|
+
readonly signsOut: boolean
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* The proxies of the older releases a pin may run whose parsers Control holds. A pin's release is not recorded, so what
|
|
511
|
+
* any of them reads is what the pin may read.
|
|
512
|
+
*/
|
|
513
|
+
export type OlderProxyReaders = readonly [OlderProxyReader, ...OlderProxyReader[]]
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* A contract application's manifest entry: the one form every producer builds. Refused when the proxy's own parser
|
|
517
|
+
* would refuse it, naming the fields and never their values, because an origin with userinfo may carry a key.
|
|
518
|
+
*/
|
|
519
|
+
export function contractProxyEntry(placement: ContractEntryPlacement): ContractProxyApp {
|
|
520
|
+
const { table, operationsOrigin, crossOrigins = [] } = placement
|
|
521
|
+
const entry: ContractProxyApp = {
|
|
522
|
+
id: placement.id,
|
|
523
|
+
hosts: [...placement.hosts],
|
|
524
|
+
upstream: placement.upstream,
|
|
525
|
+
scheme: placement.scheme,
|
|
526
|
+
endpoints: table.endpoints,
|
|
527
|
+
reservedPrefixes: table.reservedPrefixes,
|
|
528
|
+
snapshot: table.snapshot,
|
|
529
|
+
csp: table.csp,
|
|
530
|
+
...(operationsOrigin === undefined ? {} : { operationsOrigin }),
|
|
531
|
+
...(crossOrigins.length === 0 ? {} : { crossOrigins: [...crossOrigins] }),
|
|
532
|
+
}
|
|
533
|
+
const parsed = contractProxyAppSchema.safeParse(entry)
|
|
534
|
+
if (!parsed.success) {
|
|
535
|
+
const fields = new Set(parsed.error.issues.map((issue) => issue.path.length === 0 ? issue.message : issue.path.join('.')))
|
|
536
|
+
throw new Error(`proxy entry \`${placement.id}\` is one the proxy manifest refuses (${[...fields].join('; ')})`)
|
|
537
|
+
}
|
|
538
|
+
return entry
|
|
539
|
+
}
|
|
540
|
+
|
|
411
541
|
/** What a producer adds to an installation service's placement: the output of `serviceProxyTable`. */
|
|
412
542
|
export type InstallationProxyTable = Pick<InstallationProxyApp, 'installation' | 'endpoints' | 'reservedPrefixes'>
|
|
413
543
|
|
|
@@ -441,6 +571,7 @@ export function findDuplicateProxyHost(apps: readonly Pick<ProxyApp, 'hosts'>[])
|
|
|
441
571
|
|
|
442
572
|
export const proxyManifestSchema = z.object({
|
|
443
573
|
apps: z.array(proxyAppSchema),
|
|
574
|
+
brand: installationBrandSchema.optional(),
|
|
444
575
|
}).strict().superRefine(({ apps }, ctx) => {
|
|
445
576
|
if (new Set(apps.map((app) => app.id)).size !== apps.length) ctx.addIssue({ code: 'custom', message: 'duplicate proxy app id' })
|
|
446
577
|
if (findDuplicateProxyHost(apps) !== null) ctx.addIssue({ code: 'custom', message: 'duplicate proxy host' })
|