@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 +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/src/service.ts
CHANGED
|
@@ -21,7 +21,17 @@
|
|
|
21
21
|
* back on a 2xx, so the edge is the only thing that mints either.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
AUTH_CALLBACK_PATH,
|
|
26
|
+
compileEndpointTable,
|
|
27
|
+
DELEGATION_HEADER,
|
|
28
|
+
ENDPOINT_HEADER,
|
|
29
|
+
HANDOFF_COOKIE_TTL_SECONDS,
|
|
30
|
+
isCanonicalPath,
|
|
31
|
+
planEndpoint,
|
|
32
|
+
SESSION_COOKIE,
|
|
33
|
+
splitRequestTarget,
|
|
34
|
+
} from '@fabrika/auth-core'
|
|
25
35
|
import { uuidv7 } from '@fabrika/platform'
|
|
26
36
|
import { Authorizer, type AuthorizerOptions, type Decision, type DenyReason, type ForwardedRequest, type ResolvedApp } from './authorize'
|
|
27
37
|
import type { TokenCache } from './cache'
|
|
@@ -36,11 +46,20 @@ import {
|
|
|
36
46
|
PROXY_TOKEN_HEADER,
|
|
37
47
|
REQUEST_ID_HEADER,
|
|
38
48
|
} from './constants'
|
|
39
|
-
import { compileGates } from './gates'
|
|
40
49
|
import { type HandoffAttempt, handoffCookieName } from './handoff'
|
|
41
|
-
import
|
|
50
|
+
import {
|
|
51
|
+
appFileTreatment,
|
|
52
|
+
appHeaderPolicy,
|
|
53
|
+
applyFileTreatment,
|
|
54
|
+
applyHeaderPolicy,
|
|
55
|
+
BASE_HEADER_POLICY,
|
|
56
|
+
type HeaderPolicy,
|
|
57
|
+
transportPolicy,
|
|
58
|
+
} from './headers'
|
|
59
|
+
import type { DelegationIssuer, IamGateway } from './iam'
|
|
42
60
|
import { type LogFields, type ProxyLogger, redactPath, silentLogger } from './log'
|
|
43
|
-
import type
|
|
61
|
+
import { formatOAuthBearerChallenge, isApplicationEntry, type ManifestEndpointRow, type ProxyApp, type ProxyManifest } from './manifest'
|
|
62
|
+
import { findOAuthMetadata, OAUTH_METADATA_VERIFY_PATH, oauthMetadataResponse } from './oauth'
|
|
44
63
|
|
|
45
64
|
export interface VerifyServiceConfig {
|
|
46
65
|
manifest: ProxyManifest
|
|
@@ -54,6 +73,8 @@ export interface VerifyServiceConfig {
|
|
|
54
73
|
healthPath?: string
|
|
55
74
|
/** Unix seconds. Injectable for tests. */
|
|
56
75
|
now?: () => number
|
|
76
|
+
/** Issues delegation grants for membership and scope-creating endpoints; see `AuthorizerOptions.delegations`. */
|
|
77
|
+
delegations?: DelegationIssuer | null
|
|
57
78
|
}
|
|
58
79
|
|
|
59
80
|
export type VerifyService = (request: Request) => Promise<Response>
|
|
@@ -62,8 +83,8 @@ export type VerifyService = (request: Request) => Promise<Response>
|
|
|
62
83
|
const HOST_PATTERN = /^[a-z0-9.\-[\]]+(:\d{1,5})?$/i
|
|
63
84
|
|
|
64
85
|
/**
|
|
65
|
-
* Build the `/verify` service.
|
|
66
|
-
* hot path of every request to every app.
|
|
86
|
+
* Build the `/verify` service. Endpoint tables are compiled once here, not per request — the planner is
|
|
87
|
+
* on the hot path of every request to every app.
|
|
67
88
|
*/
|
|
68
89
|
export function createVerifyService(config: VerifyServiceConfig): VerifyService {
|
|
69
90
|
const logger = config.logger ?? silentLogger
|
|
@@ -71,9 +92,11 @@ export function createVerifyService(config: VerifyServiceConfig): VerifyService
|
|
|
71
92
|
|
|
72
93
|
const byId = new Map<string, ResolvedApp>()
|
|
73
94
|
const byHost = new Map<string, ResolvedApp>()
|
|
95
|
+
const policies = new Map<string, HeaderPolicy>()
|
|
74
96
|
for (const app of config.manifest.apps) {
|
|
75
|
-
const resolved
|
|
97
|
+
const resolved = resolveApp(app)
|
|
76
98
|
byId.set(app.id, resolved)
|
|
99
|
+
policies.set(app.id, isApplicationEntry(app) ? appHeaderPolicy(app) : BASE_HEADER_POLICY)
|
|
77
100
|
for (const host of app.hosts) {
|
|
78
101
|
// Normalized exactly as a forwarded host is, so the two sides cannot disagree about a port.
|
|
79
102
|
byHost.set(normalizeHost(host), resolved)
|
|
@@ -87,6 +110,9 @@ export function createVerifyService(config: VerifyServiceConfig): VerifyService
|
|
|
87
110
|
if (config.now !== undefined) {
|
|
88
111
|
authorizerOptions.now = config.now
|
|
89
112
|
}
|
|
113
|
+
if (config.delegations !== undefined) {
|
|
114
|
+
authorizerOptions.delegations = config.delegations
|
|
115
|
+
}
|
|
90
116
|
const authorizer = new Authorizer(authorizerOptions)
|
|
91
117
|
|
|
92
118
|
return async (request: Request): Promise<Response> => {
|
|
@@ -99,30 +125,36 @@ export function createVerifyService(config: VerifyServiceConfig): VerifyService
|
|
|
99
125
|
if (url.pathname === healthPath) {
|
|
100
126
|
return new Response('ok', { status: 200, headers: { 'cache-control': 'no-store' } })
|
|
101
127
|
}
|
|
102
|
-
if (url.pathname !== DEFAULT_VERIFY_PATH) {
|
|
103
|
-
return textResponse(404, 'not found')
|
|
128
|
+
if (url.pathname !== DEFAULT_VERIFY_PATH && url.pathname !== OAUTH_METADATA_VERIFY_PATH) {
|
|
129
|
+
return withPolicy(textResponse(404, 'not found'), BASE_HEADER_POLICY)
|
|
104
130
|
}
|
|
105
131
|
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
106
132
|
// `forward_auth` always rewrites to GET; anything else did not come from our config.
|
|
107
|
-
return textResponse(405, 'method not allowed')
|
|
133
|
+
return withPolicy(textResponse(405, 'method not allowed'), BASE_HEADER_POLICY)
|
|
108
134
|
}
|
|
109
135
|
|
|
110
|
-
const
|
|
111
|
-
if (
|
|
112
|
-
|
|
136
|
+
const read = readForwardedRequest(request, requestId)
|
|
137
|
+
if (!read.ok) {
|
|
138
|
+
const status = read.reason === 'non_canonical_path' ? 400 : 403
|
|
139
|
+
return withPolicy(denied(logger, { requestId }, { outcome: 'deny', status, reason: read.reason }), earlyPolicy(request, url))
|
|
113
140
|
}
|
|
141
|
+
const forwarded = read.request
|
|
114
142
|
|
|
115
143
|
const resolved = selectApp(url, forwarded.url.host, byId, byHost)
|
|
116
144
|
if (resolved === null) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
145
|
+
const fields = { requestId, host: forwarded.url.host, path: redactPath(forwarded.url.pathname) }
|
|
146
|
+
return withPolicy(denied(logger, fields, { outcome: 'deny', status: 403, reason: 'no_app' }), BASE_HEADER_POLICY)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (url.pathname === OAUTH_METADATA_VERIFY_PATH) {
|
|
150
|
+
const metadata = findOAuthMetadata(resolved.app, forwarded.url)
|
|
151
|
+
return metadata === null
|
|
152
|
+
? withPolicy(textResponse(404, 'not found'), policies.get(resolved.app.id) ?? BASE_HEADER_POLICY)
|
|
153
|
+
: oauthMetadataResponse(metadata, forwarded.method)
|
|
122
154
|
}
|
|
123
155
|
|
|
124
|
-
// The handoff callback is checked BEFORE the
|
|
125
|
-
//
|
|
156
|
+
// The handoff callback is checked BEFORE the endpoint table: it is how a browser obtains a
|
|
157
|
+
// session, so it cannot itself be behind an endpoint. Because Caddy returns a non-2xx auth
|
|
126
158
|
// response verbatim, answering 302 + Set-Cookie here reaches the client and never the app.
|
|
127
159
|
const isCallback = forwarded.url.pathname === AUTH_CALLBACK_PATH
|
|
128
160
|
const decision = isCallback
|
|
@@ -136,25 +168,68 @@ export function createVerifyService(config: VerifyServiceConfig): VerifyService
|
|
|
136
168
|
path: redactPath(forwarded.url.pathname),
|
|
137
169
|
}
|
|
138
170
|
if (decision.outcome === 'allow') {
|
|
139
|
-
logger.info('allow', { ...fields,
|
|
171
|
+
logger.info('allow', { ...fields, subject: decision.subject, endpoint: decision.endpoint })
|
|
140
172
|
const headers = new Headers({ 'cache-control': 'no-store', [REQUEST_ID_HEADER]: requestId })
|
|
141
173
|
if (decision.token !== null) {
|
|
142
174
|
headers.set(PROXY_TOKEN_HEADER, decision.token)
|
|
143
175
|
}
|
|
176
|
+
if (decision.endpoint !== null) {
|
|
177
|
+
headers.set(ENDPOINT_HEADER, decision.endpoint)
|
|
178
|
+
}
|
|
179
|
+
if (decision.delegation !== null) {
|
|
180
|
+
headers.set(DELEGATION_HEADER, decision.delegation)
|
|
181
|
+
}
|
|
144
182
|
return new Response(null, { status: 204, headers })
|
|
145
183
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
return denied(logger, fields, decision, isCallback)
|
|
184
|
+
const answer = decision.outcome === 'handoff' ? handoff(logger, fields, decision) : denied(logger, fields, decision, isCallback)
|
|
185
|
+
return isDownload(resolved, forwarded)
|
|
186
|
+
? withFileTreatment(answer, appFileTreatment(resolved.app))
|
|
187
|
+
: withPolicy(answer, policies.get(resolved.app.id) ?? BASE_HEADER_POLICY)
|
|
151
188
|
} catch {
|
|
152
189
|
// The last line of the fail-closed guarantee. No `err` is inspected or logged: a caught
|
|
153
190
|
// error can stringify a URL, a header or a body, any of which may carry a credential.
|
|
154
191
|
logger.error('verify failed', { requestId })
|
|
155
|
-
return textResponse(403, 'forbidden')
|
|
192
|
+
return withPolicy(textResponse(403, 'forbidden'), earlyPolicy(request, null))
|
|
156
193
|
}
|
|
157
194
|
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* A refusal before the request is read: the base policy, plus the transport header of the application host it
|
|
198
|
+
* names, when the forwarded host (and the pinned app) resolves to one. Nothing of the host decides anything else.
|
|
199
|
+
*/
|
|
200
|
+
function earlyPolicy(request: Request, verifyUrl: URL | null): HeaderPolicy {
|
|
201
|
+
try {
|
|
202
|
+
const host = request.headers.get(FORWARDED_HOST_HEADER) ?? request.headers.get('Host') ?? ''
|
|
203
|
+
const resolved = selectApp(verifyUrl ?? new URL(request.url), host, byId, byHost)
|
|
204
|
+
return resolved === null || !isApplicationEntry(resolved.app)
|
|
205
|
+
? BASE_HEADER_POLICY
|
|
206
|
+
: [...BASE_HEADER_POLICY, ...transportPolicy(resolved.app.scheme)]
|
|
207
|
+
} catch {
|
|
208
|
+
return BASE_HEADER_POLICY
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function handoff(logger: ProxyLogger, fields: LogFields, decision: Extract<Decision, { outcome: 'handoff' }>): Response {
|
|
214
|
+
logger.info('handoff', fields)
|
|
215
|
+
return handoffResponse(decision)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** An answer the proxy writes reaches the client as-is, so it carries the policy the application's own answers do. */
|
|
219
|
+
function withPolicy(response: Response, policy: HeaderPolicy): Response {
|
|
220
|
+
applyHeaderPolicy(response.headers, policy)
|
|
221
|
+
return response
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function withFileTreatment(response: Response, treatment: HeaderPolicy): Response {
|
|
225
|
+
applyFileTreatment(response.headers, treatment)
|
|
226
|
+
return response
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Whether the request selects a download endpoint, whose every answer gets the file treatment instead of the policy. */
|
|
230
|
+
function isDownload(resolved: ResolvedApp, forwarded: ForwardedRequest): boolean {
|
|
231
|
+
const plan = planEndpoint(resolved.table, forwarded.method, forwarded.rawPath)
|
|
232
|
+
return plan.outcome === 'endpoint' && plan.endpoint.kind === 'download'
|
|
158
233
|
}
|
|
159
234
|
|
|
160
235
|
/**
|
|
@@ -198,12 +273,13 @@ function denied(
|
|
|
198
273
|
: loginRequired(decision.location, decision.handoff)
|
|
199
274
|
}
|
|
200
275
|
logger.warn('deny', { ...fields, reason, status: decision.status })
|
|
201
|
-
const response = isCallback
|
|
202
|
-
? callbackFailure(decision.status)
|
|
203
|
-
: textResponse(decision.status, decision.status === 401 ? 'unauthorized' : decision.status === 503 ? 'unavailable' : 'forbidden')
|
|
276
|
+
const response = isCallback ? callbackFailure(decision.status) : textResponse(decision.status, DENY_TEXT[decision.status])
|
|
204
277
|
if (decision.clearHandoffState !== undefined) {
|
|
205
278
|
response.headers.append('set-cookie', clearHandoffCookie(decision.clearHandoffState))
|
|
206
279
|
}
|
|
280
|
+
if (decision.challenge !== undefined) {
|
|
281
|
+
response.headers.set('WWW-Authenticate', formatOAuthBearerChallenge(decision.challenge))
|
|
282
|
+
}
|
|
207
283
|
return response
|
|
208
284
|
}
|
|
209
285
|
|
|
@@ -300,46 +376,57 @@ function secureCookie(name: string, value: string, maxAge: number): string {
|
|
|
300
376
|
return [`${name}=${value}`, 'Path=/', `Max-Age=${maxAge}`, 'HttpOnly', 'SameSite=Lax', 'Secure'].join('; ')
|
|
301
377
|
}
|
|
302
378
|
|
|
379
|
+
const DENY_TEXT: Record<Extract<Decision, { outcome: 'deny' }>['status'], string> = {
|
|
380
|
+
400: 'bad request',
|
|
381
|
+
401: 'unauthorized',
|
|
382
|
+
403: 'forbidden',
|
|
383
|
+
503: 'unavailable',
|
|
384
|
+
}
|
|
385
|
+
|
|
303
386
|
function textResponse(status: number, body: string): Response {
|
|
304
387
|
return new Response(body, { status, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } })
|
|
305
388
|
}
|
|
306
389
|
|
|
390
|
+
type ForwardedRead = { ok: true; request: ForwardedRequest } | { ok: false; reason: 'bad_forward' | 'non_canonical_path' }
|
|
391
|
+
|
|
307
392
|
/**
|
|
308
|
-
* Rebuild the original request from the forwarded headers.
|
|
393
|
+
* Rebuild the original request from the forwarded headers. Refuses — a DENY — whenever the
|
|
309
394
|
* description is incomplete or suspicious, because a proxy that guesses what was asked for is a proxy
|
|
310
|
-
* that authorizes the wrong thing.
|
|
395
|
+
* that authorizes the wrong thing. That includes a missing method: there is no default to guess.
|
|
396
|
+
*
|
|
397
|
+
* The path is checked RAW, before any URL is built: assigning a pathname resolves `..` and `%2e%2e`, so
|
|
398
|
+
* `/a/../admin` would otherwise be matched as `/admin` while the upstream receives the original.
|
|
311
399
|
*
|
|
312
400
|
* The URL is assembled by assignment rather than by `new URL(uri, base)`: a request line of
|
|
313
401
|
* `GET //evil.example/x` is a legal path, and relative resolution would read it as a protocol-relative
|
|
314
|
-
* URL, silently changing the host — which would both
|
|
402
|
+
* URL, silently changing the host — which would both select the wrong app and turn the login bounce
|
|
315
403
|
* into an open redirect.
|
|
316
404
|
*/
|
|
317
|
-
function readForwardedRequest(request: Request, requestId: string):
|
|
405
|
+
function readForwardedRequest(request: Request, requestId: string): ForwardedRead {
|
|
318
406
|
const uri = request.headers.get(FORWARDED_URI_HEADER)
|
|
319
|
-
|
|
320
|
-
|
|
407
|
+
const method = request.headers.get(FORWARDED_METHOD_HEADER)
|
|
408
|
+
if (uri === null || !uri.startsWith('/') || method === null || method === '') {
|
|
409
|
+
return { ok: false, reason: 'bad_forward' }
|
|
410
|
+
}
|
|
411
|
+
const target = splitRequestTarget(uri)
|
|
412
|
+
if (!isCanonicalPath(target.pathname)) {
|
|
413
|
+
return { ok: false, reason: 'non_canonical_path' }
|
|
321
414
|
}
|
|
322
415
|
const host = request.headers.get(FORWARDED_HOST_HEADER) ?? request.headers.get('Host')
|
|
323
416
|
if (host === null || !HOST_PATTERN.test(host)) {
|
|
324
|
-
return
|
|
417
|
+
return { ok: false, reason: 'bad_forward' }
|
|
325
418
|
}
|
|
326
419
|
const proto = request.headers.get(FORWARDED_PROTO_HEADER) === 'http' ? 'http' : 'https'
|
|
327
420
|
|
|
328
|
-
const query = uri.indexOf('?')
|
|
329
421
|
const url = new URL(`${proto}://${host}`)
|
|
330
|
-
url.pathname =
|
|
331
|
-
url.search =
|
|
422
|
+
url.pathname = target.pathname
|
|
423
|
+
url.search = target.search
|
|
332
424
|
if (url.host.toLowerCase() !== host.toLowerCase()) {
|
|
333
425
|
// Defensive: the assignment above must not have been able to move the host.
|
|
334
|
-
return
|
|
426
|
+
return { ok: false, reason: 'bad_forward' }
|
|
335
427
|
}
|
|
336
428
|
|
|
337
|
-
return {
|
|
338
|
-
method: request.headers.get(FORWARDED_METHOD_HEADER) ?? 'GET',
|
|
339
|
-
url,
|
|
340
|
-
headers: request.headers,
|
|
341
|
-
requestId,
|
|
342
|
-
}
|
|
429
|
+
return { ok: true, request: { method, url, rawPath: target.pathname, headers: request.headers, requestId } }
|
|
343
430
|
}
|
|
344
431
|
|
|
345
432
|
/**
|
|
@@ -366,6 +453,11 @@ function selectApp(verifyUrl: URL, host: string, byId: Map<string, ResolvedApp>,
|
|
|
366
453
|
return byHost.get(hostname) ?? null
|
|
367
454
|
}
|
|
368
455
|
|
|
456
|
+
/** The compiled table an app is protected by. The manifest parser already refused an ambiguous table. */
|
|
457
|
+
function resolveApp(app: ProxyApp): ResolvedApp {
|
|
458
|
+
return { app, table: compileEndpointTable<ManifestEndpointRow>(app) }
|
|
459
|
+
}
|
|
460
|
+
|
|
369
461
|
/** Lower-case, port stripped — the one normalization every host comparison in this file uses. */
|
|
370
462
|
function normalizeHost(host: string): string {
|
|
371
463
|
return host.toLowerCase().replace(/:\d+$/, '')
|
package/src/verifier.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* not per verifier.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import type { Jwks } from '@fabrika/auth-core'
|
|
11
|
+
import type { Jwks, TokenAudienceProfile } from '@fabrika/auth-core'
|
|
12
12
|
import { TokenVerifier as JwksVerifier, type VerifyResult } from '@fabrika/jwks'
|
|
13
13
|
import { JWKS_REFETCH_COOLDOWN_SECONDS, JWKS_TTL_SECONDS } from './constants'
|
|
14
14
|
|
|
@@ -28,11 +28,11 @@ export class TokenVerifier {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
* Verify `token` for `audience
|
|
31
|
+
* Verify `token` for `audience` (a bare string is an app id). An unknown `kid` forces one JWKS refetch (a key rotated in), then a
|
|
32
32
|
* retry — but at most once per cooldown, because otherwise any caller can turn every request into an
|
|
33
33
|
* IAM round trip just by inventing a `kid`.
|
|
34
34
|
*/
|
|
35
|
-
verify(token: string, audience: string): Promise<VerifyResult> {
|
|
35
|
+
verify(token: string, audience: string | TokenAudienceProfile): Promise<VerifyResult> {
|
|
36
36
|
return this.inner.verify(token, audience)
|
|
37
37
|
}
|
|
38
38
|
}
|
package/src/gates.ts
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Credential extraction for the gate evaluator relocated out of the app's process
|
|
3
|
-
* ([ADR-0022](../../../docs/decisions/0022-the-proxy-is-the-only-enforcement-point.md)).
|
|
4
|
-
*
|
|
5
|
-
* The semantics are `AppGates`', verbatim (see `@fabrika/auth-core`'s `types.ts`):
|
|
6
|
-
* - array order IS the precedence;
|
|
7
|
-
* - a matching rule whose credential is ABSENT falls through to the next matching rule;
|
|
8
|
-
* - a matching rule whose credential is PRESENT is terminal (valid → allow, invalid → deny);
|
|
9
|
-
* - a request matching NO rule is denied.
|
|
10
|
-
*
|
|
11
|
-
* The canonical matcher and its ordered compiled representation live in `@fabrika/auth-core` so the
|
|
12
|
-
* gate declaration and its evaluator cannot drift. Credential extraction remains proxy-owned.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import type { CredentialLocation } from '@fabrika/auth-core'
|
|
16
|
-
|
|
17
|
-
export { applicableGates, compileGates } from '@fabrika/auth-core'
|
|
18
|
-
export type { CompiledGate } from '@fabrika/auth-core'
|
|
19
|
-
|
|
20
|
-
// ── Credential extraction ──────────────────────────────────────────────────────
|
|
21
|
-
|
|
22
|
-
/** The raw `service`-rule credential: the declared location, else `Authorization: Bearer`. Null if absent. */
|
|
23
|
-
export function readServiceCredential(headers: Headers, url: URL, location: CredentialLocation | undefined): string | null {
|
|
24
|
-
if (location !== undefined) {
|
|
25
|
-
const raw = extractCredential(headers, url, location)
|
|
26
|
-
return raw === null || raw === '' ? null : raw
|
|
27
|
-
}
|
|
28
|
-
return readBearer(headers.get('Authorization'))
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Pull the raw credential from a declared location. A header value may be bare or `Bearer <token>`. */
|
|
32
|
-
function extractCredential(headers: Headers, url: URL, source: CredentialLocation): string | null {
|
|
33
|
-
if (source.in === 'header') {
|
|
34
|
-
const value = headers.get(source.name)
|
|
35
|
-
return value === null ? null : (readBearer(value) ?? value.trim())
|
|
36
|
-
}
|
|
37
|
-
if (source.in === 'query') {
|
|
38
|
-
return url.searchParams.get(source.name)
|
|
39
|
-
}
|
|
40
|
-
return readCookie(headers.get('Cookie'), source.name)
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** Read the token out of an `Authorization: Bearer <token>` header. Null when absent/non-bearer. */
|
|
44
|
-
export function readBearer(header: string | null): string | null {
|
|
45
|
-
if (header === null) {
|
|
46
|
-
return null
|
|
47
|
-
}
|
|
48
|
-
const match = /^Bearer\s+(.+)$/i.exec(header.trim())
|
|
49
|
-
return match ? (match[1]?.trim() ?? null) : null
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Read a single cookie value out of a raw `Cookie` header. Null when absent or malformed. */
|
|
53
|
-
export function readCookie(header: string | null, name: string): string | null {
|
|
54
|
-
if (!header) {
|
|
55
|
-
return null
|
|
56
|
-
}
|
|
57
|
-
for (const part of header.split(';')) {
|
|
58
|
-
const eq = part.indexOf('=')
|
|
59
|
-
if (eq !== -1 && part.slice(0, eq).trim() === name) {
|
|
60
|
-
const value = part.slice(eq + 1).trim()
|
|
61
|
-
return value === '' ? null : value
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
return null
|
|
65
|
-
}
|