@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
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/** Configuration, not caller-supplied URLs. Resource and metadata stay on the protected app origin. */
|
|
3
|
+
export interface OAuthProtectedResource {
|
|
4
|
+
resource: string;
|
|
5
|
+
resourceMetadata: string;
|
|
6
|
+
authorizationServers: string[];
|
|
7
|
+
scopesSupported: string[];
|
|
8
|
+
}
|
|
9
|
+
/** RFC 9728 metadata response; metadata publication does not admit requests to the resource. */
|
|
10
|
+
export interface OAuthProtectedResourceMetadata {
|
|
11
|
+
resource: string;
|
|
12
|
+
authorization_servers: string[];
|
|
13
|
+
scopes_supported: string[];
|
|
14
|
+
bearer_methods_supported: ['header'];
|
|
15
|
+
}
|
|
16
|
+
/** RFC 6750 challenge parameters with RFC 9728 resource metadata discovery. */
|
|
17
|
+
export interface OAuthBearerChallenge {
|
|
18
|
+
resource_metadata: string;
|
|
19
|
+
error?: 'invalid_token' | 'insufficient_scope';
|
|
20
|
+
scope?: string;
|
|
21
|
+
}
|
|
22
|
+
export declare const oauthScopesSchema: z.ZodEffects<z.ZodUnknown, string[], unknown>;
|
|
23
|
+
export declare const oauthProtectedResourceSchema: z.ZodEffects<z.ZodObject<{
|
|
24
|
+
resource: z.ZodEffects<z.ZodString, string, string>;
|
|
25
|
+
resourceMetadata: z.ZodEffects<z.ZodString, string, string>;
|
|
26
|
+
authorizationServers: z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">;
|
|
27
|
+
scopesSupported: z.ZodEffects<z.ZodUnknown, string[], unknown>;
|
|
28
|
+
}, "strip", z.ZodTypeAny, {
|
|
29
|
+
resource: string;
|
|
30
|
+
resourceMetadata: string;
|
|
31
|
+
authorizationServers: string[];
|
|
32
|
+
scopesSupported: string[];
|
|
33
|
+
}, {
|
|
34
|
+
resource: string;
|
|
35
|
+
resourceMetadata: string;
|
|
36
|
+
authorizationServers: string[];
|
|
37
|
+
scopesSupported?: unknown;
|
|
38
|
+
}>, {
|
|
39
|
+
resource: string;
|
|
40
|
+
resourceMetadata: string;
|
|
41
|
+
authorizationServers: string[];
|
|
42
|
+
scopesSupported: string[];
|
|
43
|
+
}, {
|
|
44
|
+
resource: string;
|
|
45
|
+
resourceMetadata: string;
|
|
46
|
+
authorizationServers: string[];
|
|
47
|
+
scopesSupported?: unknown;
|
|
48
|
+
}>;
|
|
49
|
+
export declare function parseOAuthProtectedResource(value: unknown): OAuthProtectedResource | null;
|
|
50
|
+
export declare function oauthProtectedResourceMetadata(config: OAuthProtectedResource): OAuthProtectedResourceMetadata;
|
|
51
|
+
export declare function parseOAuthBearerChallenge(value: unknown): OAuthBearerChallenge | null;
|
|
52
|
+
export declare function formatOAuthBearerChallenge(value: OAuthBearerChallenge): string;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type OAuthProtectedResource, type ProxyApp } from './manifest';
|
|
2
|
+
/**
|
|
3
|
+
* The auth service's RFC 9728 metadata endpoint. Separate from `/verify` because a 2xx from `/verify`
|
|
4
|
+
* admits the request to the app, while metadata is answered by the proxy itself and never forwarded.
|
|
5
|
+
*/
|
|
6
|
+
export declare const OAUTH_METADATA_VERIFY_PATH = "/oauth-metadata";
|
|
7
|
+
/**
|
|
8
|
+
* The entry's protected resource whose metadata URL is this request's host and exact raw pathname. Only an
|
|
9
|
+
* installation entry has one.
|
|
10
|
+
*/
|
|
11
|
+
export declare function findOAuthMetadata(app: ProxyApp, url: URL): OAuthProtectedResource | null;
|
|
12
|
+
export declare function oauthMetadataResponse(resource: OAuthProtectedResource, method: string): Response;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { InstallationBrand } from './brand';
|
|
2
|
+
/**
|
|
3
|
+
* The one stylesheet of every page fabrika renders outside the console and an application's own interface: IAM's
|
|
4
|
+
* sign-in, account, invitation and consent pages, Control's GitHub handoff, and the proxy's error pages (`./pages`).
|
|
5
|
+
* Each page is the console's welcome (`packages/dashboard/src/styles.css`, `.welcome`) set for a page that stands alone:
|
|
6
|
+
* one panel in the middle of the window, the page's own words at the left, the fabrika mark drawn large on the
|
|
7
|
+
* blueprint grid at the right, and fabrika named once, quietly, in a footer under it. Unlike the console's welcome the
|
|
8
|
+
* panel follows the scheme: a plate on the floor in light, the welcome's dark panel in dark. Buttons are the console's:
|
|
9
|
+
* outlined, and the brand blue fills only the page's one constructive step, the first button of a form.
|
|
10
|
+
* `pageBody` writes the frame, so every page has the same one. `page-style.test.ts` fails when a token here drifts from
|
|
11
|
+
* the console's.
|
|
12
|
+
*
|
|
13
|
+
* It imports nothing at runtime and loads nothing: every page that carries it admits only its own inline style, so the
|
|
14
|
+
* console's IBM Plex is used where the machine has it and a system face otherwise. The values the console mixes with
|
|
15
|
+
* `color-mix()` are written out here, and `light-dark()` is a media query, because these pages reach every browser
|
|
16
|
+
* that follows a sign-in link, not only the console's.
|
|
17
|
+
*
|
|
18
|
+
* The proxy's Caddy configuration serves it inside a `static_response` body, where Caddy expands `{name}`
|
|
19
|
+
* placeholders. Every block therefore opens with `{` and a newline, never `{` and a name.
|
|
20
|
+
*/
|
|
21
|
+
export declare const PAGE_STYLE = "\n:root {\n\tcolor-scheme: light dark;\n\t--floor: #f6f7f9;\n\t--panel: #ffffff;\n\t--panel-rule: rgba(14, 20, 32, 0.1);\n\t--art-rule: rgba(14, 20, 32, 0.08);\n\t--logo: #2544c2;\n\t--recess: #f1f3f6;\n\t--rule: rgba(14, 20, 32, 0.1);\n\t--rule-strong: rgba(14, 20, 32, 0.18);\n\t--control: #ffffff;\n\t--control-rule: rgba(14, 20, 32, 0.16);\n\t--control-hover: #f2f4f7;\n\t--ink: #0e1117;\n\t--ink-2: #343d4a;\n\t--ink-3: #4b5563;\n\t--ink-4: #596372;\n\t--brand: #2544c2;\n\t--brand-hover: #1e389f;\n\t--brand-on: #ffffff;\n\t--blueprint: #2544c2;\n\t--blueprint-hover: #1e369b;\n\t--blueprint-wash: rgba(37, 68, 194, 0.07);\n\t--rule-focus: rgba(37, 68, 194, 0.28);\n\t--lamp-stop: #bf2f2f;\n\t--wash-stop: rgba(191, 47, 47, 0.09);\n\t--r-1: 4px;\n\t--r-2: 6px;\n\t--font-ui: 'IBM Plex Sans Variable', 'IBM Plex Sans', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;\n\t--font-mono: 'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;\n}\n\n@media (prefers-color-scheme: dark) {\n\t:root {\n\t\t--floor: #080a0d;\n\t\t--panel: #080a0d;\n\t\t--panel-rule: rgba(255, 255, 255, 0.08);\n\t\t--art-rule: rgba(255, 255, 255, 0.08);\n\t\t--logo: #e6e9ee;\n\t\t--recess: #0a0d11;\n\t\t--rule: rgba(255, 255, 255, 0.1);\n\t\t--rule-strong: rgba(255, 255, 255, 0.18);\n\t\t--control: #11151a;\n\t\t--control-rule: rgba(255, 255, 255, 0.15);\n\t\t--control-hover: #1b2027;\n\t\t--ink: #f1f3f6;\n\t\t--ink-2: #b0b2b8;\n\t\t--ink-3: #85878e;\n\t\t--ink-4: #5c5e64;\n\t\t--blueprint: #9dabe4;\n\t\t--blueprint-hover: #bec7ed;\n\t\t--blueprint-wash: rgba(37, 68, 194, 0.16);\n\t\t--rule-focus: rgba(37, 68, 194, 0.45);\n\t\t--lamp-stop: #f4736f;\n\t\t--wash-stop: rgba(244, 115, 111, 0.14);\n\t}\n}\n\n*, *::before, *::after {\n\tbox-sizing: border-box;\n}\n\n[hidden] {\n\tdisplay: none !important;\n}\n\nhtml {\n\t-webkit-text-size-adjust: 100%;\n\ttext-size-adjust: 100%;\n}\n\n/* The panel and its footer, together in the middle of the window; a page taller than the window grows down. */\nbody {\n\tdisplay: grid;\n\talign-content: center;\n\tmin-height: 100vh;\n\tmin-height: 100dvh;\n\tmargin: 0;\n\tpadding: 3rem 1rem;\n\tbackground: var(--floor);\n\tcolor: var(--ink);\n\tfont: 400 0.875rem/1.5 var(--font-ui);\n\tfont-variant-numeric: tabular-nums;\n\t-webkit-font-smoothing: antialiased;\n\t-moz-osx-font-smoothing: grayscale;\n}\n\n::selection {\n\tbackground: var(--blueprint-wash);\n}\n\nmain {\n\tdisplay: grid;\n\tgrid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);\n\tgap: 2.5rem;\n\twidth: 100%;\n\tmax-width: 60rem;\n\tmargin: 0 auto;\n\tpadding: 2.75rem;\n\tbackground: var(--panel);\n\tborder: 1px solid var(--panel-rule);\n\tborder-radius: var(--r-2);\n\tcolor: var(--ink-2);\n\toverflow-wrap: anywhere;\n}\n\n.copy {\n\tmin-width: 0;\n\talign-self: center;\n}\n\n.copy > :last-child {\n\tmargin-bottom: 0;\n}\n\n/* The mark on the grid of the console's welcome, in the brand blue on both panels. */\n.art {\n\tdisplay: grid;\n\tplace-items: center;\n\tmin-height: 15rem;\n\tcolor: var(--brand);\n\tbackground-image:\n\t\tlinear-gradient(var(--art-rule) 1px, transparent 1px),\n\t\tlinear-gradient(90deg, var(--art-rule) 1px, transparent 1px);\n\tbackground-size: 24px 24px;\n\tbackground-position: center;\n\tborder-radius: var(--r-1);\n}\n\n.art svg {\n\tdisplay: block;\n\twidth: min(70%, 10rem);\n\theight: auto;\n}\n\n/* The client's logo (ADR-0066), fitted inside the grid whatever its proportions: a wordmark runs wide, a symbol tall. */\n.art img {\n\tdisplay: block;\n\tmax-width: 80%;\n\tmax-height: 8rem;\n\twidth: auto;\n\theight: auto;\n}\n\n.made-with {\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: space-between;\n\tgap: 1rem;\n\twidth: 100%;\n\tmax-width: 60rem;\n\tmargin: 1rem auto 0;\n\tpadding-inline: 0.25rem;\n\tcolor: var(--ink-3);\n\tfont-size: 0.75rem;\n}\n\n.runs-on {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tgap: 0.3125rem;\n}\n\n.runs-on svg {\n\tdisplay: block;\n}\n\n.eyebrow {\n\tmargin: 0 0 0.75rem;\n\tcolor: var(--ink-3);\n\tfont-size: 0.8125rem;\n\tfont-weight: 500;\n}\n\nh1 {\n\tmargin: 0 0 1rem;\n\tfont-size: clamp(1.75rem, 4vw, 2.5rem);\n\tfont-weight: 700;\n\tline-height: 1.05;\n\tletter-spacing: -0.04em;\n\tcolor: var(--ink);\n\ttext-wrap: balance;\n}\n\np {\n\tmax-width: 34rem;\n\tmargin: 0 0 0.75rem;\n\tcolor: var(--ink-2);\n\tfont-size: 0.9375rem;\n\tline-height: 1.6;\n\ttext-wrap: pretty;\n}\n\nstrong {\n\tcolor: var(--ink);\n\tfont-weight: 600;\n}\n\na {\n\tcolor: var(--blueprint);\n\ttext-decoration: none;\n\tborder-radius: 2px;\n}\n\na:hover {\n\tcolor: var(--blueprint-hover);\n\ttext-decoration: underline;\n\ttext-underline-offset: 2px;\n\ttext-decoration-thickness: 1px;\n}\n\n:focus-visible {\n\toutline: 2px solid var(--blueprint);\n\toutline-offset: 2px;\n}\n\ncode {\n\tpadding: 0.1em 0.35em;\n\tfont-family: var(--font-mono);\n\tfont-size: 0.92em;\n\tbackground: var(--recess);\n\tborder-radius: var(--r-1);\n}\n\nul {\n\tmargin: 0 0 0.75rem;\n\tpadding: 0;\n\tlist-style: none;\n\tborder: 1px solid var(--rule);\n\tborder-radius: var(--r-2);\n\tfont-size: 0.875rem;\n}\n\nli {\n\tpadding: 0.5rem 0.75rem;\n\tcolor: var(--ink-2);\n}\n\nli + li {\n\tborder-top: 1px solid var(--rule);\n}\n\nhr {\n\tmargin: 1.25rem 0;\n\tborder: 0;\n\tborder-top: 1px solid var(--rule);\n}\n\nform {\n\tdisplay: grid;\n\tgap: 1rem;\n\tmargin: 1rem 0 0;\n}\n\nform + p {\n\tmargin: 1rem 0 0;\n}\n\nlabel {\n\tdisplay: grid;\n\tgap: 0.375rem;\n\tcolor: var(--ink-2);\n\tfont-size: 0.8125rem;\n\tfont-weight: 600;\n}\n\ninput {\n\twidth: 100%;\n\tmin-width: 0;\n\tmin-height: 2.25rem;\n\tmargin: 0;\n\tpadding: 0.375rem 0.625rem;\n\tfont: inherit;\n\tfont-size: 0.875rem;\n\tfont-weight: 400;\n\tcolor: var(--ink);\n\tbackground: var(--control);\n\tborder: 1px solid var(--control-rule);\n\tborder-radius: var(--r-1);\n\ttransition: border-color 0.12s ease, box-shadow 0.12s ease;\n}\n\ninput::placeholder {\n\tcolor: var(--ink-4);\n}\n\ninput:hover {\n\tborder-color: var(--rule-strong);\n}\n\ninput:focus {\n\toutline: none;\n\tborder-color: var(--blueprint);\n\tbox-shadow: 0 0 0 3px var(--rule-focus);\n}\n\nbutton,\n.button {\n\tdisplay: inline-flex;\n\talign-items: center;\n\tjustify-content: center;\n\tjustify-self: start;\n\tgap: 0.375rem;\n\tmin-height: 2.25rem;\n\tmargin: 0;\n\tpadding: 0.375rem 0.875rem;\n\tfont: inherit;\n\tfont-size: 0.8125rem;\n\tfont-weight: 500;\n\twhite-space: nowrap;\n\ttext-decoration: none;\n\tcolor: var(--ink);\n\tbackground: var(--control);\n\tborder: 1px solid var(--control-rule);\n\tborder-radius: var(--r-1);\n\tcursor: pointer;\n\ttransition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;\n}\n\nbutton:hover,\n.button:hover {\n\tcolor: var(--ink);\n\ttext-decoration: none;\n\tbackground: var(--control-hover);\n\tborder-color: var(--rule-strong);\n}\n\nform > button:first-of-type {\n\tcolor: var(--brand-on);\n\tbackground: var(--brand);\n\tborder-color: var(--brand);\n\tfont-weight: 600;\n}\n\nform > button:first-of-type:hover {\n\tcolor: var(--brand-on);\n\tbackground: var(--brand-hover);\n\tborder-color: var(--brand-hover);\n}\n\n.password {\n\tdisplay: flex;\n\tbackground: var(--control);\n\tborder: 1px solid var(--control-rule);\n\tborder-radius: var(--r-1);\n\ttransition: border-color 0.12s ease, box-shadow 0.12s ease;\n}\n\n.password:hover {\n\tborder-color: var(--rule-strong);\n}\n\n.password:focus-within {\n\tborder-color: var(--blueprint);\n\tbox-shadow: 0 0 0 3px var(--rule-focus);\n}\n\n.password input {\n\tflex: 1;\n\tmin-height: calc(2.25rem - 2px);\n\tbackground: transparent;\n\tborder: 0;\n\tbox-shadow: none;\n}\n\n.password button {\n\tflex: none;\n\tmin-height: 0;\n\tmargin: 0.25rem;\n\tpadding: 0 0.625rem;\n\tfont-size: 0.75rem;\n\tcolor: var(--blueprint);\n\tbackground: transparent;\n\tborder: 0;\n}\n\n.password button:hover {\n\tcolor: var(--blueprint);\n\tbackground: var(--blueprint-wash);\n}\n\n.error {\n\tdisplay: flex;\n\tgap: 0.625rem;\n\tpadding: 0.75rem 1rem;\n\tcolor: var(--ink);\n\tbackground: var(--wash-stop);\n\tborder-radius: var(--r-2);\n}\n\n.error::before {\n\tcontent: '';\n\tflex: none;\n\twidth: 0.5rem;\n\theight: 0.5rem;\n\tmargin-top: 0.55em;\n\tborder-radius: 50%;\n\tbackground: var(--lamp-stop);\n\tbox-shadow: 0 0 0 3px var(--wash-stop);\n}\n\n/* On a phone the art becomes a band above the copy. */\n@media (max-width: 40rem) {\n\tbody {\n\t\tpadding: 1.5rem 1rem;\n\t}\n\n\tmain {\n\t\tgrid-template-columns: minmax(0, 1fr);\n\t\tgap: 1.5rem;\n\t\tpadding: 1.5rem;\n\t}\n\n\t.art {\n\t\torder: -1;\n\t\tmin-height: 7rem;\n\t}\n\n\t.art svg {\n\t\twidth: 4.5rem;\n\t}\n\n\t.art img {\n\t\tmax-height: 4.5rem;\n\t}\n\n\t/* A smaller input makes a phone zoom the page in when it is focused. */\n\tinput {\n\t\tfont-size: 1rem;\n\t}\n}\n\n@media (prefers-reduced-motion: reduce) {\n\t* {\n\t\ttransition: none !important;\n\t}\n}\n";
|
|
22
|
+
/**
|
|
23
|
+
* What the grid shows: the installation's client logo where it has one (ADR-0066), the designer's mark otherwise. The
|
|
24
|
+
* dark variant takes over on a dark page; without one, the light logo stands on the dark panel as it is.
|
|
25
|
+
*/
|
|
26
|
+
export declare function pageArt(brand: InstallationBrand | null): string;
|
|
27
|
+
/** The designer's mark, as the console's `BrandMark` draws it: `height` tall and a hair narrower. */
|
|
28
|
+
export declare function brandMark(height: number): string;
|
|
29
|
+
/** What the footer under the panel says: who runs the page, and the status the page answers with, if it has one. */
|
|
30
|
+
export interface PageFooter {
|
|
31
|
+
/** The words before the mark and the name: "Runs on" in English. */
|
|
32
|
+
readonly runsOn: string;
|
|
33
|
+
readonly status?: string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The body every page in this stylesheet has: the panel with the page's `content` at the left and the art on the grid
|
|
37
|
+
* at the right (`pageArt`), and the footer under it, which names fabrika whatever the art shows. Every value is markup
|
|
38
|
+
* the caller has already escaped; the brand is checked where it was written and again where it was read.
|
|
39
|
+
*/
|
|
40
|
+
export declare function pageBody(content: string, footer: PageFooter, brand?: InstallationBrand | null): string;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { InstallationBrand } from './brand';
|
|
2
|
+
/**
|
|
3
|
+
* The pages a person reads when the proxy, not the application, answers their browser: a refusal, an unknown host, an
|
|
4
|
+
* application that does not answer. Every enforcement point renders them from here, the auth service, the generated
|
|
5
|
+
* Caddy configuration and the Cloudflare proxy Worker, so a refusal looks the same on both providers.
|
|
6
|
+
*
|
|
7
|
+
* A page is only for a top-level navigation (`isPageNavigation`). An XHR, a `fetch`, a key-carrying program or a client
|
|
8
|
+
* that sends no `Sec-Fetch-Mode` gets the short text it always got, which is what each of them parses. The text of a
|
|
9
|
+
* page is fixed per status and names no deny reason: the reasons stay coarse and stay in the log.
|
|
10
|
+
*
|
|
11
|
+
* They look like IAM's pages because they carry the same stylesheet and frame (`./page-style`). No script and no external
|
|
12
|
+
* reference: a page is served on the application's own origin under its header policy, whose CSP admits inline styles
|
|
13
|
+
* and nothing else that a page could load from elsewhere. A download endpoint's answers never get a page, because the
|
|
14
|
+
* file treatment makes every one of them an attachment.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* The browser's own description of what issued the request, written by the browser and not by the page: `Sec-` is a
|
|
18
|
+
* forbidden header prefix, so a page's `fetch` cannot set it. Read from the FORWARDED headers like everything else a
|
|
19
|
+
* decision depends on; caddy 2.10.2's `forward_auth` subrequest carries the original `Sec-Fetch-*` verbatim.
|
|
20
|
+
*/
|
|
21
|
+
export declare const FETCH_MODE_HEADER = "Sec-Fetch-Mode";
|
|
22
|
+
/** The statuses the proxy answers on its own. */
|
|
23
|
+
export type ErrorPageStatus = 400 | 401 | 403 | 404 | 502 | 503 | 504;
|
|
24
|
+
/** A same-origin link under a page's text, drawn as the console's outlined button; an empty `href` reloads the page. */
|
|
25
|
+
export interface PageAction {
|
|
26
|
+
readonly href: string;
|
|
27
|
+
readonly label: string;
|
|
28
|
+
/** Draws the console's refresh glyph before the label, as its Retry does. */
|
|
29
|
+
readonly retry?: boolean;
|
|
30
|
+
}
|
|
31
|
+
export interface PageContent {
|
|
32
|
+
readonly title: string;
|
|
33
|
+
readonly detail: string;
|
|
34
|
+
readonly action?: PageAction;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Only an explicit `navigate`. Unlike the login bounce, an absent header is not read as a browser here: a client that
|
|
38
|
+
* sends none (curl, a script, an old SDK) has always read the text, and a page instead of it helps nobody.
|
|
39
|
+
*/
|
|
40
|
+
export declare function isPageNavigation(headers: Headers): boolean;
|
|
41
|
+
export interface ErrorPageOptions {
|
|
42
|
+
/**
|
|
43
|
+
* The code printed on the page, the status itself by default; the Caddy configuration passes
|
|
44
|
+
* `{http.error.status_code}`, a placeholder Caddy fills in for the status it answers with.
|
|
45
|
+
*/
|
|
46
|
+
readonly shownStatus?: string;
|
|
47
|
+
/** The installation's client logo (ADR-0066), from the manifest; the fabrika mark without one. */
|
|
48
|
+
readonly brand?: InstallationBrand | null;
|
|
49
|
+
}
|
|
50
|
+
/** The page of a status. */
|
|
51
|
+
export declare function errorPage(status: ErrorPageStatus, options?: ErrorPageOptions): string;
|
|
52
|
+
/** The answer of a status: its page when `asPage`, `text` as plain text otherwise. The caller applies the header policy. */
|
|
53
|
+
export declare function errorAnswer(status: ErrorPageStatus, text: string, asPage: boolean, brand?: InstallationBrand | null): Response;
|
|
54
|
+
/** A rendered page as an answer; the caller applies the header policy. */
|
|
55
|
+
export declare function pageResponse(status: number, body: string): Response;
|
|
56
|
+
export declare const PAGE_CONTENT_TYPE = "text/html; charset=utf-8";
|
|
57
|
+
/**
|
|
58
|
+
* The page in the shared stylesheet and frame (`./page-style`): a heading, one sentence and an outlined button in the
|
|
59
|
+
* panel, the status in the footer for whoever reports it. Every string that reaches the markup is a constant of this
|
|
60
|
+
* module or a status code; nothing of the request is printed, so nothing is escaped.
|
|
61
|
+
*/
|
|
62
|
+
export declare function renderPage(content: PageContent, shownStatus: string, brand?: InstallationBrand | null): string;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `forward_auth` endpoint — a fetch-style `Request → Response` service, written the way
|
|
3
|
+
* `@fabrika/iam` is so the same code runs on Bun, Node and Workers.
|
|
4
|
+
*
|
|
5
|
+
* Caddy's contract (verified against `modules/caddyhttp/reverseproxy/forwardauth/caddyfile.go`):
|
|
6
|
+
* - the subrequest is rewritten to `GET /verify` with the ORIGINAL headers, plus
|
|
7
|
+
* `X-Forwarded-Method` / `X-Forwarded-Uri`, plus reverse_proxy's `X-Forwarded-{For,Proto,Host}`;
|
|
8
|
+
* - a **2xx** response lets the request continue, and each `copy_headers` field is copied onto the
|
|
9
|
+
* UPSTREAM request — but ONLY when non-empty (Caddy guards each copy with a `not vars ""` matcher);
|
|
10
|
+
* - **any other status** is returned to the client verbatim — status, headers AND body, verified
|
|
11
|
+
* against caddy 2.10.2 — so both a 302 to `/auth/login` and a 401 carrying a JSON login envelope
|
|
12
|
+
* reach the caller and neither reaches the app.
|
|
13
|
+
*
|
|
14
|
+
* The two consequences that shape this file:
|
|
15
|
+
* 1. the request being authorized is described ENTIRELY by the forwarded headers. Never look at
|
|
16
|
+
* this request's own method or path — a missing `X-Forwarded-Uri` must deny, not fall back;
|
|
17
|
+
* 2. because an empty copy header is a no-op rather than a delete, the generated Caddy config
|
|
18
|
+
* strips the token header AND the request id from the inbound request BEFORE `forward_auth`
|
|
19
|
+
* runs. Otherwise a client could present its own token header on a `public` path and have it
|
|
20
|
+
* reach the app, or choose the correlation id that lands in IAM's audit trail. Both are handed
|
|
21
|
+
* back on a 2xx, so the edge is the only thing that mints either.
|
|
22
|
+
*/
|
|
23
|
+
import type { TokenCache } from './cache';
|
|
24
|
+
import type { DelegationIssuer, IamGateway } from './iam';
|
|
25
|
+
import { type ProxyLogger } from './log';
|
|
26
|
+
import { type ProxyManifest } from './manifest';
|
|
27
|
+
export interface VerifyServiceConfig {
|
|
28
|
+
manifest: ProxyManifest;
|
|
29
|
+
iam: IamGateway;
|
|
30
|
+
/** IAM's origin — token `iss` and the base of the login bounce. */
|
|
31
|
+
issuer: string;
|
|
32
|
+
/** `null`/omitted disables the token cache. Same decisions, more IAM calls. */
|
|
33
|
+
cache?: TokenCache | null;
|
|
34
|
+
logger?: ProxyLogger;
|
|
35
|
+
/** Liveness probe path on the auth service's own port. Default `/healthz`. */
|
|
36
|
+
healthPath?: string;
|
|
37
|
+
/** Unix seconds. Injectable for tests. */
|
|
38
|
+
now?: () => number;
|
|
39
|
+
/** Issues delegation grants for membership and scope-creating endpoints; see `AuthorizerOptions.delegations`. */
|
|
40
|
+
delegations?: DelegationIssuer | null;
|
|
41
|
+
}
|
|
42
|
+
export type VerifyService = (request: Request) => Promise<Response>;
|
|
43
|
+
/**
|
|
44
|
+
* Build the `/verify` service. Endpoint tables are compiled once here, not per request — the planner is
|
|
45
|
+
* on the hot path of every request to every app.
|
|
46
|
+
*/
|
|
47
|
+
export declare function createVerifyService(config: VerifyServiceConfig): VerifyService;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local token verification — the warm path. Fetch the JWKS once, then verify every token with no
|
|
3
|
+
* network call on the warm path.
|
|
4
|
+
*
|
|
5
|
+
* The verification itself is `@fabrika/jwks`, shared byte-for-byte with the app SDK. What this file
|
|
6
|
+
* owns is the proxy's construction shape: one long-lived process holding one verifier, so the key set
|
|
7
|
+
* is cached per instance, and the audience is the app the forwarded request resolved to — per call,
|
|
8
|
+
* not per verifier.
|
|
9
|
+
*/
|
|
10
|
+
import type { Jwks, TokenAudienceProfile } from '@fabrika/auth-core';
|
|
11
|
+
import { type VerifyResult } from '@fabrika/jwks';
|
|
12
|
+
export type { VerifyResult };
|
|
13
|
+
export declare class TokenVerifier {
|
|
14
|
+
private readonly inner;
|
|
15
|
+
constructor(getJwks: () => Promise<Jwks>, issuer: string, now: () => number, ttlSeconds?: number, refetchCooldownSeconds?: number);
|
|
16
|
+
/**
|
|
17
|
+
* Verify `token` for `audience` (a bare string is an app id). An unknown `kid` forces one JWKS refetch (a key rotated in), then a
|
|
18
|
+
* retry — but at most once per cooldown, because otherwise any caller can turn every request into an
|
|
19
|
+
* IAM round trip just by inventing a `kid`.
|
|
20
|
+
*/
|
|
21
|
+
verify(token: string, audience: string | TokenAudienceProfile): Promise<VerifyResult>;
|
|
22
|
+
}
|