@fabrika/proxy-core 0.0.26 → 0.3.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 +11 -4
- package/src/authorize.ts +11 -17
- package/src/cache.ts +0 -0
- package/src/constants.ts +3 -8
- package/src/gates.ts +1 -1
- package/src/iam.ts +30 -77
- package/src/index.ts +1 -1
- package/src/manifest.ts +97 -0
- package/src/service.ts +4 -3
- package/src/verifier.ts +20 -87
- package/src/json.ts +0 -44
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/proxy-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -19,6 +19,11 @@
|
|
|
19
19
|
"bun": "./src/index.ts",
|
|
20
20
|
"types": "./src/index.ts",
|
|
21
21
|
"default": "./src/index.ts"
|
|
22
|
+
},
|
|
23
|
+
"./manifest": {
|
|
24
|
+
"bun": "./src/manifest.ts",
|
|
25
|
+
"types": "./src/manifest.ts",
|
|
26
|
+
"default": "./src/manifest.ts"
|
|
22
27
|
}
|
|
23
28
|
},
|
|
24
29
|
"scripts": {
|
|
@@ -26,8 +31,10 @@
|
|
|
26
31
|
"test": "bun test"
|
|
27
32
|
},
|
|
28
33
|
"dependencies": {
|
|
29
|
-
"
|
|
30
|
-
"@fabrika/
|
|
31
|
-
"
|
|
34
|
+
"zod": "^3.25.76",
|
|
35
|
+
"@fabrika/auth-core": "^0.3.1",
|
|
36
|
+
"@fabrika/jwks": "^0.3.1",
|
|
37
|
+
"jose": "^5.9.0",
|
|
38
|
+
"@fabrika/platform": "^0.3.1"
|
|
32
39
|
}
|
|
33
40
|
}
|
package/src/authorize.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The proxy authorization core ([ADR-
|
|
2
|
+
* The proxy authorization core ([ADR-0022](../../../docs/decisions/0022-the-proxy-is-the-only-enforcement-point.md)).
|
|
3
3
|
*
|
|
4
4
|
* Same four steps, same order, same fail-closed posture:
|
|
5
5
|
* 1. match the request path against the app's ordered gate rules;
|
|
6
|
-
* 2. resolve the credential the matched rule requires (cookie session
|
|
6
|
+
* 2. resolve the credential the matched rule requires (cookie session or `px_` key);
|
|
7
7
|
* 3. exchange it with IAM once, cache the result, and thereafter verify LOCALLY against the JWKS;
|
|
8
8
|
* 4. deny unless a rule was satisfied.
|
|
9
9
|
*
|
|
@@ -17,12 +17,12 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
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
20
|
import { cacheKey, type TokenCache } from './cache'
|
|
22
21
|
import { LOGIN_REDIRECT_PARAM } from './constants'
|
|
23
22
|
import { applicableGates, type CompiledGate, readCookie, readServiceCredential } from './gates'
|
|
24
23
|
import { createHandoffAttempt, type HandoffAttempt, handoffCookieName, setHandoffParams } from './handoff'
|
|
25
24
|
import { type IamGateway, IamUnavailableError } from './iam'
|
|
25
|
+
import type { ProxyApp } from './manifest'
|
|
26
26
|
import { TokenVerifier } from './verifier'
|
|
27
27
|
|
|
28
28
|
/** Every way a request can be refused. Coarse on purpose — these are logged, not returned to clients. */
|
|
@@ -31,7 +31,7 @@ export type DenyReason =
|
|
|
31
31
|
| 'no_rule'
|
|
32
32
|
/** Every matching rule was a `service` rule and no credential was presented. */
|
|
33
33
|
| 'no_credential'
|
|
34
|
-
/** A
|
|
34
|
+
/** A service credential was presented and is not a valid `px_` key. */
|
|
35
35
|
| 'invalid_key'
|
|
36
36
|
| 'no_session'
|
|
37
37
|
| 'invalid_session'
|
|
@@ -63,7 +63,7 @@ export type Decision =
|
|
|
63
63
|
*/
|
|
64
64
|
| { outcome: 'login'; status: 302 | 401; location: string; reason: DenyReason; handoff: HandoffAttempt }
|
|
65
65
|
/**
|
|
66
|
-
* A handoff code was redeemed (ADR-
|
|
66
|
+
* A handoff code was redeemed (ADR-0022). The only outcome that writes the long-lived app
|
|
67
67
|
* session: `session` is host-only on this app's host, and `location` is where IAM said the browser
|
|
68
68
|
* was going — read from the code server-side, never from the request.
|
|
69
69
|
*/
|
|
@@ -189,19 +189,13 @@ export class Authorizer {
|
|
|
189
189
|
}
|
|
190
190
|
|
|
191
191
|
/**
|
|
192
|
-
* A `service`-rule bearer.
|
|
193
|
-
* path is a local verify)
|
|
194
|
-
*
|
|
192
|
+
* A `service`-rule bearer. Only a `px_` opaque key satisfies it: it is exchanged via `mintFromKey`
|
|
193
|
+
* (cached, so the hot path is a local verify). Anything else is refused with no IAM call. A
|
|
194
|
+
* present-but-invalid credential fails closed — no fall-through.
|
|
195
195
|
*/
|
|
196
196
|
private async authorizeBearer(app: ProxyApp, bearer: string, requestId: string): Promise<Decision> {
|
|
197
197
|
if (!bearer.startsWith(API_KEY_PREFIX)) {
|
|
198
|
-
|
|
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 }
|
|
198
|
+
return { outcome: 'deny', status: 401, reason: 'invalid_key' }
|
|
205
199
|
}
|
|
206
200
|
|
|
207
201
|
const key = cacheKey(app.id, 'key', bearer)
|
|
@@ -305,7 +299,7 @@ export class Authorizer {
|
|
|
305
299
|
}
|
|
306
300
|
|
|
307
301
|
/**
|
|
308
|
-
* Redeem a handoff code (ADR-
|
|
302
|
+
* Redeem a handoff code (ADR-0022). Called for the reserved callback path only, BEFORE gate
|
|
309
303
|
* matching — the callback is how a browser becomes able to satisfy a gate, so it cannot be behind
|
|
310
304
|
* one.
|
|
311
305
|
*
|
|
@@ -387,7 +381,7 @@ export class Authorizer {
|
|
|
387
381
|
* A redirect is not a neutral way to say "sign in". A cross-origin bounce to IAM is something a
|
|
388
382
|
* page's `fetch` cannot follow, so the console sees an opaque network failure instead of a sign-in;
|
|
389
383
|
* and a 302 destroys the request it answers, turning an in-flight POST into a bodyless GET — the same
|
|
390
|
-
* argument ADR-
|
|
384
|
+
* argument ADR-0022 makes for handing over a session rather than a bare token.
|
|
391
385
|
*
|
|
392
386
|
* **`Sec-Fetch-Mode` is the signal, not `Accept`.** It is written by the browser and not by the page:
|
|
393
387
|
* `Sec-` is a forbidden header PREFIX, so `fetch`/XHR cannot set it, and only a document or frame
|
package/src/cache.ts
CHANGED
|
Binary file
|
package/src/constants.ts
CHANGED
|
@@ -30,19 +30,14 @@ export const DEFAULT_HEALTH_PATH = '/healthz'
|
|
|
30
30
|
export const APP_QUERY_PARAM = 'app'
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
* Query parameter IAM's `/auth/login` takes the return URL in (ADR-
|
|
33
|
+
* Query parameter IAM's `/auth/login` takes the return URL in (ADR-0022). The proxy writes it on the
|
|
34
34
|
* login bounce and redacts it out of the access log, where it would re-emit — percent-encoded — a
|
|
35
35
|
* credential that was just stripped from the logged URI.
|
|
36
36
|
*/
|
|
37
37
|
export const LOGIN_REDIRECT_PARAM = 'redirect'
|
|
38
38
|
|
|
39
|
-
/**
|
|
40
|
-
export
|
|
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
|
|
39
|
+
/** The JWKS cache TTL and the unknown-`kid` refetch cooldown, owned by the verifier both planes share. */
|
|
40
|
+
export { JWKS_REFETCH_COOLDOWN_SECONDS, JWKS_TTL_SECONDS } from '@fabrika/jwks'
|
|
46
41
|
|
|
47
42
|
/** Default IAM request timeout. A hung IAM must deny, not hang every request behind it. */
|
|
48
43
|
export const DEFAULT_IAM_TIMEOUT_MS = 5_000
|
package/src/gates.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Credential extraction for the gate evaluator relocated out of the app's process
|
|
3
|
-
* ([ADR-
|
|
3
|
+
* ([ADR-0022](../../../docs/decisions/0022-the-proxy-is-the-only-enforcement-point.md)).
|
|
4
4
|
*
|
|
5
5
|
* The semantics are `AppGates`', verbatim (see `@fabrika/auth-core`'s `types.ts`):
|
|
6
6
|
* - array order IS the precedence;
|
package/src/iam.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* `IamGateway` is a STRUCTURAL SUBSET of `IamRpc`, so on Cloudflare the thin Worker passes its
|
|
5
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-
|
|
6
|
+
* `HttpIamGateway` speaks HTTP to the global IAM service — the public hop ADR-0022 calls cold-path
|
|
7
7
|
* only: everything after the first exchange is a local JWKS verification against the cached token.
|
|
8
8
|
*
|
|
9
9
|
* FAIL-CLOSED CONTRACT: an unreachable, slow or incoherent IAM raises `IamUnavailableError`. A
|
|
@@ -19,15 +19,25 @@ import type {
|
|
|
19
19
|
MintFromKeyResult,
|
|
20
20
|
MintTokenInput,
|
|
21
21
|
MintTokenResult,
|
|
22
|
-
PublicJwk,
|
|
23
22
|
} from '@fabrika/auth-core'
|
|
23
|
+
import { parseJwks } from '@fabrika/jwks'
|
|
24
|
+
import { z } from 'zod'
|
|
24
25
|
import { DEFAULT_IAM_TIMEOUT_MS } from './constants'
|
|
25
|
-
|
|
26
|
+
|
|
27
|
+
const denialSchema = z.object({ ok: z.literal(false), reason: z.string() })
|
|
28
|
+
const mintResultSchema = z.discriminatedUnion('ok', [
|
|
29
|
+
z.object({ ok: z.literal(true), token: z.string().min(1), expiresAt: z.number().finite() }),
|
|
30
|
+
denialSchema,
|
|
31
|
+
])
|
|
32
|
+
const exchangeResultSchema = z.discriminatedUnion('ok', [
|
|
33
|
+
z.object({ ok: z.literal(true), session: z.string().min(1), returnUrl: z.string().min(1), expiresAt: z.number().finite() }),
|
|
34
|
+
denialSchema,
|
|
35
|
+
])
|
|
26
36
|
|
|
27
37
|
/**
|
|
28
38
|
* What the proxy needs of IAM. IAM's entrypoints satisfy it structurally — the CF binding drops
|
|
29
39
|
* straight in with no adapter. Three of the four calls are `IamRpc`; `exchangeAuthCode` is
|
|
30
|
-
* deliberately NOT (`IamHandoffRpc`, ADR-
|
|
40
|
+
* deliberately NOT (`IamHandoffRpc`, ADR-0022), because only the proxy may turn a handoff code into
|
|
31
41
|
* a session and `IamRpc` is held by every SDK consumer.
|
|
32
42
|
*/
|
|
33
43
|
export interface IamGateway {
|
|
@@ -78,11 +88,11 @@ export type FetchLike = (input: string, init: RequestInit) => Promise<Response>
|
|
|
78
88
|
*
|
|
79
89
|
* POST {origin}/auth/mint/session {app, session, requestId} -> MintTokenResult
|
|
80
90
|
* POST {origin}/auth/mint/key {app, key, requestId} -> MintFromKeyResult
|
|
81
|
-
* POST {origin}/auth/mint/exchange {app, code, requestId} -> ExchangeAuthCodeResult (ADR-
|
|
91
|
+
* POST {origin}/auth/mint/exchange {app, code, requestId} -> ExchangeAuthCodeResult (ADR-0022)
|
|
82
92
|
* GET {origin}/.well-known/jwks.json -> Jwks (public)
|
|
83
93
|
*
|
|
84
94
|
* The three POST endpoints are the `/auth/mint/*` PROXY surface, gated by IAM's `proxyKey` — the
|
|
85
|
-
* least-privilege split ADR-
|
|
95
|
+
* least-privilege split ADR-0022 requires, kept apart from the management `/rpc/*` surface. They
|
|
86
96
|
* answer **200 on any decided outcome, including a denial**: `{ ok: false, reason }` is "IAM answered:
|
|
87
97
|
* no", a non-200 is "IAM could not be consulted". Both deny; only the second is an incident.
|
|
88
98
|
*
|
|
@@ -103,10 +113,11 @@ export class HttpIamGateway implements IamGateway {
|
|
|
103
113
|
}
|
|
104
114
|
|
|
105
115
|
async mintToken(input: MintTokenInput): Promise<MintTokenResult> {
|
|
106
|
-
const
|
|
107
|
-
if (
|
|
116
|
+
const result = mintResultSchema.safeParse(await this.post('/auth/mint/session', input, 'mintToken'))
|
|
117
|
+
if (!result.success) {
|
|
108
118
|
throw new IamUnavailableError('mintToken')
|
|
109
119
|
}
|
|
120
|
+
const parsed = result.data
|
|
110
121
|
if (parsed.ok) {
|
|
111
122
|
return parsed
|
|
112
123
|
}
|
|
@@ -114,10 +125,11 @@ export class HttpIamGateway implements IamGateway {
|
|
|
114
125
|
}
|
|
115
126
|
|
|
116
127
|
async mintFromKey(input: MintFromKeyInput): Promise<MintFromKeyResult> {
|
|
117
|
-
const
|
|
118
|
-
if (
|
|
128
|
+
const result = mintResultSchema.safeParse(await this.post('/auth/mint/key', input, 'mintFromKey'))
|
|
129
|
+
if (!result.success) {
|
|
119
130
|
throw new IamUnavailableError('mintFromKey')
|
|
120
131
|
}
|
|
132
|
+
const parsed = result.data
|
|
121
133
|
if (parsed.ok) {
|
|
122
134
|
return parsed
|
|
123
135
|
}
|
|
@@ -125,27 +137,18 @@ export class HttpIamGateway implements IamGateway {
|
|
|
125
137
|
}
|
|
126
138
|
|
|
127
139
|
async exchangeAuthCode(input: ExchangeAuthCodeInput): Promise<ExchangeAuthCodeResult> {
|
|
128
|
-
const
|
|
129
|
-
|
|
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) {
|
|
140
|
+
const result = exchangeResultSchema.safeParse(await this.post('/auth/mint/exchange', input, 'exchangeAuthCode'))
|
|
141
|
+
if (!result.success) {
|
|
144
142
|
throw new IamUnavailableError('exchangeAuthCode')
|
|
145
143
|
}
|
|
144
|
+
if (result.data.ok) return result.data
|
|
146
145
|
return {
|
|
147
146
|
ok: false,
|
|
148
|
-
reason: reasonOf(
|
|
147
|
+
reason: reasonOf(
|
|
148
|
+
result.data.reason,
|
|
149
|
+
['invalid_code', 'expired_code', 'wrong_app', 'invalid_verifier', 'unknown_principal', 'disabled'],
|
|
150
|
+
'invalid_code',
|
|
151
|
+
),
|
|
149
152
|
}
|
|
150
153
|
}
|
|
151
154
|
|
|
@@ -192,26 +195,6 @@ export class HttpIamGateway implements IamGateway {
|
|
|
192
195
|
}
|
|
193
196
|
}
|
|
194
197
|
|
|
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
198
|
/**
|
|
216
199
|
* Narrow a wire `reason` string to the declared union without a cast. An unrecognised reason degrades
|
|
217
200
|
* to the given fallback, which is always a DENY reason — an IAM that starts speaking a dialect we do
|
|
@@ -225,33 +208,3 @@ function reasonOf<T extends string>(value: string, allowed: readonly T[], fallba
|
|
|
225
208
|
}
|
|
226
209
|
return fallback
|
|
227
210
|
}
|
|
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
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
//
|
|
8
8
|
// The deployment that fronts it on Zerops — the Caddy configuration and the compiled binary — is
|
|
9
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
|
|
10
|
+
// contract and its strict parser are the `./manifest` subpath; the gate matcher and the token claims
|
|
11
11
|
// are `@fabrika/auth-core`.
|
|
12
12
|
|
|
13
13
|
export { Authorizer } from './authorize'
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { AppGates, CredentialLocation, GateRule } from '@fabrika/auth-core'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
|
|
4
|
+
export const FABRIKA_PROXY_MANIFEST_JSON = 'FABRIKA_PROXY_MANIFEST_JSON'
|
|
5
|
+
|
|
6
|
+
export interface ProxyApp {
|
|
7
|
+
/** The IAM app id, used as the minted token audience. */
|
|
8
|
+
id: string
|
|
9
|
+
/** Literal hostnames, lower-cased on parse; never patterns or host:port pairs. */
|
|
10
|
+
hosts: string[]
|
|
11
|
+
/** Private upstream dial address, for example `app:3000`. */
|
|
12
|
+
upstream: string
|
|
13
|
+
/** Ordered rules; evaluated only by auth-core in the proxy. */
|
|
14
|
+
gates: AppGates
|
|
15
|
+
/** Browser-facing scheme used for login and handoff URLs, defaulting to https. */
|
|
16
|
+
scheme: 'http' | 'https'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ProxyManifest {
|
|
20
|
+
apps: ProxyApp[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const HOST_LABEL = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/
|
|
24
|
+
|
|
25
|
+
/** Literal LDH hostnames only: Caddy reads wildcard labels and placeholders as routing patterns. */
|
|
26
|
+
export function isProxyHost(host: string): boolean {
|
|
27
|
+
return host.length > 0 && host.length <= 253 && host.split('.').every((label) => label.length <= 63 && HOST_LABEL.test(label))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const proxyCredentialLocationSchema = z.object({
|
|
31
|
+
in: z.enum(['header', 'query', 'cookie']),
|
|
32
|
+
name: z.string().min(1),
|
|
33
|
+
}) satisfies z.ZodType<CredentialLocation, z.ZodTypeDef, unknown>
|
|
34
|
+
|
|
35
|
+
const gatePathSchema = z.string().startsWith('/')
|
|
36
|
+
/** A gate rule's fields before normalization; a producer adds its own refinements on top. */
|
|
37
|
+
export const proxyGateRuleFieldsSchema = z.discriminatedUnion('kind', [
|
|
38
|
+
z.object({ path: gatePathSchema, kind: z.literal('public') }),
|
|
39
|
+
z.object({ path: gatePathSchema, kind: z.literal('human') }),
|
|
40
|
+
z.object({ path: gatePathSchema, kind: z.literal('service'), credential: proxyCredentialLocationSchema.nullish() }),
|
|
41
|
+
])
|
|
42
|
+
|
|
43
|
+
/** Drop an absent service credential, so a rule without one carries no `credential` key at all. */
|
|
44
|
+
export const normalizeGateRule = (rule: z.infer<typeof proxyGateRuleFieldsSchema>): GateRule => {
|
|
45
|
+
if (rule.kind !== 'service') return rule
|
|
46
|
+
return { path: rule.path, kind: rule.kind, ...(rule.credential == null ? {} : { credential: rule.credential }) }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const proxyGateRuleSchema = proxyGateRuleFieldsSchema.transform(normalizeGateRule) satisfies z.ZodType<GateRule, z.ZodTypeDef, unknown>
|
|
50
|
+
|
|
51
|
+
export const proxyAppGatesSchema = z.object({
|
|
52
|
+
rules: z.array(proxyGateRuleSchema),
|
|
53
|
+
}) satisfies z.ZodType<AppGates, z.ZodTypeDef, unknown>
|
|
54
|
+
|
|
55
|
+
export const proxyAppSchema = z.object({
|
|
56
|
+
id: z.string().min(1),
|
|
57
|
+
hosts: z.array(z.string().toLowerCase().refine(isProxyHost)).min(1),
|
|
58
|
+
upstream: z.string().min(1),
|
|
59
|
+
gates: proxyAppGatesSchema,
|
|
60
|
+
scheme: z.preprocess((value) => typeof value === 'string' ? value : undefined, z.enum(['http', 'https']).default('https')),
|
|
61
|
+
}) satisfies z.ZodType<ProxyApp, z.ZodTypeDef, unknown>
|
|
62
|
+
|
|
63
|
+
/** The first host two apps (or one app twice) claim, or null. A host belongs to exactly one app. */
|
|
64
|
+
export function findDuplicateProxyHost(apps: readonly Pick<ProxyApp, 'hosts'>[]): string | null {
|
|
65
|
+
const hosts = new Set<string>()
|
|
66
|
+
for (const host of apps.flatMap((app) => app.hosts)) {
|
|
67
|
+
if (hosts.has(host)) return host
|
|
68
|
+
hosts.add(host)
|
|
69
|
+
}
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const proxyManifestSchema = z.object({
|
|
74
|
+
apps: z.array(proxyAppSchema),
|
|
75
|
+
}).superRefine(({ apps }, ctx) => {
|
|
76
|
+
if (new Set(apps.map((app) => app.id)).size !== apps.length) ctx.addIssue({ code: 'custom', message: 'duplicate proxy app id' })
|
|
77
|
+
if (findDuplicateProxyHost(apps) !== null) ctx.addIssue({ code: 'custom', message: 'duplicate proxy host' })
|
|
78
|
+
}) satisfies z.ZodType<ProxyManifest, z.ZodTypeDef, unknown>
|
|
79
|
+
|
|
80
|
+
export function parseProxyManifest(value: unknown): ProxyManifest | null {
|
|
81
|
+
const result = proxyManifestSchema.safeParse(value)
|
|
82
|
+
return result.success ? result.data : null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function parseProxyManifestJson(value: string): ProxyManifest | null {
|
|
86
|
+
let parsed: unknown
|
|
87
|
+
try {
|
|
88
|
+
parsed = JSON.parse(value)
|
|
89
|
+
} catch {
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
return parseProxyManifest(parsed)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function encodeProxyManifestJson(manifest: ProxyManifest): string {
|
|
96
|
+
return JSON.stringify(manifest)
|
|
97
|
+
}
|
package/src/service.ts
CHANGED
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
* back on a 2xx, so the edge is the only thing that mints either.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { AUTH_CALLBACK_PATH, HANDOFF_COOKIE_TTL_SECONDS, SESSION_COOKIE
|
|
25
|
-
import
|
|
24
|
+
import { AUTH_CALLBACK_PATH, HANDOFF_COOKIE_TTL_SECONDS, SESSION_COOKIE } from '@fabrika/auth-core'
|
|
25
|
+
import { uuidv7 } from '@fabrika/platform'
|
|
26
26
|
import { Authorizer, type AuthorizerOptions, type Decision, type DenyReason, type ForwardedRequest, type ResolvedApp } from './authorize'
|
|
27
27
|
import type { TokenCache } from './cache'
|
|
28
28
|
import {
|
|
@@ -40,6 +40,7 @@ import { compileGates } from './gates'
|
|
|
40
40
|
import { type HandoffAttempt, handoffCookieName } from './handoff'
|
|
41
41
|
import type { IamGateway } from './iam'
|
|
42
42
|
import { type LogFields, type ProxyLogger, redactPath, silentLogger } from './log'
|
|
43
|
+
import type { ProxyManifest } from './manifest'
|
|
43
44
|
|
|
44
45
|
export interface VerifyServiceConfig {
|
|
45
46
|
manifest: ProxyManifest
|
|
@@ -347,7 +348,7 @@ function readForwardedRequest(request: Request, requestId: string): ForwardedReq
|
|
|
347
348
|
* "default app".
|
|
348
349
|
*
|
|
349
350
|
* A PINNED app is still cross-checked against the forwarded host. `X-Forwarded-Host` is
|
|
350
|
-
* client-controllable once `trusted_proxies` is set (ADR-
|
|
351
|
+
* client-controllable once `trusted_proxies` is set (ADR-0022), and the pin is what stops it choosing
|
|
351
352
|
* the app — but the same header then builds the login bounce and the handoff callback, so a request
|
|
352
353
|
* pinned to app A claiming app B's host would send the browser somewhere the manifest never declared.
|
|
353
354
|
* The authoritative host list is right here; use it.
|
package/src/verifier.ts
CHANGED
|
@@ -2,104 +2,37 @@
|
|
|
2
2
|
* Local token verification — the warm path. Fetch the JWKS once, then verify every token with no
|
|
3
3
|
* network call on the warm path.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
|
-
import
|
|
10
|
-
import {
|
|
11
|
+
import type { Jwks } from '@fabrika/auth-core'
|
|
12
|
+
import { TokenVerifier as JwksVerifier, type VerifyResult } from '@fabrika/jwks'
|
|
11
13
|
import { JWKS_REFETCH_COOLDOWN_SECONDS, JWKS_TTL_SECONDS } from './constants'
|
|
12
|
-
import { IamUnavailableError } from './iam'
|
|
13
14
|
|
|
14
|
-
export type VerifyResult
|
|
15
|
-
| { ok: true; claims: AccessTokenClaims }
|
|
16
|
-
/** Signature, issuer, audience, expiry or claim-shape failure — the token is not trustworthy. */
|
|
17
|
-
| { ok: false; reason: 'invalid' }
|
|
18
|
-
/** The key set could not be obtained, so nothing can be verified. Deny, but say why. */
|
|
19
|
-
| { ok: false; reason: 'unavailable' }
|
|
20
|
-
|
|
21
|
-
/** Sentinel distinguishing "no signing key matched (kid rotation)" from a real verification failure. */
|
|
22
|
-
const NO_KEY = Symbol('no-matching-key')
|
|
15
|
+
export type { VerifyResult }
|
|
23
16
|
|
|
24
17
|
export class TokenVerifier {
|
|
25
|
-
private
|
|
26
|
-
/** When the last unknown-`kid` refetch was attempted. Null until one has been. */
|
|
27
|
-
private lastForcedAt: number | null = null
|
|
18
|
+
private readonly inner: JwksVerifier
|
|
28
19
|
|
|
29
20
|
constructor(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
) {
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Verify `token` for `audience` and narrow it to access claims. An unknown `kid` triggers one JWKS
|
|
39
|
-
* refetch (a key rotated in), then a retry — but at most once per cooldown, because otherwise any
|
|
40
|
-
* caller can turn every request into an IAM round trip just by inventing a `kid`.
|
|
41
|
-
*/
|
|
42
|
-
async verify(token: string, audience: string): Promise<VerifyResult> {
|
|
43
|
-
let payload: unknown
|
|
44
|
-
try {
|
|
45
|
-
payload = await this.verifyWith(token, audience, false)
|
|
46
|
-
if (payload === NO_KEY && this.mayForceRefetch()) {
|
|
47
|
-
payload = await this.verifyWith(token, audience, true)
|
|
48
|
-
}
|
|
49
|
-
} catch (err) {
|
|
50
|
-
if (err instanceof IamUnavailableError) {
|
|
51
|
-
return { ok: false, reason: 'unavailable' }
|
|
52
|
-
}
|
|
53
|
-
throw err
|
|
54
|
-
}
|
|
55
|
-
if (payload === NO_KEY || payload === null) {
|
|
56
|
-
return { ok: false, reason: 'invalid' }
|
|
57
|
-
}
|
|
58
|
-
const claims = parseAccessClaims(payload)
|
|
59
|
-
return claims === null ? { ok: false, reason: 'invalid' } : { ok: true, claims }
|
|
21
|
+
getJwks: () => Promise<Jwks>,
|
|
22
|
+
issuer: string,
|
|
23
|
+
now: () => number,
|
|
24
|
+
ttlSeconds: number = JWKS_TTL_SECONDS,
|
|
25
|
+
refetchCooldownSeconds: number = JWKS_REFETCH_COOLDOWN_SECONDS,
|
|
26
|
+
) {
|
|
27
|
+
this.inner = new JwksVerifier({ getJwks, issuer, now, ttlSeconds, refetchCooldownSeconds })
|
|
60
28
|
}
|
|
61
29
|
|
|
62
30
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
31
|
+
* Verify `token` for `audience`. An unknown `kid` forces one JWKS refetch (a key rotated in), then a
|
|
32
|
+
* retry — but at most once per cooldown, because otherwise any caller can turn every request into an
|
|
33
|
+
* IAM round trip just by inventing a `kid`.
|
|
66
34
|
*/
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (this.lastForcedAt !== null && now - this.lastForcedAt < this.refetchCooldownSeconds) {
|
|
70
|
-
return false
|
|
71
|
-
}
|
|
72
|
-
this.lastForcedAt = now
|
|
73
|
-
return true
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** Returns the payload, `null` on a genuine verification failure, or `NO_KEY` on a kid miss. */
|
|
77
|
-
private async verifyWith(token: string, audience: string, force: boolean): Promise<unknown> {
|
|
78
|
-
const jwks = await this.jwks(force)
|
|
79
|
-
try {
|
|
80
|
-
const { payload } = await jwtVerify(token, jwks, { issuer: this.issuer, audience })
|
|
81
|
-
return payload
|
|
82
|
-
} catch (err) {
|
|
83
|
-
return err instanceof joseErrors.JWKSNoMatchingKey ? NO_KEY : null
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
private async jwks(force: boolean): Promise<ReturnType<typeof createLocalJWKSet>> {
|
|
88
|
-
const now = this.now()
|
|
89
|
-
if (!force && this.cached !== null && this.cached.expiresAt > now) {
|
|
90
|
-
return this.cached.verifier
|
|
91
|
-
}
|
|
92
|
-
let keys: Jwks
|
|
93
|
-
try {
|
|
94
|
-
keys = await this.getJwks()
|
|
95
|
-
} catch {
|
|
96
|
-
// Any JWKS failure is an IAM outage as far as we are concerned. Note that we do NOT fall
|
|
97
|
-
// back to a stale verifier here: the previous key set may have been rotated out precisely
|
|
98
|
-
// because it was compromised.
|
|
99
|
-
throw new IamUnavailableError('getJwks')
|
|
100
|
-
}
|
|
101
|
-
const verifier = createLocalJWKSet(keys)
|
|
102
|
-
this.cached = { verifier, expiresAt: now + this.ttlSeconds }
|
|
103
|
-
return verifier
|
|
35
|
+
verify(token: string, audience: string): Promise<VerifyResult> {
|
|
36
|
+
return this.inner.verify(token, audience)
|
|
104
37
|
}
|
|
105
38
|
}
|
package/src/json.ts
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
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
|
-
}
|