@fabrika/proxy-core 0.3.2 → 0.4.0
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 +9 -4
- package/src/authorize.ts +295 -83
- package/src/constants.ts +2 -2
- package/src/credentials.ts +25 -0
- package/src/headers.ts +210 -0
- package/src/iam.ts +36 -4
- package/src/index.ts +6 -4
- package/src/manifest.ts +320 -31
- package/src/oauth-resource.ts +87 -0
- package/src/oauth.ts +30 -0
- package/src/service.ts +139 -47
- package/src/verifier.ts +3 -3
- package/src/gates.ts +0 -65
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/proxy-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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.
|
|
36
|
-
"@fabrika/jwks": "^0.
|
|
40
|
+
"@fabrika/auth-core": "^0.4.0",
|
|
41
|
+
"@fabrika/jwks": "^0.4.0",
|
|
37
42
|
"jose": "^5.9.0",
|
|
38
|
-
"@fabrika/platform": "^0.
|
|
43
|
+
"@fabrika/platform": "^0.4.0"
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
120
|
+
/** Original method; the endpoint table selects by it. */
|
|
75
121
|
method: string
|
|
76
|
-
/** Original absolute URL
|
|
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
|
|
131
|
+
/** An entry plus its precompiled endpoint table, built once at startup. */
|
|
84
132
|
export interface ResolvedApp {
|
|
85
|
-
app: ProxyApp
|
|
86
|
-
|
|
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
|
-
/**
|
|
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;
|
|
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.
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|
-
|
|
188
|
-
return
|
|
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
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
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
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
|
240
|
-
*
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
303
|
-
*
|
|
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(
|
|
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
|
|
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
|
+
}
|