@vzn/vx-devtools 0.0.652 → 0.0.653
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/README.md +4 -3
- package/package.json +1 -1
- package/src/hash-part.ts +4 -3
- package/src/oidc.ts +30 -9
package/README.md
CHANGED
|
@@ -25,8 +25,9 @@ option.
|
|
|
25
25
|
`public`, `named` when ALLOW names it, or `undefined` when none is named.
|
|
26
26
|
- `errorJson(status, message)` is every server's refusal, `{ code, message }`
|
|
27
27
|
(Turbo's spec's `Error`); `notAllowed(allow)` its `405`, naming the methods in `Allow`.
|
|
28
|
-
- `hashPart(text)` is what a
|
|
29
|
-
128 bits of its SHA-256 in hex (Web Crypto, so Bun and Workers agree)
|
|
28
|
+
- `hashPart(text)` is what a request names its issuer and repository by (`vx-issuer`, `vx-repository`):
|
|
29
|
+
the first 128 bits of its SHA-256 in hex (Web Crypto, so Bun and Workers agree). A server stores
|
|
30
|
+
them plain: a cache object's name and a history row hold the issuer, repository and ref.
|
|
30
31
|
- `statusLine(tone, name, text)` is the line a server prints when it starts (`'ok'`) or refuses to
|
|
31
32
|
(`'err'`): `✓ vx-cache-server listening on …`, painted as vx's own status lines (NO_COLOR, then
|
|
32
33
|
FORCE_COLOR, then a terminal whose `TERM` is not `dumb`), with any lines after the first dim.
|
|
@@ -95,7 +96,7 @@ export function cachedFetchJson(
|
|
|
95
96
|
checkout's origin names (2026-10-25). One `ALLOW` does not name is
|
|
96
97
|
`403`, and so is one other than the request names.
|
|
97
98
|
- The token's workflow (`job_workflow_ref` on GitHub, `ci_config_ref_uri`
|
|
98
|
-
on GitLab, up to
|
|
99
|
+
on GitLab, up to its last `@`, before the ref or SHA) must be one an entry names for that
|
|
99
100
|
repository: any other is `403`.
|
|
100
101
|
- With `readAccess: 'public'` (`READ_ACCESS`), a request with no token
|
|
101
102
|
reads the repository it names when `ALLOW` names it (else `403`), and
|
package/package.json
CHANGED
package/src/hash-part.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
// What a
|
|
2
|
-
//
|
|
3
|
-
// name and a history row hold
|
|
1
|
+
// What a request names its issuer and repository by (`vx-issuer`,
|
|
2
|
+
// `vx-repository`, 2026-10-25), never their plain values. What a server
|
|
3
|
+
// stores is plain: a cache object's name and a history row hold the
|
|
4
|
+
// issuer, repository and ref as they are (2026-10-27, 2026-11-03).
|
|
4
5
|
|
|
5
6
|
const parts = new Map<string, Promise<string>>()
|
|
6
7
|
|
package/src/oidc.ts
CHANGED
|
@@ -155,7 +155,7 @@ function branchOf(iss: string, claims: JWTPayload): string | undefined {
|
|
|
155
155
|
export interface OidcOptions {
|
|
156
156
|
/** `ALLOW` (`parseAllow`): who may take part. */
|
|
157
157
|
readonly allow: readonly Allowed[]
|
|
158
|
-
/** The `aud` a token must name; unset, the origin the server is reached at. */
|
|
158
|
+
/** The `aud` a token must name, read as its origin (a URL that is not one throws); unset, the origin the server is reached at. */
|
|
159
159
|
readonly audience?: string
|
|
160
160
|
/**
|
|
161
161
|
* Reads a URL as JSON (`cachedFetchJson` on a Worker); tests serve keys
|
|
@@ -291,9 +291,7 @@ const bearerOf = (req: Request): string | undefined =>
|
|
|
291
291
|
|
|
292
292
|
/** A token checked: jose's checks, then ALLOW's repository and workflow, and the branch it may write. */
|
|
293
293
|
function tokenVerifier(opts: OidcOptions): (token: string, req: Request) => Promise<Access> {
|
|
294
|
-
|
|
295
|
-
if (opts.audience !== undefined && opts.audience.trim() === '')
|
|
296
|
-
throw new Error('AUDIENCE: the aud CI tokens name, or unset, not blank')
|
|
294
|
+
const own = opts.audience === undefined ? undefined : originOf(opts.audience)
|
|
297
295
|
const fetchJson = opts.fetchJson ?? defaultFetchJson
|
|
298
296
|
const now = opts.now ?? (() => Date.now() / 1000)
|
|
299
297
|
// jose keeps each issuer's keys, and refetches them for a key it lacks.
|
|
@@ -360,7 +358,7 @@ function tokenVerifier(opts: OidcOptions): (token: string, req: Request) => Prom
|
|
|
360
358
|
: 'the token names no issuer (iss)',
|
|
361
359
|
)
|
|
362
360
|
}
|
|
363
|
-
const audience =
|
|
361
|
+
const audience = own ?? new URL(req.url).origin
|
|
364
362
|
let claims: JWTPayload
|
|
365
363
|
try {
|
|
366
364
|
claims = (
|
|
@@ -390,9 +388,13 @@ function tokenVerifier(opts: OidcOptions): (token: string, req: Request) => Prom
|
|
|
390
388
|
const wclaim = workflowClaimOf(iss)
|
|
391
389
|
const ran = claims[wclaim]
|
|
392
390
|
if (typeof ran !== 'string') throw new Refusal(403, `no ${wclaim} claim`)
|
|
393
|
-
// Cut at the
|
|
394
|
-
//
|
|
395
|
-
|
|
391
|
+
// Cut at the last `@`: the ref after it is `refs/…` or a SHA (a reusable
|
|
392
|
+
// workflow pinned by one), and GitLab's CI file may itself hold one
|
|
393
|
+
// (`.gitlab-ci.yml@group/project`, a file kept in another project, which
|
|
394
|
+
// a cut at the first `@refs/` matched as this project's own when that
|
|
395
|
+
// project was `refs/…`). A ref holding an `@` is refused, never widened.
|
|
396
|
+
const at = ran.lastIndexOf('@')
|
|
397
|
+
const workflow = at < 0 ? ran : ran.slice(0, at)
|
|
396
398
|
if (!ofRepo.some((a) => same(workflow, workflowPath(iss, a.repository, a.workflow))))
|
|
397
399
|
throw new Refusal(403, `${wclaim} ${workflow} is not in this server's ALLOW`)
|
|
398
400
|
const repo = { issuer: iss, repository }
|
|
@@ -401,6 +403,22 @@ function tokenVerifier(opts: OidcOptions): (token: string, req: Request) => Prom
|
|
|
401
403
|
}
|
|
402
404
|
}
|
|
403
405
|
|
|
406
|
+
/**
|
|
407
|
+
* `AUDIENCE` as tokens name it: the origin, which vxCache() and vxHistory()
|
|
408
|
+
* ask CI for, so `https://c/` or a URL with a path refused every token.
|
|
409
|
+
*/
|
|
410
|
+
function originOf(audience: string): string {
|
|
411
|
+
const said = audience.trim()
|
|
412
|
+
// A blank one is no origin a token names: every token was "wrong audience".
|
|
413
|
+
if (said === '') throw new Error('AUDIENCE: the aud CI tokens name, or unset, not blank')
|
|
414
|
+
const url = URL.parse(said)
|
|
415
|
+
if (url === null || (url.protocol !== 'https:' && url.protocol !== 'http:'))
|
|
416
|
+
throw new Error(
|
|
417
|
+
`AUDIENCE: the origin clients reach this server at (https://cache.example.com), not ${said}`,
|
|
418
|
+
)
|
|
419
|
+
return url.origin
|
|
420
|
+
}
|
|
421
|
+
|
|
404
422
|
/** An issuer as a key names it: its URL, no trailing slash. */
|
|
405
423
|
const issuerOf = (iss: string): string => iss.replace(/\/$/, '')
|
|
406
424
|
|
|
@@ -469,11 +487,14 @@ export function cachedFetchJson(
|
|
|
469
487
|
const res = await (fetchImpl ?? fetch)(url, { signal: AbortSignal.timeout(10_000) })
|
|
470
488
|
if (!res.ok) throw new Error(`${url}: ${res.status}`)
|
|
471
489
|
const body = await res.text()
|
|
490
|
+
// Parsed before it is kept: a 200 that is not JSON (a proxy's page) is
|
|
491
|
+
// not served to every token for the next ten minutes.
|
|
492
|
+
const json: unknown = JSON.parse(body)
|
|
472
493
|
await cache.put(
|
|
473
494
|
url,
|
|
474
495
|
new Response(body, { headers: { 'cache-control': `max-age=${KEYS_TTL_S}` } }),
|
|
475
496
|
)
|
|
476
|
-
return
|
|
497
|
+
return json
|
|
477
498
|
}
|
|
478
499
|
}
|
|
479
500
|
|