@fabrika/proxy-core 0.0.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/src/iam.ts ADDED
@@ -0,0 +1,257 @@
1
+ /**
2
+ * The proxy's view of the IAM service: the three cold-path calls it needs, and nothing else.
3
+ *
4
+ * `IamGateway` is a STRUCTURAL SUBSET of `IamRpc`, so on Cloudflare the thin Worker passes its
5
+ * `env.IAM` service binding straight in with no adapter. On Zerops there is no binding, so
6
+ * `HttpIamGateway` speaks HTTP to the global IAM service — the public hop ADR-0007 calls cold-path
7
+ * only: everything after the first exchange is a local JWKS verification against the cached token.
8
+ *
9
+ * FAIL-CLOSED CONTRACT: an unreachable, slow or incoherent IAM raises `IamUnavailableError`. A
10
+ * *decided* negative (`{ ok: false, reason }`) is returned normally. The caller must map the former
11
+ * to a deny too — the distinction only chooses the status, never the outcome.
12
+ */
13
+
14
+ import type {
15
+ ExchangeAuthCodeInput,
16
+ ExchangeAuthCodeResult,
17
+ Jwks,
18
+ MintFromKeyInput,
19
+ MintFromKeyResult,
20
+ MintTokenInput,
21
+ MintTokenResult,
22
+ PublicJwk,
23
+ } from '@fabrika/auth-core'
24
+ import { DEFAULT_IAM_TIMEOUT_MS } from './constants'
25
+ import { numberField, prop, stringField } from './json'
26
+
27
+ /**
28
+ * What the proxy needs of IAM. IAM's entrypoints satisfy it structurally — the CF binding drops
29
+ * straight in with no adapter. Three of the four calls are `IamRpc`; `exchangeAuthCode` is
30
+ * deliberately NOT (`IamHandoffRpc`, ADR-0021), because only the proxy may turn a handoff code into
31
+ * a session and `IamRpc` is held by every SDK consumer.
32
+ */
33
+ export interface IamGateway {
34
+ mintToken(input: MintTokenInput): Promise<MintTokenResult>
35
+ mintFromKey(input: MintFromKeyInput): Promise<MintFromKeyResult>
36
+ exchangeAuthCode(input: ExchangeAuthCodeInput): Promise<ExchangeAuthCodeResult>
37
+ getJwks(): Promise<Jwks>
38
+ }
39
+
40
+ /**
41
+ * IAM could not be consulted — network failure, timeout, non-200, or a body we cannot read. Distinct
42
+ * from a decided denial: we do not know the answer, so the request is denied with 503 rather than
43
+ * being told it is unauthorized.
44
+ *
45
+ * Carries NO cause and no response body: an error object from `fetch` can carry a URL, and a body
46
+ * can carry a token.
47
+ */
48
+ export class IamUnavailableError extends Error {
49
+ constructor(readonly operation: string) {
50
+ super(`iam unavailable: ${operation}`)
51
+ this.name = 'IamUnavailableError'
52
+ }
53
+ }
54
+
55
+ export interface HttpIamGatewayOptions {
56
+ /** The IAM service origin, e.g. `https://iam.example.com` (trailing slash tolerated). */
57
+ origin: string
58
+ /** Per-call timeout. A hung IAM must deny, not hang every request behind the proxy. */
59
+ timeoutMs?: number
60
+ /** Optional `Authorization: Bearer` the proxy authenticates ITSELF to IAM with. */
61
+ key?: string
62
+ /** Injectable for tests; defaults to the global `fetch`. */
63
+ fetch?: FetchLike
64
+ }
65
+
66
+ /**
67
+ * Just the call shape the gateway uses. Narrower than `typeof fetch` on purpose: the global carries
68
+ * runtime-specific extras (Bun's `preconnect`) that a test double has no business implementing.
69
+ */
70
+ export type FetchLike = (input: string, init: RequestInit) => Promise<Response>
71
+
72
+ /**
73
+ * HTTP transport for `IamGateway` — the Zerops path, where there is no service binding.
74
+ *
75
+ * This file and `packages/iam/src/rpc-http.ts` are two halves of one contract; neither may move
76
+ * without the other in the SAME commit. A mismatch fails closed (any non-200 becomes
77
+ * `IamUnavailableError` → a 503 deny) but is very hard to read from the outside.
78
+ *
79
+ * POST {origin}/auth/mint/session {app, session, requestId} -> MintTokenResult
80
+ * POST {origin}/auth/mint/key {app, key, requestId} -> MintFromKeyResult
81
+ * POST {origin}/auth/mint/exchange {app, code, requestId} -> ExchangeAuthCodeResult (ADR-0021)
82
+ * GET {origin}/.well-known/jwks.json -> Jwks (public)
83
+ *
84
+ * The three POST endpoints are the `/auth/mint/*` PROXY surface, gated by IAM's `proxyKey` — the
85
+ * least-privilege split ADR-0007 requires, kept apart from the management `/rpc/*` surface. They
86
+ * answer **200 on any decided outcome, including a denial**: `{ ok: false, reason }` is "IAM answered:
87
+ * no", a non-200 is "IAM could not be consulted". Both deny; only the second is an incident.
88
+ *
89
+ * Credentials always ride in the POST body, never in a URL — a URL ends up in access logs and error
90
+ * strings; a body does not.
91
+ */
92
+ export class HttpIamGateway implements IamGateway {
93
+ private readonly origin: string
94
+ private readonly timeoutMs: number
95
+ private readonly key: string | undefined
96
+ private readonly fetchImpl: FetchLike
97
+
98
+ constructor(options: HttpIamGatewayOptions) {
99
+ this.origin = options.origin.replace(/\/+$/, '')
100
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_IAM_TIMEOUT_MS
101
+ this.key = options.key
102
+ this.fetchImpl = options.fetch ?? globalThis.fetch
103
+ }
104
+
105
+ async mintToken(input: MintTokenInput): Promise<MintTokenResult> {
106
+ const parsed = parseMintResult(await this.post('/auth/mint/session', input, 'mintToken'))
107
+ if (parsed === null) {
108
+ throw new IamUnavailableError('mintToken')
109
+ }
110
+ if (parsed.ok) {
111
+ return parsed
112
+ }
113
+ return { ok: false, reason: reasonOf(parsed.reason, ['no_session', 'invalid_session', 'unknown_principal', 'disabled'], 'invalid_session') }
114
+ }
115
+
116
+ async mintFromKey(input: MintFromKeyInput): Promise<MintFromKeyResult> {
117
+ const parsed = parseMintResult(await this.post('/auth/mint/key', input, 'mintFromKey'))
118
+ if (parsed === null) {
119
+ throw new IamUnavailableError('mintFromKey')
120
+ }
121
+ if (parsed.ok) {
122
+ return parsed
123
+ }
124
+ return { ok: false, reason: reasonOf(parsed.reason, ['invalid_key', 'unknown_principal', 'disabled'], 'invalid_key') }
125
+ }
126
+
127
+ async exchangeAuthCode(input: ExchangeAuthCodeInput): Promise<ExchangeAuthCodeResult> {
128
+ const body = await this.post('/auth/mint/exchange', input, 'exchangeAuthCode')
129
+ const ok = prop(body, 'ok')
130
+ if (ok === true) {
131
+ const session = stringField(body, 'session')
132
+ const returnUrl = stringField(body, 'returnUrl')
133
+ const expiresAt = numberField(body, 'expiresAt')
134
+ if (session === undefined || session === '' || returnUrl === undefined || returnUrl === '' || expiresAt === undefined) {
135
+ throw new IamUnavailableError('exchangeAuthCode')
136
+ }
137
+ return { ok: true, session, returnUrl, expiresAt }
138
+ }
139
+ if (ok !== false) {
140
+ throw new IamUnavailableError('exchangeAuthCode')
141
+ }
142
+ const reason = stringField(body, 'reason')
143
+ if (reason === undefined) {
144
+ throw new IamUnavailableError('exchangeAuthCode')
145
+ }
146
+ return {
147
+ ok: false,
148
+ reason: reasonOf(reason, ['invalid_code', 'expired_code', 'wrong_app', 'invalid_verifier', 'unknown_principal', 'disabled'], 'invalid_code'),
149
+ }
150
+ }
151
+
152
+ async getJwks(): Promise<Jwks> {
153
+ const response = await this.send('/.well-known/jwks.json', undefined, 'getJwks')
154
+ const body: unknown = await response.json().catch(() => null)
155
+ const jwks = parseJwks(body)
156
+ if (jwks === null) {
157
+ throw new IamUnavailableError('getJwks')
158
+ }
159
+ return jwks
160
+ }
161
+
162
+ private async post(path: string, payload: unknown, operation: string): Promise<unknown> {
163
+ const response = await this.send(path, JSON.stringify(payload), operation)
164
+ return response.json().catch(() => null)
165
+ }
166
+
167
+ private async send(path: string, body: string | undefined, operation: string): Promise<Response> {
168
+ const headers = new Headers({ accept: 'application/json' })
169
+ if (body !== undefined) {
170
+ headers.set('content-type', 'application/json')
171
+ }
172
+ if (this.key !== undefined) {
173
+ headers.set('authorization', `Bearer ${this.key}`)
174
+ }
175
+ let response: Response
176
+ try {
177
+ response = await this.fetchImpl(`${this.origin}${path}`, {
178
+ method: body === undefined ? 'GET' : 'POST',
179
+ headers,
180
+ body,
181
+ signal: AbortSignal.timeout(this.timeoutMs),
182
+ })
183
+ } catch {
184
+ // Deliberately swallow the cause: a fetch error stringifies the request URL and, on some
185
+ // runtimes, request detail. We only ever surface the operation name.
186
+ throw new IamUnavailableError(operation)
187
+ }
188
+ if (!response.ok) {
189
+ throw new IamUnavailableError(operation)
190
+ }
191
+ return response
192
+ }
193
+ }
194
+
195
+ /** The shared `{ok:true,token,expiresAt} | {ok:false,reason}` wire shape, read structurally. */
196
+ type ParsedMint = { ok: true; token: string; expiresAt: number } | { ok: false; reason: string }
197
+
198
+ function parseMintResult(body: unknown): ParsedMint | null {
199
+ const ok = prop(body, 'ok')
200
+ if (ok === true) {
201
+ const token = stringField(body, 'token')
202
+ const expiresAt = numberField(body, 'expiresAt')
203
+ if (token === undefined || token === '' || expiresAt === undefined) {
204
+ return null
205
+ }
206
+ return { ok: true, token, expiresAt }
207
+ }
208
+ if (ok === false) {
209
+ const reason = stringField(body, 'reason')
210
+ return reason === undefined ? null : { ok: false, reason }
211
+ }
212
+ return null
213
+ }
214
+
215
+ /**
216
+ * Narrow a wire `reason` string to the declared union without a cast. An unrecognised reason degrades
217
+ * to the given fallback, which is always a DENY reason — an IAM that starts speaking a dialect we do
218
+ * not know must not be able to talk us into an allow.
219
+ */
220
+ function reasonOf<T extends string>(value: string, allowed: readonly T[], fallback: T): T {
221
+ for (const candidate of allowed) {
222
+ if (candidate === value) {
223
+ return candidate
224
+ }
225
+ }
226
+ return fallback
227
+ }
228
+
229
+ /**
230
+ * Read a key set, rejecting one we cannot fully represent. `PublicJwk` carries only the EC members,
231
+ * so a key of any other type would be copied into a shape missing the material that verifies it — a
232
+ * silently short key set, which reads as "this token is invalid" (a client problem) instead of "we
233
+ * could not check" (an incident). IAM signs ES256 only, so anything else means we are not reading the
234
+ * key set we think we are: refuse the whole fetch and let the caller raise `IamUnavailableError`.
235
+ */
236
+ function parseJwks(body: unknown): Jwks | null {
237
+ const rawKeys = prop(body, 'keys')
238
+ if (!Array.isArray(rawKeys)) {
239
+ return null
240
+ }
241
+ const keys: PublicJwk[] = []
242
+ for (const raw of rawKeys) {
243
+ const kty = stringField(raw, 'kty')
244
+ if (kty !== 'EC') {
245
+ return null
246
+ }
247
+ const key: PublicJwk = { kty }
248
+ for (const field of ['crv', 'x', 'y', 'kid', 'alg', 'use'] as const) {
249
+ const value = stringField(raw, field)
250
+ if (value !== undefined) {
251
+ key[field] = value
252
+ }
253
+ }
254
+ keys.push(key)
255
+ }
256
+ return { keys }
257
+ }
package/src/index.ts ADDED
@@ -0,0 +1,43 @@
1
+ // @fabrika/proxy-core — the auth enforcement decision, as a library.
2
+ //
3
+ // ADR-0022 makes the proxy the ONLY enforcement point, and there are two of them: a Bun service
4
+ // answering Caddy's `forward_auth` subrequest on Zerops, and the Cloudflare proxy Worker calling the
5
+ // same code in-process. This package is what they share — take a forwarded request, decide, and mint
6
+ // or refuse. It is runtime-neutral by construction: no Caddy, no filesystem, no process.
7
+ //
8
+ // The deployment that fronts it on Zerops — the Caddy configuration and the compiled binary — is
9
+ // `@fabrika/proxy`, which is private because it is an artifact rather than an API. The manifest wire
10
+ // contract and its strict parser are `@fabrika/proxy-contract`; the gate matcher and the token claims
11
+ // are `@fabrika/auth-core`.
12
+
13
+ export { Authorizer } from './authorize'
14
+ export type { AuthorizerOptions, Decision, DenyReason, ForwardedRequest, ResolvedApp } from './authorize'
15
+ export { cacheKey, MemoryTokenCache } from './cache'
16
+ export type { CachedToken, TokenCache } from './cache'
17
+ export {
18
+ APP_QUERY_PARAM,
19
+ CLIENT_ADDRESS_HEADER,
20
+ DEFAULT_HEALTH_PATH,
21
+ DEFAULT_IAM_TIMEOUT_MS,
22
+ DEFAULT_VERIFY_PATH,
23
+ FORWARDED_HOST_HEADER,
24
+ FORWARDED_METHOD_HEADER,
25
+ FORWARDED_PROTO_HEADER,
26
+ FORWARDED_URI_HEADER,
27
+ JWKS_TTL_SECONDS,
28
+ LOGIN_REDIRECT_PARAM,
29
+ PROXY_TOKEN_HEADER,
30
+ REQUEST_ID_HEADER,
31
+ UNTRUSTED_FORWARD_HEADERS,
32
+ } from './constants'
33
+ export { applicableGates, compileGates, readBearer, readCookie, readServiceCredential } from './gates'
34
+ export type { CompiledGate } from './gates'
35
+ export { stripFabrikaCookies } from './handoff'
36
+ export { HttpIamGateway, IamUnavailableError } from './iam'
37
+ export type { HttpIamGatewayOptions, IamGateway } from './iam'
38
+ export { consoleLogger, redactPath, redactUrl, silentLogger } from './log'
39
+ export type { LogFields, LogValue, ProxyLogger } from './log'
40
+ export { createVerifyService } from './service'
41
+ export type { VerifyService, VerifyServiceConfig } from './service'
42
+ export { TokenVerifier } from './verifier'
43
+ export type { VerifyResult } from './verifier'
package/src/json.ts ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Tiny structural readers for untrusted JSON (the deployed manifest, an IAM HTTP response body).
3
+ * They let the parsers narrow without `as` casts: read a field, check its runtime type, proceed.
4
+ * Mirrors `@fabrika/auth-core`'s internal `json.ts`, which is not exported from its entry point.
5
+ */
6
+
7
+ /** Read a property off an unknown value (undefined when absent / not an object). */
8
+ export function prop(value: unknown, key: string): unknown {
9
+ if (typeof value !== 'object' || value === null) {
10
+ return undefined
11
+ }
12
+ return Object.hasOwn(value, key) ? Reflect.get(value, key) : undefined
13
+ }
14
+
15
+ export function stringField(value: unknown, key: string): string | undefined {
16
+ const v = prop(value, key)
17
+ return typeof v === 'string' ? v : undefined
18
+ }
19
+
20
+ export function numberField(value: unknown, key: string): number | undefined {
21
+ const v = prop(value, key)
22
+ return typeof v === 'number' && Number.isFinite(v) ? v : undefined
23
+ }
24
+
25
+ export function booleanField(value: unknown, key: string): boolean | undefined {
26
+ const v = prop(value, key)
27
+ return typeof v === 'boolean' ? v : undefined
28
+ }
29
+
30
+ /** An array of strings, or undefined when the field is absent or any element is not a string. */
31
+ export function stringArrayField(value: unknown, key: string): string[] | undefined {
32
+ const v = prop(value, key)
33
+ if (!Array.isArray(v)) {
34
+ return undefined
35
+ }
36
+ const out: string[] = []
37
+ for (const item of v) {
38
+ if (typeof item !== 'string') {
39
+ return undefined
40
+ }
41
+ out.push(item)
42
+ }
43
+ return out
44
+ }
package/src/log.ts ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Logging, constrained so that secret material cannot reach it.
3
+ *
4
+ * The rule is structural rather than aspirational: a log field is a `string | number | boolean |
5
+ * null`, call sites pass only decision metadata, and the two values that CAN carry a credential —
6
+ * a request path (share-link tokens ride in the path) and a URL — go through `redactPath` /
7
+ * `redactUrl` first. Nothing in this package ever passes a header, a cookie, a token or a caught
8
+ * error object to a logger.
9
+ */
10
+
11
+ import { API_KEY_PREFIX } from '@fabrika/auth-core'
12
+
13
+ export type LogValue = string | number | boolean | null
14
+ export type LogFields = Record<string, LogValue>
15
+
16
+ export interface ProxyLogger {
17
+ info(message: string, fields?: LogFields): void
18
+ warn(message: string, fields?: LogFields): void
19
+ error(message: string, fields?: LogFields): void
20
+ }
21
+
22
+ /** Emits one JSON object per line — what Zerops' log collector expects. */
23
+ export const consoleLogger: ProxyLogger = {
24
+ info: (message, fields) => console.info(line('info', message, fields)),
25
+ warn: (message, fields) => console.warn(line('warn', message, fields)),
26
+ error: (message, fields) => console.error(line('error', message, fields)),
27
+ }
28
+
29
+ /** A logger that discards everything — the default, so a library consumer opts IN to output. */
30
+ export const silentLogger: ProxyLogger = {
31
+ info: () => {},
32
+ warn: () => {},
33
+ error: () => {},
34
+ }
35
+
36
+ function line(level: string, message: string, fields: LogFields | undefined): string {
37
+ return JSON.stringify({ level, message, ...fields })
38
+ }
39
+
40
+ /**
41
+ * Make a path safe to log. Two hazards, both real:
42
+ * - a query string can carry a declared `query` credential (`?pxt=<token>`) — dropped entirely;
43
+ * - a path SEGMENT can be a share-link `px_` credential — replaced with `px_***`.
44
+ * Anything else is preserved, because an unloggable path is an undebuggable proxy.
45
+ */
46
+ export function redactPath(pathname: string): string {
47
+ const query = pathname.indexOf('?')
48
+ const path = query === -1 ? pathname : pathname.slice(0, query)
49
+ return path
50
+ .split('/')
51
+ .map((segment) => (segment.startsWith(API_KEY_PREFIX) ? `${API_KEY_PREFIX}***` : segment))
52
+ .join('/')
53
+ }
54
+
55
+ /** Origin + redacted path. Never the query string. */
56
+ export function redactUrl(url: URL): string {
57
+ return `${url.origin}${redactPath(url.pathname)}`
58
+ }