@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/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@fabrika/proxy-core",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/contember/fabrika-platform.git",
8
+ "directory": "packages/proxy-core"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "!src/**/__tests__/**"
16
+ ],
17
+ "exports": {
18
+ ".": {
19
+ "bun": "./src/index.ts",
20
+ "types": "./src/index.ts",
21
+ "default": "./src/index.ts"
22
+ }
23
+ },
24
+ "scripts": {
25
+ "typecheck": "tsc --noEmit",
26
+ "test": "bun test"
27
+ },
28
+ "dependencies": {
29
+ "@fabrika/auth-core": "^0.0.1",
30
+ "@fabrika/proxy-contract": "^0.0.1",
31
+ "jose": "^5.9.0"
32
+ }
33
+ }
@@ -0,0 +1,406 @@
1
+ /**
2
+ * The proxy authorization core ([ADR-0007](../../../docs/decisions/0007-proxy-based-auth-enforcement.md)).
3
+ *
4
+ * Same four steps, same order, same fail-closed posture:
5
+ * 1. match the request path against the app's ordered gate rules;
6
+ * 2. resolve the credential the matched rule requires (cookie session, `px_` key, passthrough JWT);
7
+ * 3. exchange it with IAM once, cache the result, and thereafter verify LOCALLY against the JWKS;
8
+ * 4. deny unless a rule was satisfied.
9
+ *
10
+ * A human miss becomes a bounce to IAM's `/auth/login`, in whichever shape the caller can act on: a
11
+ * 302 for a document navigation, a 401 carrying the same URL in a JSON body for everything else (see
12
+ * `isDocumentNavigation`). Caddy returns a non-2xx auth response — status, headers and body — to the
13
+ * client verbatim, so both reach the caller and neither reaches the app. A request with no session
14
+ * cookie needs no IAM call.
15
+ *
16
+ * NOTHING here returns an allow on an unexpected condition. Every `catch` maps to a deny.
17
+ */
18
+
19
+ import { API_KEY_PREFIX, AUTH_CODE_PARAM, AUTH_HANDOFF_STATE_PARAM, SESSION_COOKIE, TOKEN_REFRESH_SKEW_SECONDS } from '@fabrika/auth-core'
20
+ import type { ProxyApp } from '@fabrika/proxy-contract'
21
+ import { cacheKey, type TokenCache } from './cache'
22
+ import { LOGIN_REDIRECT_PARAM } from './constants'
23
+ import { applicableGates, type CompiledGate, readCookie, readServiceCredential } from './gates'
24
+ import { createHandoffAttempt, type HandoffAttempt, handoffCookieName, setHandoffParams } from './handoff'
25
+ import { type IamGateway, IamUnavailableError } from './iam'
26
+ import { TokenVerifier } from './verifier'
27
+
28
+ /** Every way a request can be refused. Coarse on purpose — these are logged, not returned to clients. */
29
+ export type DenyReason =
30
+ /** No gate rule matched the path. The fail-closed default. */
31
+ | 'no_rule'
32
+ /** Every matching rule was a `service` rule and no credential was presented. */
33
+ | 'no_credential'
34
+ /** A `px_` key or passthrough JWT was presented and is not valid. */
35
+ | 'invalid_key'
36
+ | 'no_session'
37
+ | 'invalid_session'
38
+ | 'unknown_principal'
39
+ | 'disabled'
40
+ /** The `Host` maps to no app in the manifest. */
41
+ | 'no_app'
42
+ /** The forwarded-request headers are missing or malformed — we cannot know what was asked for. */
43
+ | 'bad_forward'
44
+ /** IAM could not be consulted, or its keys could not be fetched. We do not know; we refuse. */
45
+ | 'iam_unavailable'
46
+ /** Anything that escaped. Always a deny. */
47
+ | 'internal_error'
48
+
49
+ export type Decision =
50
+ | {
51
+ outcome: 'allow'
52
+ gate: 'public' | 'service' | 'human'
53
+ /** The verified token to inject upstream. `null` on a `public` gate — no credential, no token. */
54
+ token: string | null
55
+ /** Principal id (or credential id) for the log line. Never a secret. */
56
+ subject: string | null
57
+ }
58
+ | { outcome: 'deny'; status: 401 | 403 | 503; reason: DenyReason; clearHandoffState?: string }
59
+ /**
60
+ * A human hit a `human` gate without a usable session. `location` is where they must go to sign
61
+ * in; `status` is only how they are TOLD — 302 for a document navigation, 401 for anything else,
62
+ * whose body carries the same URL. See `isDocumentNavigation` for why the two differ.
63
+ */
64
+ | { outcome: 'login'; status: 302 | 401; location: string; reason: DenyReason; handoff: HandoffAttempt }
65
+ /**
66
+ * A handoff code was redeemed (ADR-0021). The only outcome that writes the long-lived app
67
+ * session: `session` is host-only on this app's host, and `location` is where IAM said the browser
68
+ * was going — read from the code server-side, never from the request.
69
+ */
70
+ | { outcome: 'handoff'; status: 302; location: string; session: string; expiresAt: number; handoffState: string }
71
+
72
+ /** The original request, reconstructed from what `forward_auth` forwarded. */
73
+ export interface ForwardedRequest {
74
+ /** Original method. Informational — gates are path-only — but logged. */
75
+ method: string
76
+ /** Original absolute URL. Its `pathname` drives gate matching; the whole URL is the login return. */
77
+ url: URL
78
+ /** The original request's headers, forwarded verbatim by Caddy. */
79
+ headers: Headers
80
+ requestId: string
81
+ }
82
+
83
+ /** An app plus its precompiled gates, built once at startup. */
84
+ export interface ResolvedApp {
85
+ app: ProxyApp
86
+ gates: CompiledGate[]
87
+ }
88
+
89
+ export interface AuthorizerOptions {
90
+ iam: IamGateway
91
+ /** IAM's origin — verifies the token `iss` and is the base for the `/auth/login` bounce. */
92
+ issuer: string
93
+ /** `null` disables caching entirely. Decisions are identical either way; only the IAM call count changes. */
94
+ cache?: TokenCache | null
95
+ /** Unix seconds. Injectable for tests. */
96
+ now?: () => number
97
+ }
98
+
99
+ /**
100
+ * The browser's own description of what issued the request. Read from the FORWARDED headers like
101
+ * everything else a decision depends on — verified against caddy 2.10.2, whose `forward_auth`
102
+ * subrequest carries the original `Sec-Fetch-*` verbatim.
103
+ */
104
+ const FETCH_MODE_HEADER = 'Sec-Fetch-Mode'
105
+
106
+ /** Why a `human` attempt failed — mirrors `mintToken`'s reasons. */
107
+ type SessionFailureReason = 'no_session' | 'invalid_session' | 'unknown_principal' | 'disabled'
108
+
109
+ /**
110
+ * The outcome of resolving the human behind a request. Three arms, not two: "there is no usable
111
+ * session" bounces the browser to login, but "we could not check" must NOT — an unreachable IAM would
112
+ * otherwise look like an expired session and put the user in a login loop instead of showing an
113
+ * incident.
114
+ */
115
+ type SessionAttempt =
116
+ | { ok: true; token: string; subject: string }
117
+ | { ok: false; status: 'miss'; reason: SessionFailureReason }
118
+ | { ok: false; status: 'unavailable' }
119
+
120
+ export class Authorizer {
121
+ private readonly verifier: TokenVerifier
122
+ private readonly cache: TokenCache | null
123
+ private readonly now: () => number
124
+
125
+ constructor(private readonly options: AuthorizerOptions) {
126
+ this.now = options.now ?? (() => Math.floor(Date.now() / 1000))
127
+ this.cache = options.cache ?? null
128
+ this.verifier = new TokenVerifier(() => options.iam.getJwks(), options.issuer, this.now)
129
+ }
130
+
131
+ /**
132
+ * Decide one request. NEVER throws and NEVER returns an allow it did not derive from a verified
133
+ * token — the outer `catch` is the last line of the fail-closed guarantee.
134
+ */
135
+ async authorize(resolved: ResolvedApp, request: ForwardedRequest): Promise<Decision> {
136
+ try {
137
+ return await this.decide(resolved, request)
138
+ } catch (err) {
139
+ if (err instanceof IamUnavailableError) {
140
+ return { outcome: 'deny', status: 503, reason: 'iam_unavailable' }
141
+ }
142
+ return { outcome: 'deny', status: 403, reason: 'internal_error' }
143
+ }
144
+ }
145
+
146
+ private async decide(resolved: ResolvedApp, request: ForwardedRequest): Promise<Decision> {
147
+ const matching = applicableGates(resolved.gates, request.url.pathname)
148
+ if (matching.length === 0) {
149
+ // Fail closed — the app has no public route of its own, so an unmatched path is denied.
150
+ return { outcome: 'deny', status: 403, reason: 'no_rule' }
151
+ }
152
+
153
+ // Remembers a human rule's miss (with its precise reason) so the browser is bounced to login
154
+ // only after every matching rule has been exhausted.
155
+ let humanMiss: SessionFailureReason | null = null
156
+ // The human behind a request is resolved AT MOST ONCE. Two matching human rules cannot reach
157
+ // different verdicts, so a second resolution would only buy a second `mintToken`.
158
+ let session: SessionAttempt | null = null
159
+
160
+ for (const { rule } of matching) {
161
+ if (rule.kind === 'public') {
162
+ return { outcome: 'allow', gate: 'public', token: null, subject: null }
163
+ }
164
+ if (rule.kind === 'service') {
165
+ const raw = readServiceCredential(request.headers, request.url, rule.credential)
166
+ if (raw === null) {
167
+ continue // absent → fall through to the next matching rule
168
+ }
169
+ return await this.authorizeBearer(resolved.app, raw, request.requestId) // present → terminal
170
+ }
171
+ session ??= await this.authorizeSession(resolved.app, request)
172
+ if (session.ok) {
173
+ return { outcome: 'allow', gate: 'human', token: session.token, subject: session.subject }
174
+ }
175
+ if (session.status === 'unavailable') {
176
+ return { outcome: 'deny', status: 503, reason: 'iam_unavailable' }
177
+ }
178
+ if (session.reason !== 'no_session') {
179
+ return await this.loginDecision(resolved.app, request, session.reason)
180
+ }
181
+ humanMiss = session.reason
182
+ }
183
+
184
+ if (humanMiss !== null) {
185
+ return await this.loginDecision(resolved.app, request, humanMiss)
186
+ }
187
+ // Every matching rule was a `service` rule with no credential present.
188
+ return { outcome: 'deny', status: 401, reason: 'no_credential' }
189
+ }
190
+
191
+ /**
192
+ * A `service`-rule bearer. A `px_` opaque key is exchanged via `mintFromKey` (cached, so the hot
193
+ * path is a local verify); anything else is treated as an already-signed passthrough token and
194
+ * verified locally with no IAM call. A present-but-invalid credential fails closed — no fall-through.
195
+ */
196
+ private async authorizeBearer(app: ProxyApp, bearer: string, requestId: string): Promise<Decision> {
197
+ if (!bearer.startsWith(API_KEY_PREFIX)) {
198
+ const verified = await this.verifier.verify(bearer, app.id)
199
+ if (!verified.ok) {
200
+ return verified.reason === 'unavailable'
201
+ ? { outcome: 'deny', status: 503, reason: 'iam_unavailable' }
202
+ : { outcome: 'deny', status: 401, reason: 'invalid_key' }
203
+ }
204
+ return { outcome: 'allow', gate: 'service', token: bearer, subject: verified.claims.sub }
205
+ }
206
+
207
+ const key = cacheKey(app.id, 'key', bearer)
208
+ const cached = this.cache?.get(key)
209
+ if (cached !== undefined && cached.expiresAt - this.now() > TOKEN_REFRESH_SKEW_SECONDS) {
210
+ const verified = await this.verifier.verify(cached.token, app.id)
211
+ if (verified.ok) {
212
+ return { outcome: 'allow', gate: 'service', token: cached.token, subject: verified.claims.sub }
213
+ }
214
+ if (verified.reason === 'unavailable') {
215
+ // Nothing is wrong with the entry — we simply cannot check it. Keep it; a re-mint could
216
+ // not be verified either, so it would only add an IAM call to the same 503.
217
+ return { outcome: 'deny', status: 503, reason: 'iam_unavailable' }
218
+ }
219
+ // A cached entry that PROVED bad (rotated key, revoked issuer) is dropped, and we fall
220
+ // through to a fresh mint rather than trusting it.
221
+ this.cache?.delete(key)
222
+ }
223
+
224
+ const minted = await this.callIam('mintFromKey', () => this.options.iam.mintFromKey({ app: app.id, key: bearer, requestId }))
225
+ if (!minted.ok) {
226
+ return { outcome: 'deny', status: minted.reason === 'invalid_key' ? 401 : 403, reason: minted.reason }
227
+ }
228
+ const verified = await this.verifier.verify(minted.token, app.id)
229
+ if (!verified.ok) {
230
+ // We just minted it, so a verification miss is a config/clock/key problem — not a client
231
+ // error. Either way: deny.
232
+ return verified.reason === 'unavailable'
233
+ ? { outcome: 'deny', status: 503, reason: 'iam_unavailable' }
234
+ : { outcome: 'deny', status: 401, reason: 'invalid_key' }
235
+ }
236
+ this.cache?.set(key, { token: minted.token, expiresAt: minted.expiresAt })
237
+ return { outcome: 'allow', gate: 'service', token: minted.token, subject: verified.claims.sub }
238
+ }
239
+
240
+ /**
241
+ * Resolve a human. Two tiers, cheapest first:
242
+ * 1. a cached token minted earlier from the SAME session — no IAM call;
243
+ * 2. exchange the `px_session` SSO cookie via `mintToken`.
244
+ *
245
+ * Every tier additionally requires `ptype: 'user'`: a `human` gate asserts that a resolved USER
246
+ * principal exists. Without the check a broken IAM could turn a service token into a human allow.
247
+ */
248
+ private async authorizeSession(app: ProxyApp, request: ForwardedRequest): Promise<SessionAttempt> {
249
+ const cookieHeader = request.headers.get('Cookie')
250
+ const now = this.now()
251
+
252
+ const session = readCookie(cookieHeader, SESSION_COOKIE)
253
+ if (session === null) {
254
+ // DIVERGENCE from the SDK, which calls `mintToken({ session: null })` and lets IAM answer
255
+ // `no_session`. The proxy sits in front of EVERY request to every app, so an anonymous
256
+ // flood would otherwise become an IAM flood. The answer is knowable locally; take it.
257
+ return { ok: false, status: 'miss', reason: 'no_session' }
258
+ }
259
+
260
+ const key = cacheKey(app.id, 'session', session)
261
+ const cached = this.cache?.get(key)
262
+ if (cached !== undefined && cached.expiresAt - now > TOKEN_REFRESH_SKEW_SECONDS) {
263
+ const verified = await this.verifier.verify(cached.token, app.id)
264
+ if (verified.ok && verified.claims.ptype === 'user') {
265
+ return { ok: true, token: cached.token, subject: verified.claims.sub }
266
+ }
267
+ if (!verified.ok && verified.reason === 'unavailable') {
268
+ // Nothing is wrong with the entry — we simply cannot check it. Keep it and say so.
269
+ return { ok: false, status: 'unavailable' }
270
+ }
271
+ // Only a token that PROVED bad is dropped (rotated key, revoked issuer, wrong principal).
272
+ this.cache?.delete(key)
273
+ }
274
+
275
+ const minted = await this.callIam('mintToken', () => this.options.iam.mintToken({ app: app.id, session, requestId: request.requestId }))
276
+ if (!minted.ok) {
277
+ return { ok: false, status: 'miss', reason: minted.reason }
278
+ }
279
+ const verified = await this.verifier.verify(minted.token, app.id)
280
+ if (!verified.ok) {
281
+ // We just minted it, so this is a config/clock/key problem, not a bad session — and if the
282
+ // key set is unreachable it is an incident, not a reason to bounce the browser to login.
283
+ return verified.reason === 'unavailable' ? { ok: false, status: 'unavailable' } : { ok: false, status: 'miss', reason: 'invalid_session' }
284
+ }
285
+ if (verified.claims.ptype !== 'user') {
286
+ // `mintToken` always signs `type: 'user'`; anything else means IAM is not what we think.
287
+ return { ok: false, status: 'miss', reason: 'invalid_session' }
288
+ }
289
+ this.cache?.set(key, { token: minted.token, expiresAt: minted.expiresAt })
290
+ return { ok: true, token: minted.token, subject: verified.claims.sub }
291
+ }
292
+
293
+ /**
294
+ * Every IAM call goes through here, so ANY failure — a rejected fetch, a Workers RPC error, a
295
+ * gateway that does not honour the `IamUnavailableError` contract — becomes the same deny. The
296
+ * gateway is an injected interface; the fail-closed guarantee must not depend on its manners.
297
+ */
298
+ private async callIam<T>(operation: string, call: () => Promise<T>): Promise<T> {
299
+ try {
300
+ return await call()
301
+ } catch (err) {
302
+ // Never inspect or re-wrap the cause: it can stringify a request URL or a body.
303
+ throw err instanceof IamUnavailableError ? err : new IamUnavailableError(operation)
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Redeem a handoff code (ADR-0021). Called for the reserved callback path only, BEFORE gate
309
+ * matching — the callback is how a browser becomes able to satisfy a gate, so it cannot be behind
310
+ * one.
311
+ *
312
+ * A failed redemption is an ordinary deny with no cookie. It is deliberately not a fresh bounce to
313
+ * IAM: a code that will not redeem twice would turn a retry into a loop.
314
+ */
315
+ async authorizeCallback(resolved: ResolvedApp, request: ForwardedRequest): Promise<Decision> {
316
+ const state = request.url.searchParams.get(AUTH_HANDOFF_STATE_PARAM)
317
+ if (handoffCookieName(state) === null || state === null) {
318
+ return { outcome: 'deny', status: 403, reason: 'no_credential' }
319
+ }
320
+ try {
321
+ return await this.redeem(resolved, request, state)
322
+ } catch (err) {
323
+ if (err instanceof IamUnavailableError) {
324
+ return { outcome: 'deny', status: 503, reason: 'iam_unavailable', clearHandoffState: state }
325
+ }
326
+ return { outcome: 'deny', status: 403, reason: 'internal_error', clearHandoffState: state }
327
+ }
328
+ }
329
+
330
+ private async redeem(resolved: ResolvedApp, request: ForwardedRequest, state: string): Promise<Decision> {
331
+ const code = request.url.searchParams.get(AUTH_CODE_PARAM)
332
+ if (code === null || code === '') {
333
+ return { outcome: 'deny', status: 403, reason: 'no_credential', clearHandoffState: state }
334
+ }
335
+ const cookieName = handoffCookieName(state)
336
+ const verifier = cookieName === null ? null : readCookie(request.headers.get('Cookie'), cookieName)
337
+ if (verifier === null) {
338
+ return { outcome: 'deny', status: 403, reason: 'invalid_session', clearHandoffState: state }
339
+ }
340
+ // Through `callIam` like every other IAM call, so an unreachable service is a 503 whatever kind
341
+ // of error the injected gateway happened to throw. `authorize`'s outer catch maps the rest.
342
+ const result = await this.callIam(
343
+ 'exchangeAuthCode',
344
+ () => this.options.iam.exchangeAuthCode({ app: resolved.app.id, code, verifier, requestId: request.requestId }),
345
+ )
346
+ if (!result.ok) {
347
+ return { outcome: 'deny', status: 403, reason: 'invalid_session', clearHandoffState: state }
348
+ }
349
+ return { outcome: 'handoff', status: 302, location: result.returnUrl, session: result.session, expiresAt: result.expiresAt, handoffState: state }
350
+ }
351
+
352
+ private async loginDecision(app: ProxyApp, request: ForwardedRequest, reason: SessionFailureReason): Promise<Decision> {
353
+ const handoff = await createHandoffAttempt()
354
+ return {
355
+ outcome: 'login',
356
+ status: isDocumentNavigation(request.headers) ? 302 : 401,
357
+ location: this.loginUrl(app, request.url, handoff),
358
+ reason,
359
+ handoff,
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Where to send the browser to log in, returning to the originally requested URL afterwards.
365
+ *
366
+ * The scheme comes from the MANIFEST, not from the forwarded request: behind a TLS-terminating
367
+ * balancer the proxy is reached over plain HTTP and `X-Forwarded-Proto` says so, which would send
368
+ * the browser back to an `http://` URL for an origin only served over `https://`.
369
+ */
370
+ private loginUrl(app: ProxyApp, url: URL, handoff: HandoffAttempt): string {
371
+ const target = new URL(url)
372
+ target.protocol = `${app.scheme}:`
373
+ // `readProxyEnv` already reduces the issuer to an origin; this covers a composition root that
374
+ // does not go through it (the Cloudflare Worker reads its env directly).
375
+ const issuer = this.options.issuer.replace(/\/+$/, '')
376
+ const login = new URL('/auth/login', issuer)
377
+ login.searchParams.set('app', app.id)
378
+ login.searchParams.set(LOGIN_REDIRECT_PARAM, target.toString())
379
+ setHandoffParams(login, handoff)
380
+ return login.toString()
381
+ }
382
+ }
383
+
384
+ /**
385
+ * Is this a top-level document navigation — the one caller a 302 actually helps?
386
+ *
387
+ * A redirect is not a neutral way to say "sign in". A cross-origin bounce to IAM is something a
388
+ * page's `fetch` cannot follow, so the console sees an opaque network failure instead of a sign-in;
389
+ * and a 302 destroys the request it answers, turning an in-flight POST into a bodyless GET — the same
390
+ * argument ADR-0021 makes for handing over a session rather than a bare token.
391
+ *
392
+ * **`Sec-Fetch-Mode` is the signal, not `Accept`.** It is written by the browser and not by the page:
393
+ * `Sec-` is a forbidden header PREFIX, so `fetch`/XHR cannot set it, and only a document or frame
394
+ * navigation carries `navigate` — `fetch()` and XHR emit `cors` / `same-origin` / `no-cors`. `Accept:
395
+ * text/html` (what the deleted SDK's `wantsHtml` used) is an ordinary header: any XHR may send it and
396
+ * a navigation may omit it, so it would let the CALLER pick the answer shape instead of describing
397
+ * what the caller is.
398
+ *
399
+ * **Absent → 302**, the answer this proxy has always given. A client old enough to omit the header is
400
+ * overwhelmingly a browser navigating, and a JSON body is unusable to one; a non-browser client that
401
+ * sends no `Sec-Fetch-Mode` is no worse off than before this distinction existed.
402
+ */
403
+ function isDocumentNavigation(headers: Headers): boolean {
404
+ const mode = headers.get(FETCH_MODE_HEADER)
405
+ return mode === null || mode === '' || mode === 'navigate'
406
+ }
package/src/cache.ts ADDED
Binary file
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The wire names the proxy and its Caddy config agree on. Both sides are generated from this file
3
+ * (`caddy.ts` reads the same constants the service does), so a rename cannot desynchronise them.
4
+ */
5
+
6
+ export { CLIENT_ADDRESS_HEADER, PROXY_TOKEN_HEADER, UNTRUSTED_FORWARD_HEADERS } from '@fabrika/auth-core'
7
+
8
+ // ── What Caddy's `forward_auth` sends us ───────────────────────────────────────
9
+ //
10
+ // `forward_auth` rewrites the subrequest to `GET <uri>`, so the ORIGINAL method and URI arrive only
11
+ // in these headers. `reverse_proxy` adds the other three X-Forwarded-* itself. Everything the gate
12
+ // decision depends on comes from here — never from the `/verify` request's own method/path.
13
+
14
+ export const FORWARDED_METHOD_HEADER = 'X-Forwarded-Method'
15
+ export const FORWARDED_URI_HEADER = 'X-Forwarded-Uri'
16
+ export const FORWARDED_HOST_HEADER = 'X-Forwarded-Host'
17
+ export const FORWARDED_PROTO_HEADER = 'X-Forwarded-Proto'
18
+
19
+ /** Correlation id, when the edge supplies one; otherwise the proxy generates a UUIDv7. */
20
+ export const REQUEST_ID_HEADER = 'X-Request-Id'
21
+
22
+ /** The auth service's decision endpoint (Caddy's `uri` subdirective). */
23
+ export const DEFAULT_VERIFY_PATH = '/verify'
24
+ /** Liveness endpoint on the auth service itself — never routed publicly. */
25
+ export const DEFAULT_HEALTH_PATH = '/healthz'
26
+ /**
27
+ * Query parameter the generated Caddy route pins the app id with. Explicit beats host-sniffing: the
28
+ * route already knows which app it fronts, so the auth service never has to infer it.
29
+ */
30
+ export const APP_QUERY_PARAM = 'app'
31
+
32
+ /**
33
+ * Query parameter IAM's `/auth/login` takes the return URL in (ADR-0021). The proxy writes it on the
34
+ * login bounce and redacts it out of the access log, where it would re-emit — percent-encoded — a
35
+ * credential that was just stripped from the logged URI.
36
+ */
37
+ export const LOGIN_REDIRECT_PARAM = 'redirect'
38
+
39
+ /** How long a fetched JWKS is reused before a refetch (a kid rotation also forces one on demand). */
40
+ export const JWKS_TTL_SECONDS = 600
41
+ /**
42
+ * Minimum gap between two unknown-`kid` refetches. Bounds how often a token nobody can verify may
43
+ * cost an IAM round trip — the token is denied either way, and a real rotation is picked up within it.
44
+ */
45
+ export const JWKS_REFETCH_COOLDOWN_SECONDS = 30
46
+
47
+ /** Default IAM request timeout. A hung IAM must deny, not hang every request behind it. */
48
+ export const DEFAULT_IAM_TIMEOUT_MS = 5_000
package/src/gates.ts ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Credential extraction for the gate evaluator relocated out of the app's process
3
+ * ([ADR-0007](../../../docs/decisions/0007-proxy-based-auth-enforcement.md)).
4
+ *
5
+ * The semantics are `AppGates`', verbatim (see `@fabrika/auth-core`'s `types.ts`):
6
+ * - array order IS the precedence;
7
+ * - a matching rule whose credential is ABSENT falls through to the next matching rule;
8
+ * - a matching rule whose credential is PRESENT is terminal (valid → allow, invalid → deny);
9
+ * - a request matching NO rule is denied.
10
+ *
11
+ * The canonical matcher and its ordered compiled representation live in `@fabrika/auth-core` so the
12
+ * gate declaration and its evaluator cannot drift. Credential extraction remains proxy-owned.
13
+ */
14
+
15
+ import type { CredentialLocation } from '@fabrika/auth-core'
16
+
17
+ export { applicableGates, compileGates } from '@fabrika/auth-core'
18
+ export type { CompiledGate } from '@fabrika/auth-core'
19
+
20
+ // ── Credential extraction ──────────────────────────────────────────────────────
21
+
22
+ /** The raw `service`-rule credential: the declared location, else `Authorization: Bearer`. Null if absent. */
23
+ export function readServiceCredential(headers: Headers, url: URL, location: CredentialLocation | undefined): string | null {
24
+ if (location !== undefined) {
25
+ const raw = extractCredential(headers, url, location)
26
+ return raw === null || raw === '' ? null : raw
27
+ }
28
+ return readBearer(headers.get('Authorization'))
29
+ }
30
+
31
+ /** Pull the raw credential from a declared location. A header value may be bare or `Bearer <token>`. */
32
+ function extractCredential(headers: Headers, url: URL, source: CredentialLocation): string | null {
33
+ if (source.in === 'header') {
34
+ const value = headers.get(source.name)
35
+ return value === null ? null : (readBearer(value) ?? value.trim())
36
+ }
37
+ if (source.in === 'query') {
38
+ return url.searchParams.get(source.name)
39
+ }
40
+ return readCookie(headers.get('Cookie'), source.name)
41
+ }
42
+
43
+ /** Read the token out of an `Authorization: Bearer <token>` header. Null when absent/non-bearer. */
44
+ export function readBearer(header: string | null): string | null {
45
+ if (header === null) {
46
+ return null
47
+ }
48
+ const match = /^Bearer\s+(.+)$/i.exec(header.trim())
49
+ return match ? (match[1]?.trim() ?? null) : null
50
+ }
51
+
52
+ /** Read a single cookie value out of a raw `Cookie` header. Null when absent or malformed. */
53
+ export function readCookie(header: string | null, name: string): string | null {
54
+ if (!header) {
55
+ return null
56
+ }
57
+ for (const part of header.split(';')) {
58
+ const eq = part.indexOf('=')
59
+ if (eq !== -1 && part.slice(0, eq).trim() === name) {
60
+ const value = part.slice(eq + 1).trim()
61
+ return value === '' ? null : value
62
+ }
63
+ }
64
+ return null
65
+ }
package/src/handoff.ts ADDED
@@ -0,0 +1,61 @@
1
+ import { AUTH_HANDOFF_CHALLENGE_PARAM, AUTH_HANDOFF_STATE_PARAM, FABRIKA_COOKIE_PREFIX, HANDOFF_COOKIE_PREFIX } from '@fabrika/auth-core'
2
+
3
+ const HANDOFF_VALUE_PATTERN = /^[A-Za-z0-9_-]{16,128}$/
4
+
5
+ export interface HandoffAttempt {
6
+ state: string
7
+ verifier: string
8
+ challenge: string
9
+ }
10
+
11
+ /** Create the browser-held verifier and the public values IAM carries through the login. */
12
+ export async function createHandoffAttempt(): Promise<HandoffAttempt> {
13
+ const state = randomToken(16)
14
+ const verifier = randomToken(32)
15
+ return { state, verifier, challenge: await handoffChallenge(verifier) }
16
+ }
17
+
18
+ /** S256, matching PKCE: the verifier never rides in a URL or reaches IAM through the browser. */
19
+ export async function handoffChallenge(verifier: string): Promise<string> {
20
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
21
+ return base64url(new Uint8Array(digest))
22
+ }
23
+
24
+ /** Return the dynamic `__Host-` cookie name for a validated public state. */
25
+ export function handoffCookieName(state: string | null): string | null {
26
+ return state !== null && HANDOFF_VALUE_PATTERN.test(state) ? `${HANDOFF_COOKIE_PREFIX}${state}` : null
27
+ }
28
+
29
+ /** Add the public handoff coordinates to IAM's login URL. */
30
+ export function setHandoffParams(url: URL, attempt: HandoffAttempt): void {
31
+ url.searchParams.set(AUTH_HANDOFF_STATE_PARAM, attempt.state)
32
+ url.searchParams.set(AUTH_HANDOFF_CHALLENGE_PARAM, attempt.challenge)
33
+ }
34
+
35
+ /** Keep application cookies, but never expose a Fabrika bearer credential to the upstream app. */
36
+ export function stripFabrikaCookies(headers: Headers): void {
37
+ const raw = headers.get('Cookie')
38
+ if (raw === null) return
39
+ const kept = raw.split(';').map((part) => part.trim()).filter((part) => {
40
+ const separator = part.indexOf('=')
41
+ const name = separator === -1 ? part : part.slice(0, separator).trim()
42
+ return !name.startsWith(FABRIKA_COOKIE_PREFIX)
43
+ })
44
+ if (kept.length === 0) {
45
+ headers.delete('Cookie')
46
+ } else {
47
+ headers.set('Cookie', kept.join('; '))
48
+ }
49
+ }
50
+
51
+ function randomToken(bytes: number): string {
52
+ const value = new Uint8Array(bytes)
53
+ crypto.getRandomValues(value)
54
+ return base64url(value)
55
+ }
56
+
57
+ function base64url(bytes: Uint8Array): string {
58
+ let binary = ''
59
+ for (const byte of bytes) binary += String.fromCharCode(byte)
60
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '')
61
+ }