@vzn/vx-devtools 0.0.650 → 0.0.651
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 +18 -3
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/oidc.ts +63 -21
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@ export type Authorize = (req: Request) => Promise<Access>
|
|
|
51
51
|
export interface OidcOptions {
|
|
52
52
|
readonly allow: readonly Allowed[]
|
|
53
53
|
readonly audience?: string
|
|
54
|
-
readonly fetchJson?: (url: string) => Promise<unknown>
|
|
54
|
+
readonly fetchJson?: (url: string, fresh: boolean) => Promise<unknown>
|
|
55
55
|
readonly now?: () => number
|
|
56
56
|
readonly readAccess?: string
|
|
57
57
|
}
|
|
@@ -64,8 +64,22 @@ export interface Repo {
|
|
|
64
64
|
export function oidcTokenAuth(
|
|
65
65
|
opts: OidcOptions,
|
|
66
66
|
): (req: Request, named?: Repo) => Promise<Access | undefined>
|
|
67
|
+
export interface KeysCache {
|
|
68
|
+
match(url: string): Promise<Response | undefined>
|
|
69
|
+
put(url: string, res: Response): Promise<void>
|
|
70
|
+
}
|
|
71
|
+
export function cachedFetchJson(
|
|
72
|
+
cache: KeysCache,
|
|
73
|
+
fetchImpl?: typeof fetch,
|
|
74
|
+
): (url: string, fresh: boolean) => Promise<unknown>
|
|
67
75
|
```
|
|
68
76
|
|
|
77
|
+
- `fetchJson`'s `fresh` is true for a reload of keys already read. A reload
|
|
78
|
+
must reach the issuer. The first read of a URL may come from a cache.
|
|
79
|
+
- `cachedFetchJson(caches.default)` (any `KeysCache`) is how a Worker reads the keys: a fresh
|
|
80
|
+
isolate takes them from the cache its data center shares (10 minutes),
|
|
81
|
+
not from the issuer.
|
|
82
|
+
|
|
69
83
|
## How it decides
|
|
70
84
|
|
|
71
85
|
- jose checks the JWT: `RS256`, the signature against the issuer's keys
|
|
@@ -92,5 +106,6 @@ export function oidcTokenAuth(
|
|
|
92
106
|
`refs/heads/gh-readonly-queue/<base>/pr-<n>-<sha>` ref. A
|
|
93
107
|
`pull_request_target`, `issue_comment` or `workflow_run` run writes
|
|
94
108
|
nothing: its `ref` is the base while it may run a pull request's code.
|
|
95
|
-
Nor does a GitLab
|
|
96
|
-
|
|
109
|
+
Nor does a GitLab run whose `source_project_path` or `job_project_path`
|
|
110
|
+
names another project (a fork's merge request, a child pipeline of one),
|
|
111
|
+
or a merge request pipeline naming no source project (GitLab before 19.5).
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
package/src/oidc.ts
CHANGED
|
@@ -126,22 +126,23 @@ const ON_BEHALF = new Set(['pull_request_target', 'issue_comment', 'workflow_run
|
|
|
126
126
|
* queue's (`merge_group`) run writes its base branch, which its code is
|
|
127
127
|
* about to become (2026-10-25: "just strip it"); only its ref is renamed:
|
|
128
128
|
* anyone can push a branch named like a queue ref. A run of an event in
|
|
129
|
-
* `ON_BEHALF` writes nothing. A GitLab
|
|
130
|
-
*
|
|
131
|
-
* the parent project with the fork's branch
|
|
132
|
-
*
|
|
129
|
+
* `ON_BEHALF` writes nothing. A GitLab run writes only when the code and
|
|
130
|
+
* the job are its project's own (`source_project_path`, `job_project_path`):
|
|
131
|
+
* a fork's merge request runs in the parent project with the fork's branch
|
|
132
|
+
* as its `ref_path`, and so may a child pipeline of one or a mirrored pull
|
|
133
|
+
* request, which were not checked. A merge request pipeline must name its
|
|
134
|
+
* source project: a GitLab before 19.5 names none.
|
|
133
135
|
*/
|
|
134
136
|
function branchOf(iss: string, claims: JWTPayload): string | undefined {
|
|
135
|
-
if (iss !== GITHUB
|
|
136
|
-
|
|
137
|
+
if (iss !== GITHUB) {
|
|
138
|
+
// Absent is its own, but where it must be named: a merge request's.
|
|
139
|
+
const own = (claim: string, required: boolean) => {
|
|
137
140
|
const p = claims[claim]
|
|
141
|
+
if (p === undefined) return !required
|
|
138
142
|
return typeof p === 'string' && same(p, String(claims['project_path']))
|
|
139
143
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
(claims['job_project_path'] !== undefined && !own('job_project_path'))
|
|
143
|
-
)
|
|
144
|
-
return undefined
|
|
144
|
+
const mr = claims['pipeline_source'] === 'merge_request_event'
|
|
145
|
+
if (!own('source_project_path', mr) || !own('job_project_path', false)) return undefined
|
|
145
146
|
}
|
|
146
147
|
const event = claims['event_name']
|
|
147
148
|
if (ON_BEHALF.has(event as string)) return undefined
|
|
@@ -156,8 +157,12 @@ export interface OidcOptions {
|
|
|
156
157
|
readonly allow: readonly Allowed[]
|
|
157
158
|
/** The `aud` a token must name; unset, the origin the server is reached at. */
|
|
158
159
|
readonly audience?: string
|
|
159
|
-
/**
|
|
160
|
-
|
|
160
|
+
/**
|
|
161
|
+
* Reads a URL as JSON (`cachedFetchJson` on a Worker); tests serve keys
|
|
162
|
+
* without a network. `fresh`: a reload of keys already read, which must
|
|
163
|
+
* reach the issuer; the first read of a URL may come from a cache.
|
|
164
|
+
*/
|
|
165
|
+
readonly fetchJson?: (url: string, fresh: boolean) => Promise<unknown>
|
|
161
166
|
/** Seconds since the epoch; tests move the clock. */
|
|
162
167
|
readonly now?: () => number
|
|
163
168
|
/** `READ_ACCESS`: `public` lets a request with no token read a repository ALLOW names. */
|
|
@@ -303,7 +308,7 @@ function tokenVerifier(opts: OidcOptions): (token: string, req: Request) => Prom
|
|
|
303
308
|
const stale = new Set<string>()
|
|
304
309
|
const keySet = async (iss: string, url: string): Promise<unknown> => {
|
|
305
310
|
try {
|
|
306
|
-
const json = await fetchJson(url)
|
|
311
|
+
const json = await fetchJson(url, lastRead.has(url))
|
|
307
312
|
lastRead.set(url, json)
|
|
308
313
|
stale.delete(iss)
|
|
309
314
|
return json
|
|
@@ -316,13 +321,15 @@ function tokenVerifier(opts: OidcOptions): (token: string, req: Request) => Prom
|
|
|
316
321
|
const keysOf = (iss: string) => {
|
|
317
322
|
let set = keys.get(iss)
|
|
318
323
|
if (set === undefined) {
|
|
319
|
-
set = fetchJson(`${iss.replace(/\/$/, '')}/.well-known/openid-configuration
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
324
|
+
set = fetchJson(`${iss.replace(/\/$/, '')}/.well-known/openid-configuration`, false).then(
|
|
325
|
+
(c) => {
|
|
326
|
+
const uri = (c as { jwks_uri?: unknown }).jwks_uri
|
|
327
|
+
if (typeof uri !== 'string') throw new Error('no jwks_uri')
|
|
328
|
+
return createRemoteJWKSet(new URL(uri), {
|
|
329
|
+
[customFetch]: async (url: string) => Response.json(await keySet(iss, url)),
|
|
330
|
+
})
|
|
331
|
+
},
|
|
332
|
+
)
|
|
326
333
|
keys.set(iss, set)
|
|
327
334
|
// A failed discovery is not kept: the next token tries again.
|
|
328
335
|
// quiet: the awaited set rethrows it; this only forgets it
|
|
@@ -435,6 +442,41 @@ const keysUnread = (iss: string, err: unknown): Refusal =>
|
|
|
435
442
|
`could not read ${iss}'s keys: ${err instanceof Error ? err.message : String(err)}`,
|
|
436
443
|
)
|
|
437
444
|
|
|
445
|
+
/** A cache a Worker's isolates share (`caches.default`), as `cachedFetchJson` uses it. */
|
|
446
|
+
export interface KeysCache {
|
|
447
|
+
match(url: string): Promise<Response | undefined>
|
|
448
|
+
put(url: string, res: Response): Promise<void>
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** How long a shared cache holds an issuer's answer for the next isolate. */
|
|
452
|
+
const KEYS_TTL_S = 600
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* `fetchJson` through a cache isolates share: a Worker's fresh isolate read
|
|
456
|
+
* the issuer on its first token, and one slow answer from GitHub failed it
|
|
457
|
+
* 502 (2026-10-29), as the DTE relay found (2026-10-22). A fresh read (a
|
|
458
|
+
* reload, maybe for a rotated key) asks the issuer, and refills the cache.
|
|
459
|
+
*/
|
|
460
|
+
export function cachedFetchJson(
|
|
461
|
+
cache: KeysCache,
|
|
462
|
+
fetchImpl?: typeof fetch,
|
|
463
|
+
): (url: string, fresh: boolean) => Promise<unknown> {
|
|
464
|
+
return async (url, fresh) => {
|
|
465
|
+
const hit = fresh ? undefined : await cache.match(url)
|
|
466
|
+
if (hit !== undefined) return hit.json()
|
|
467
|
+
// The global read at each call: one taken when the module loaded missed
|
|
468
|
+
// a fetch set after it (a test's issuer reached the network).
|
|
469
|
+
const res = await (fetchImpl ?? fetch)(url, { signal: AbortSignal.timeout(10_000) })
|
|
470
|
+
if (!res.ok) throw new Error(`${url}: ${res.status}`)
|
|
471
|
+
const body = await res.text()
|
|
472
|
+
await cache.put(
|
|
473
|
+
url,
|
|
474
|
+
new Response(body, { headers: { 'cache-control': `max-age=${KEYS_TTL_S}` } }),
|
|
475
|
+
)
|
|
476
|
+
return JSON.parse(body)
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
438
480
|
async function defaultFetchJson(url: string): Promise<unknown> {
|
|
439
481
|
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) })
|
|
440
482
|
if (!res.ok) throw new Error(`${url}: ${res.status}`)
|