@fabrika/proxy-core 0.3.3 → 0.4.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/proxy-core",
3
- "version": "0.3.3",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -24,6 +24,11 @@
24
24
  "bun": "./src/manifest.ts",
25
25
  "types": "./src/manifest.ts",
26
26
  "default": "./src/manifest.ts"
27
+ },
28
+ "./headers": {
29
+ "bun": "./src/headers.ts",
30
+ "types": "./src/headers.ts",
31
+ "default": "./src/headers.ts"
27
32
  }
28
33
  },
29
34
  "scripts": {
@@ -32,9 +37,9 @@
32
37
  },
33
38
  "dependencies": {
34
39
  "zod": "^3.25.76",
35
- "@fabrika/auth-core": "^0.3.3",
36
- "@fabrika/jwks": "^0.3.3",
40
+ "@fabrika/auth-core": "^0.4.1",
41
+ "@fabrika/jwks": "^0.4.1",
37
42
  "jose": "^5.9.0",
38
- "@fabrika/platform": "^0.3.3"
43
+ "@fabrika/platform": "^0.4.1"
39
44
  }
40
45
  }
package/src/authorize.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * The proxy authorization core ([ADR-0022](../../../docs/decisions/0022-the-proxy-is-the-only-enforcement-point.md)).
3
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 or `px_` key);
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.
4
+ * Every entry — a contract application or an installation service — is protected by its endpoint table, in one pass
5
+ * over ONE selected endpoint: `planEndpoint` → a `px_` key whose caller kind the endpoint admits, or else the session
6
+ * when `human` may call → the requirement (`satisfies`) → admission → the delegation grant. There is no fall-through
7
+ * to another endpoint. On an installation row that admits `oauth`, a bearer that is not a `px_` key is an OAuth
8
+ * resource token, verified locally. A key or a session is exchanged with IAM once, cached, and thereafter verified
9
+ * LOCALLY against the JWKS.
9
10
  *
10
11
  * A human miss becomes a bounce to IAM's `/auth/login`, in whichever shape the caller can act on: a
11
12
  * 302 for a document navigation, a 401 carrying the same URL in a JSON body for everything else (see
@@ -16,31 +17,66 @@
16
17
  * NOTHING here returns an allow on an unexpected condition. Every `catch` maps to a deny.
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 {
21
+ type AccessTokenClaims,
22
+ API_KEY_PREFIX,
23
+ AUTH_CODE_PARAM,
24
+ AUTH_HANDOFF_STATE_PARAM,
25
+ callerAdmittedAt,
26
+ callerOf,
27
+ type CompiledEndpointTable,
28
+ formatEndpointHeader,
29
+ planEndpoint,
30
+ satisfies,
31
+ SESSION_COOKIE,
32
+ TOKEN_REFRESH_SKEW_SECONDS,
33
+ type TokenCaller,
34
+ } from '@fabrika/auth-core'
20
35
  import { cacheKey, type TokenCache } from './cache'
21
36
  import { LOGIN_REDIRECT_PARAM } from './constants'
22
- import { applicableGates, type CompiledGate, readCookie, readServiceCredential } from './gates'
37
+ import { readBearer, readCookie } from './credentials'
23
38
  import { createHandoffAttempt, type HandoffAttempt, handoffCookieName, setHandoffParams } from './handoff'
24
- import { type IamGateway, IamUnavailableError } from './iam'
25
- import type { ProxyApp } from './manifest'
39
+ import { type DelegationIssuer, type IamGateway, IamUnavailableError } from './iam'
40
+ import { type ManifestEndpointRow, type OAuthBearerChallenge, type OAuthProtectedResource, oauthResourceOfRow, type ProxyApp } from './manifest'
26
41
  import { TokenVerifier } from './verifier'
27
42
 
28
43
  /** Every way a request can be refused. Coarse on purpose — these are logged, not returned to clients. */
29
44
  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. */
45
+ /** The endpoint admits no person, and no credential was presented. */
33
46
  | 'no_credential'
34
- /** A service credential was presented and is not a valid `px_` key. */
47
+ /** A bearer was presented and is not a valid `px_` key. */
35
48
  | 'invalid_key'
49
+ /** An `oauth` row's bearer is malformed, unverifiable, or minted for another resource or app. */
50
+ | 'invalid_token'
51
+ /** An `oauth` row's bearer verified but lacks a required scope. */
52
+ | 'insufficient_scope'
36
53
  | 'no_session'
37
54
  | 'invalid_session'
38
55
  | 'unknown_principal'
39
56
  | 'disabled'
57
+ /** IAM refused to sign a token above its size bound: the principal holds more than fits in a header. */
58
+ | 'token_too_large'
59
+ /** The entry's table has no endpoint for the method and path. The fail-closed default; no fall-through. */
60
+ | 'no_endpoint'
61
+ /**
62
+ * The credential's caller kind is not one of the endpoint's callers, is no caller kind at all (a job or a webhook
63
+ * key), or is a connector caller off an export path.
64
+ */
65
+ | 'caller_kind'
66
+ /** The token does not satisfy the endpoint's requirement. */
67
+ | 'insufficient_permission'
68
+ /** A session request that is not a `GET` did not come from the app's own origin. */
69
+ | 'cross_site'
70
+ /** The request's `Content-Type` is not one the endpoint admits. */
71
+ | 'content_type'
72
+ /** IAM refused the delegation grant a membership or scope-creating endpoint needs. */
73
+ | 'delegation_refused'
40
74
  /** The `Host` maps to no app in the manifest. */
41
75
  | 'no_app'
42
76
  /** The forwarded-request headers are missing or malformed — we cannot know what was asked for. */
43
77
  | 'bad_forward'
78
+ /** The forwarded path is not canonical (`isCanonicalPath`); refused before any endpoint is planned. */
79
+ | 'non_canonical_path'
44
80
  /** IAM could not be consulted, or its keys could not be fetched. We do not know; we refuse. */
45
81
  | 'iam_unavailable'
46
82
  /** Anything that escaped. Always a deny. */
@@ -49,15 +85,25 @@ export type DenyReason =
49
85
  export type Decision =
50
86
  | {
51
87
  outcome: 'allow'
52
- gate: 'public' | 'service' | 'human'
53
- /** The verified token to inject upstream. `null` on a `public` gate — no credential, no token. */
88
+ /** The verified token to inject upstream. `null` on an anonymous endpoint — no credential, no token. */
54
89
  token: string | null
55
90
  /** Principal id (or credential id) for the log line. Never a secret. */
56
91
  subject: string | null
92
+ /** `<id>@<digest>` of the selected endpoint, for `X-Fabrika-Endpoint`. */
93
+ endpoint: string
94
+ /** The single-use delegation grant for `X-Fabrika-Delegation`, when the endpoint spends one. */
95
+ delegation: string | null
96
+ }
97
+ | {
98
+ outcome: 'deny'
99
+ status: 400 | 401 | 403 | 503
100
+ reason: DenyReason
101
+ clearHandoffState?: string
102
+ /** Set on an `oauth` row's 401/403: rendered as the RFC 6750 `WWW-Authenticate` challenge. */
103
+ challenge?: OAuthBearerChallenge
57
104
  }
58
- | { outcome: 'deny'; status: 401 | 403 | 503; reason: DenyReason; clearHandoffState?: string }
59
105
  /**
60
- * A human hit a `human` gate without a usable session. `location` is where they must go to sign
106
+ * A human reached an endpoint `human` may call without a usable session. `location` is where they must go to sign
61
107
  * in; `status` is only how they are TOLD — 302 for a document navigation, 401 for anything else,
62
108
  * whose body carries the same URL. See `isDocumentNavigation` for why the two differ.
63
109
  */
@@ -71,19 +117,21 @@ export type Decision =
71
117
 
72
118
  /** The original request, reconstructed from what `forward_auth` forwarded. */
73
119
  export interface ForwardedRequest {
74
- /** Original method. Informational — gates are path-only — but logged. */
120
+ /** Original method; the endpoint table selects by it. */
75
121
  method: string
76
- /** Original absolute URL. Its `pathname` drives gate matching; the whole URL is the login return. */
122
+ /** Original absolute URL; the whole URL is the login return. */
77
123
  url: URL
124
+ /** The forwarded path exactly as the upstream receives it, already checked by `isCanonicalPath`. */
125
+ rawPath: string
78
126
  /** The original request's headers, forwarded verbatim by Caddy. */
79
127
  headers: Headers
80
128
  requestId: string
81
129
  }
82
130
 
83
- /** An app plus its precompiled gates, built once at startup. */
131
+ /** An entry plus its precompiled endpoint table, built once at startup. */
84
132
  export interface ResolvedApp {
85
- app: ProxyApp
86
- gates: CompiledGate[]
133
+ readonly app: ProxyApp
134
+ readonly table: CompiledEndpointTable<ManifestEndpointRow>
87
135
  }
88
136
 
89
137
  export interface AuthorizerOptions {
@@ -94,6 +142,11 @@ export interface AuthorizerOptions {
94
142
  cache?: TokenCache | null
95
143
  /** Unix seconds. Injectable for tests. */
96
144
  now?: () => number
145
+ /**
146
+ * Issues the single-use grant a membership or scope-creating endpoint spends at IAM. Unset, such a request is
147
+ * forwarded without one and IAM refuses the membership operation unless the framework obtained a grant itself.
148
+ */
149
+ delegations?: DelegationIssuer | null
97
150
  }
98
151
 
99
152
  /**
@@ -103,9 +156,31 @@ export interface AuthorizerOptions {
103
156
  */
104
157
  const FETCH_MODE_HEADER = 'Sec-Fetch-Mode'
105
158
 
106
- /** Why a `human` attempt failed — mirrors `mintToken`'s reasons. */
159
+ /** Fetch metadata the browser writes and page script cannot: `same-origin` only for a request from this origin. */
160
+ const FETCH_SITE_HEADER = 'Sec-Fetch-Site'
161
+
162
+ /** Why a session attempt failed — mirrors `mintToken`'s reasons. */
107
163
  type SessionFailureReason = 'no_session' | 'invalid_session' | 'unknown_principal' | 'disabled'
108
164
 
165
+ type Denial = Extract<Decision, { outcome: 'deny' }>
166
+
167
+ /** A `px_` key exchanged and verified, or the denial that ends the request. */
168
+ type KeyAttempt = { ok: true; token: string; claims: AccessTokenClaims } | { ok: false; denial: Denial }
169
+
170
+ /** An OAuth resource token verified for a resource and its scopes, or the challenge that ends the request. */
171
+ type OAuthAttempt = { ok: true; token: string; claims: AccessTokenClaims } | { ok: false; denial: Denial }
172
+
173
+ /** The resource a row that admits `oauth` is bound to, and the scopes the row requires. */
174
+ interface OAuthBinding {
175
+ readonly resource: OAuthProtectedResource
176
+ readonly scopes: readonly string[]
177
+ }
178
+
179
+ /** Who is behind a request to a credentialed endpoint, or the decision that ends it (a denial or the login bounce). */
180
+ type EndpointCaller =
181
+ | { ok: true; token: string; claims: AccessTokenClaims; session: boolean }
182
+ | { ok: false; decision: Extract<Decision, { outcome: 'deny' | 'login' }> }
183
+
109
184
  /**
110
185
  * The outcome of resolving the human behind a request. Three arms, not two: "there is no usable
111
186
  * session" bounces the browser to login, but "we could not check" must NOT — an unreachable IAM would
@@ -113,8 +188,9 @@ type SessionFailureReason = 'no_session' | 'invalid_session' | 'unknown_principa
113
188
  * incident.
114
189
  */
115
190
  type SessionAttempt =
116
- | { ok: true; token: string; subject: string }
191
+ | { ok: true; token: string; claims: AccessTokenClaims }
117
192
  | { ok: false; status: 'miss'; reason: SessionFailureReason }
193
+ | { ok: false; status: 'refused'; reason: 'token_too_large' }
118
194
  | { ok: false; status: 'unavailable' }
119
195
 
120
196
  export class Authorizer {
@@ -134,7 +210,7 @@ export class Authorizer {
134
210
  */
135
211
  async authorize(resolved: ResolvedApp, request: ForwardedRequest): Promise<Decision> {
136
212
  try {
137
- return await this.decide(resolved, request)
213
+ return await this.decideEndpoint(resolved.app, resolved.table, request)
138
214
  } catch (err) {
139
215
  if (err instanceof IamUnavailableError) {
140
216
  return { outcome: 'deny', status: 503, reason: 'iam_unavailable' }
@@ -143,59 +219,154 @@ export class Authorizer {
143
219
  }
144
220
  }
145
221
 
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' }
222
+ /**
223
+ * One pass over the endpoint `planEndpoint` selects (ADR-0022 rule 3). An anonymous kind reads no credential and
224
+ * mints no token. A credentialed one takes a `px_` key when one is presented — terminal, whatever its verdict —
225
+ * an OAuth resource token on a row that admits `oauth`, and the session only when `human` may call; then the
226
+ * requirement, then admission.
227
+ */
228
+ private async decideEndpoint(
229
+ app: ProxyApp,
230
+ table: CompiledEndpointTable<ManifestEndpointRow>,
231
+ request: ForwardedRequest,
232
+ ): Promise<Decision> {
233
+ const plan = planEndpoint(table, request.method, request.rawPath)
234
+ if (plan.outcome === 'non-canonical') {
235
+ return { outcome: 'deny', status: 400, reason: 'non_canonical_path' }
151
236
  }
237
+ if (plan.outcome === 'no-endpoint') {
238
+ return { outcome: 'deny', status: 403, reason: 'no_endpoint' }
239
+ }
240
+ const row = plan.endpoint
241
+ const endpoint = formatEndpointHeader(row.id, row.digest)
242
+ if (row.requirement === null) {
243
+ return admitBody(row, request.headers) ?? { outcome: 'allow', token: null, subject: null, endpoint, delegation: null }
244
+ }
245
+ const oauth = oauthBindingOf(app, row)
246
+ if (oauth === 'unregistered') {
247
+ // The manifest parser refuses an `oauth` row without its resource; reaching this is a composition bug.
248
+ return { outcome: 'deny', status: 403, reason: 'internal_error' }
249
+ }
250
+ const callers: readonly TokenCaller[] = row.callers
251
+ const caller = await this.endpointCaller(app, oauth, callers, request)
252
+ if (!caller.ok) {
253
+ return caller.decision
254
+ }
255
+ const kind = callerOf(caller.claims)
256
+ if (kind === null || !callers.includes(kind) || !callerAdmittedAt(kind, row.path)) {
257
+ return withBearerChallenge({ outcome: 'deny', status: 403, reason: 'caller_kind' }, oauth)
258
+ }
259
+ if (!satisfies(row.requirement, caller.claims.perms)) {
260
+ return { outcome: 'deny', status: 403, reason: 'insufficient_permission' }
261
+ }
262
+ if (caller.session && request.method !== 'GET' && request.headers.get(FETCH_SITE_HEADER) !== 'same-origin') {
263
+ return { outcome: 'deny', status: 403, reason: 'cross_site' }
264
+ }
265
+ const refused = admitBody(row, request.headers)
266
+ if (refused !== null) {
267
+ return refused
268
+ }
269
+ const delegation = row.delegation === null ? null : await this.obtainDelegation(app, row.id, caller.token, request.requestId)
270
+ if (delegation !== null && !delegation.ok) {
271
+ return delegation.denial
272
+ }
273
+ return {
274
+ outcome: 'allow',
275
+ token: caller.token,
276
+ subject: caller.claims.sub,
277
+ endpoint,
278
+ delegation: delegation?.grant ?? null,
279
+ }
280
+ }
152
281
 
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)
282
+ /**
283
+ * A `px_` key wherever the request carries an `Authorization` header, and on a row that admits `oauth` any other
284
+ * credential there as an OAuth resource token; otherwise the session, if `human` may call.
285
+ */
286
+ private async endpointCaller(
287
+ app: ProxyApp,
288
+ oauth: OAuthBinding | null,
289
+ callers: readonly TokenCaller[],
290
+ request: ForwardedRequest,
291
+ ): Promise<EndpointCaller> {
292
+ const authorization = request.headers.get('Authorization')
293
+ if (authorization !== null && authorization.trim() !== '') {
294
+ const bearer = readBearer(authorization)
295
+ if (oauth !== null && (bearer === null || !bearer.startsWith(API_KEY_PREFIX))) {
296
+ const verified = await this.verifyOAuthBearer(app, oauth, bearer)
297
+ return verified.ok ? { ok: true, token: verified.token, claims: verified.claims, session: false } : { ok: false, decision: verified.denial }
180
298
  }
181
- humanMiss = session.reason
299
+ const key = bearer === null ? { ok: false, denial: invalidKey() } satisfies KeyAttempt : await this.exchangeKey(app, bearer, request.requestId)
300
+ return key.ok
301
+ ? { ok: true, token: key.token, claims: key.claims, session: false }
302
+ : { ok: false, decision: withBearerChallenge(key.denial, oauth) }
303
+ }
304
+ if (!callers.includes('human')) {
305
+ // No session reaches an endpoint a person may not call, so there is nothing to sign in to.
306
+ const denial: Denial = { outcome: 'deny', status: 401, reason: 'no_credential' }
307
+ return { ok: false, decision: oauth === null ? denial : { ...denial, challenge: { resource_metadata: oauth.resource.resourceMetadata } } }
308
+ }
309
+ const session = await this.authorizeSession(app, request)
310
+ if (session.ok) {
311
+ return { ok: true, token: session.token, claims: session.claims, session: true }
182
312
  }
313
+ if (session.status === 'unavailable') {
314
+ return { ok: false, decision: { outcome: 'deny', status: 503, reason: 'iam_unavailable' } }
315
+ }
316
+ if (session.status === 'refused') {
317
+ return { ok: false, decision: { outcome: 'deny', status: 403, reason: session.reason } }
318
+ }
319
+ return { ok: false, decision: await this.loginDecision(app, request, session.reason) }
320
+ }
183
321
 
184
- if (humanMiss !== null) {
185
- return await this.loginDecision(resolved.app, request, humanMiss)
322
+ /** A fresh grant for every request, never cached: IAM spends it once, for this request id. */
323
+ private async obtainDelegation(
324
+ app: ProxyApp,
325
+ endpoint: string,
326
+ token: string,
327
+ requestId: string,
328
+ ): Promise<{ ok: true; grant: string | null } | { ok: false; denial: Denial }> {
329
+ const issuer = this.options.delegations
330
+ if (issuer === undefined || issuer === null) {
331
+ return { ok: true, grant: null }
186
332
  }
187
- // Every matching rule was a `service` rule with no credential present.
188
- return { outcome: 'deny', status: 401, reason: 'no_credential' }
333
+ const result = await this.callIam('requestDelegation', () => issuer.requestDelegation({ app: app.id, credential: token, requestId, endpoint }))
334
+ return result.ok && result.grant !== ''
335
+ ? { ok: true, grant: result.grant }
336
+ : { ok: false, denial: { outcome: 'deny', status: 403, reason: 'delegation_refused' } }
189
337
  }
190
338
 
191
339
  /**
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.
340
+ * An OAuth resource token for one registered resource of this app, carrying every scope required. Verified
341
+ * locally — no IAM call, no cache. RFC 6750 §3.1: a request without a bearer (none, or another scheme) gets a
342
+ * challenge with no error code.
195
343
  */
196
- private async authorizeBearer(app: ProxyApp, bearer: string, requestId: string): Promise<Decision> {
344
+ private async verifyOAuthBearer(app: ProxyApp, binding: OAuthBinding, bearer: string | null): Promise<OAuthAttempt> {
345
+ const challenge: OAuthBearerChallenge = { resource_metadata: binding.resource.resourceMetadata }
346
+ if (bearer === null) {
347
+ return { ok: false, denial: { outcome: 'deny', status: 401, reason: 'no_credential', challenge } }
348
+ }
349
+ const verified = await this.verifier.verify(bearer, { kind: 'resource', app: app.id, resource: binding.resource.resource })
350
+ if (!verified.ok) {
351
+ return verified.reason === 'unavailable'
352
+ ? { ok: false, denial: { outcome: 'deny', status: 503, reason: 'iam_unavailable' } }
353
+ : { ok: false, denial: { outcome: 'deny', status: 401, reason: 'invalid_token', challenge: { ...challenge, error: 'invalid_token' } } }
354
+ }
355
+ const granted = verified.claims.oauth?.scopes ?? []
356
+ if (!binding.scopes.every((scope) => granted.includes(scope))) {
357
+ const scope = binding.scopes.join(' ')
358
+ return {
359
+ ok: false,
360
+ denial: { outcome: 'deny', status: 403, reason: 'insufficient_scope', challenge: { ...challenge, error: 'insufficient_scope', scope } },
361
+ }
362
+ }
363
+ return { ok: true, token: bearer, claims: verified.claims }
364
+ }
365
+
366
+ /** Exchange a `px_` key for a verified token, through the cache. A non-`px_` bearer is refused with no IAM call. */
367
+ private async exchangeKey(app: ProxyApp, bearer: string, requestId: string): Promise<KeyAttempt> {
197
368
  if (!bearer.startsWith(API_KEY_PREFIX)) {
198
- return { outcome: 'deny', status: 401, reason: 'invalid_key' }
369
+ return { ok: false, denial: invalidKey() }
199
370
  }
200
371
 
201
372
  const key = cacheKey(app.id, 'key', bearer)
@@ -203,12 +374,12 @@ export class Authorizer {
203
374
  if (cached !== undefined && cached.expiresAt - this.now() > TOKEN_REFRESH_SKEW_SECONDS) {
204
375
  const verified = await this.verifier.verify(cached.token, app.id)
205
376
  if (verified.ok) {
206
- return { outcome: 'allow', gate: 'service', token: cached.token, subject: verified.claims.sub }
377
+ return { ok: true, token: cached.token, claims: verified.claims }
207
378
  }
208
379
  if (verified.reason === 'unavailable') {
209
380
  // Nothing is wrong with the entry — we simply cannot check it. Keep it; a re-mint could
210
381
  // not be verified either, so it would only add an IAM call to the same 503.
211
- return { outcome: 'deny', status: 503, reason: 'iam_unavailable' }
382
+ return { ok: false, denial: { outcome: 'deny', status: 503, reason: 'iam_unavailable' } }
212
383
  }
213
384
  // A cached entry that PROVED bad (rotated key, revoked issuer) is dropped, and we fall
214
385
  // through to a fresh mint rather than trusting it.
@@ -217,18 +388,16 @@ export class Authorizer {
217
388
 
218
389
  const minted = await this.callIam('mintFromKey', () => this.options.iam.mintFromKey({ app: app.id, key: bearer, requestId }))
219
390
  if (!minted.ok) {
220
- return { outcome: 'deny', status: minted.reason === 'invalid_key' ? 401 : 403, reason: minted.reason }
391
+ return { ok: false, denial: { outcome: 'deny', status: minted.reason === 'invalid_key' ? 401 : 403, reason: minted.reason } }
221
392
  }
222
393
  const verified = await this.verifier.verify(minted.token, app.id)
223
394
  if (!verified.ok) {
224
395
  // We just minted it, so a verification miss is a config/clock/key problem — not a client
225
396
  // error. Either way: deny.
226
- return verified.reason === 'unavailable'
227
- ? { outcome: 'deny', status: 503, reason: 'iam_unavailable' }
228
- : { outcome: 'deny', status: 401, reason: 'invalid_key' }
397
+ return { ok: false, denial: verified.reason === 'unavailable' ? { outcome: 'deny', status: 503, reason: 'iam_unavailable' } : invalidKey() }
229
398
  }
230
399
  this.cache?.set(key, { token: minted.token, expiresAt: minted.expiresAt })
231
- return { outcome: 'allow', gate: 'service', token: minted.token, subject: verified.claims.sub }
400
+ return { ok: true, token: minted.token, claims: verified.claims }
232
401
  }
233
402
 
234
403
  /**
@@ -236,8 +405,8 @@ export class Authorizer {
236
405
  * 1. a cached token minted earlier from the SAME session — no IAM call;
237
406
  * 2. exchange the `px_session` SSO cookie via `mintToken`.
238
407
  *
239
- * Every tier additionally requires `ptype: 'user'`: a `human` gate asserts that a resolved USER
240
- * principal exists. Without the check a broken IAM could turn a service token into a human allow.
408
+ * Every tier additionally requires `ptype: 'user'`: a session asserts that a resolved USER principal
409
+ * exists. Without the check a broken IAM could turn a service token into a human allow.
241
410
  */
242
411
  private async authorizeSession(app: ProxyApp, request: ForwardedRequest): Promise<SessionAttempt> {
243
412
  const cookieHeader = request.headers.get('Cookie')
@@ -256,7 +425,7 @@ export class Authorizer {
256
425
  if (cached !== undefined && cached.expiresAt - now > TOKEN_REFRESH_SKEW_SECONDS) {
257
426
  const verified = await this.verifier.verify(cached.token, app.id)
258
427
  if (verified.ok && verified.claims.ptype === 'user') {
259
- return { ok: true, token: cached.token, subject: verified.claims.sub }
428
+ return { ok: true, token: cached.token, claims: verified.claims }
260
429
  }
261
430
  if (!verified.ok && verified.reason === 'unavailable') {
262
431
  // Nothing is wrong with the entry — we simply cannot check it. Keep it and say so.
@@ -268,7 +437,9 @@ export class Authorizer {
268
437
 
269
438
  const minted = await this.callIam('mintToken', () => this.options.iam.mintToken({ app: app.id, session, requestId: request.requestId }))
270
439
  if (!minted.ok) {
271
- return { ok: false, status: 'miss', reason: minted.reason }
440
+ return minted.reason === 'token_too_large'
441
+ ? { ok: false, status: 'refused', reason: minted.reason }
442
+ : { ok: false, status: 'miss', reason: minted.reason }
272
443
  }
273
444
  const verified = await this.verifier.verify(minted.token, app.id)
274
445
  if (!verified.ok) {
@@ -281,7 +452,7 @@ export class Authorizer {
281
452
  return { ok: false, status: 'miss', reason: 'invalid_session' }
282
453
  }
283
454
  this.cache?.set(key, { token: minted.token, expiresAt: minted.expiresAt })
284
- return { ok: true, token: minted.token, subject: verified.claims.sub }
455
+ return { ok: true, token: minted.token, claims: verified.claims }
285
456
  }
286
457
 
287
458
  /**
@@ -299,9 +470,8 @@ export class Authorizer {
299
470
  }
300
471
 
301
472
  /**
302
- * Redeem a handoff code (ADR-0022). Called for the reserved callback path only, BEFORE gate
303
- * matching — the callback is how a browser becomes able to satisfy a gate, so it cannot be behind
304
- * one.
473
+ * Redeem a handoff code (ADR-0022). Called for the reserved callback path only, BEFORE the endpoint
474
+ * is planned — the callback is how a browser obtains a session, so it cannot be behind an endpoint.
305
475
  *
306
476
  * A failed redemption is an ordinary deny with no cookie. It is deliberately not a fresh bounce to
307
477
  * IAM: a code that will not redeem twice would turn a retry into a loop.
@@ -343,7 +513,11 @@ export class Authorizer {
343
513
  return { outcome: 'handoff', status: 302, location: result.returnUrl, session: result.session, expiresAt: result.expiresAt, handoffState: state }
344
514
  }
345
515
 
346
- private async loginDecision(app: ProxyApp, request: ForwardedRequest, reason: SessionFailureReason): Promise<Decision> {
516
+ private async loginDecision(
517
+ app: ProxyApp,
518
+ request: ForwardedRequest,
519
+ reason: SessionFailureReason,
520
+ ): Promise<Extract<Decision, { outcome: 'login' }>> {
347
521
  const handoff = await createHandoffAttempt()
348
522
  return {
349
523
  outcome: 'login',
@@ -375,6 +549,44 @@ export class Authorizer {
375
549
  }
376
550
  }
377
551
 
552
+ function invalidKey(): Denial {
553
+ return { outcome: 'deny', status: 401, reason: 'invalid_key' }
554
+ }
555
+
556
+ /**
557
+ * The resource and scopes of a row that admits `oauth`, `null` for any other row, and `unregistered` when the entry
558
+ * has no resource on the row's path.
559
+ */
560
+ function oauthBindingOf(app: ProxyApp, row: ManifestEndpointRow): OAuthBinding | null | 'unregistered' {
561
+ const required = 'oauth' in row ? row.oauth : null
562
+ if (required === null) {
563
+ return null
564
+ }
565
+ const resource = 'installation' in app ? oauthResourceOfRow(app, row) : null
566
+ return resource === null ? 'unregistered' : { resource, scopes: required.scopes }
567
+ }
568
+
569
+ /**
570
+ * A refused `px_` key on a row that admits `oauth` still tells an OAuth client where the resource's metadata is
571
+ * (RFC 6750 §3): a 401 as `invalid_token`, a 403 with no error code. Status and reason stay those of the key.
572
+ */
573
+ function withBearerChallenge(denial: Denial, oauth: OAuthBinding | null): Denial {
574
+ if (oauth === null || (denial.status !== 401 && denial.status !== 403)) {
575
+ return denial
576
+ }
577
+ const challenge: OAuthBearerChallenge = { resource_metadata: oauth.resource.resourceMetadata }
578
+ return { ...denial, challenge: denial.status === 401 ? { ...challenge, error: 'invalid_token' } : challenge }
579
+ }
580
+
581
+ /** An endpoint that admits a body admits only its declared media types (ADR-0022 rule 3, admission). */
582
+ function admitBody(row: ManifestEndpointRow, headers: Headers): Denial | null {
583
+ if (row.admission === null) {
584
+ return null
585
+ }
586
+ const type = headers.get('Content-Type')?.split(';')[0]?.trim().toLowerCase() ?? ''
587
+ return row.admission.contentTypes.includes(type) ? null : { outcome: 'deny', status: 400, reason: 'content_type' }
588
+ }
589
+
378
590
  /**
379
591
  * Is this a top-level document navigation — the one caller a 302 actually helps?
380
592
  *
package/src/constants.ts CHANGED
@@ -3,12 +3,12 @@
3
3
  * (`caddy.ts` reads the same constants the service does), so a rename cannot desynchronise them.
4
4
  */
5
5
 
6
- export { CLIENT_ADDRESS_HEADER, PROXY_TOKEN_HEADER, UNTRUSTED_FORWARD_HEADERS } from '@fabrika/auth-core'
6
+ export { CLIENT_ADDRESS_HEADER, DELEGATION_HEADER, ENDPOINT_HEADER, PROXY_TOKEN_HEADER, UNTRUSTED_FORWARD_HEADERS } from '@fabrika/auth-core'
7
7
 
8
8
  // ── What Caddy's `forward_auth` sends us ───────────────────────────────────────
9
9
  //
10
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
11
+ // in these headers. `reverse_proxy` adds the other three X-Forwarded-* itself. Everything the
12
12
  // decision depends on comes from here — never from the `/verify` request's own method/path.
13
13
 
14
14
  export const FORWARDED_METHOD_HEADER = 'X-Forwarded-Method'
@@ -0,0 +1,25 @@
1
+ // Reading a `px_` credential off a request: the bearer header and the session cookie.
2
+
3
+ /** Read the token out of an `Authorization: Bearer <token>` header. Null when absent/non-bearer. */
4
+ export function readBearer(header: string | null): string | null {
5
+ if (header === null) {
6
+ return null
7
+ }
8
+ const match = /^Bearer\s+(.+)$/i.exec(header.trim())
9
+ return match ? (match[1]?.trim() ?? null) : null
10
+ }
11
+
12
+ /** Read a single cookie value out of a raw `Cookie` header. Null when absent or malformed. */
13
+ export function readCookie(header: string | null, name: string): string | null {
14
+ if (!header) {
15
+ return null
16
+ }
17
+ for (const part of header.split(';')) {
18
+ const eq = part.indexOf('=')
19
+ if (eq !== -1 && part.slice(0, eq).trim() === name) {
20
+ const value = part.slice(eq + 1).trim()
21
+ return value === '' ? null : value
22
+ }
23
+ }
24
+ return null
25
+ }