@vzn/vx-devtools 0.0.649 → 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 +20 -3
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/oidc.ts +84 -20
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,13 +64,29 @@ 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
|
|
72
86
|
(found through its `/.well-known/openid-configuration`, kept and
|
|
73
87
|
refetched by `createRemoteJWKSet`), `aud`, `exp` and `nbf` within 60 s.
|
|
88
|
+
When a refetch fails, the keys last read still check tokens. A key they
|
|
89
|
+
lack is then `502`, not a refused token.
|
|
74
90
|
- With `oidcAuth`, every request names its repository: `vx-issuer` and `vx-repository`,
|
|
75
91
|
the `hashPart`s of the issuer's URL (no trailing slash) and of its
|
|
76
92
|
`org/name` lowercased; one missing or not a hash is `400`.
|
|
@@ -90,5 +106,6 @@ export function oidcTokenAuth(
|
|
|
90
106
|
`refs/heads/gh-readonly-queue/<base>/pr-<n>-<sha>` ref. A
|
|
91
107
|
`pull_request_target`, `issue_comment` or `workflow_run` run writes
|
|
92
108
|
nothing: its `ref` is the base while it may run a pull request's code.
|
|
93
|
-
Nor does a GitLab
|
|
94
|
-
|
|
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. */
|
|
@@ -293,16 +298,38 @@ function tokenVerifier(opts: OidcOptions): (token: string, req: Request) => Prom
|
|
|
293
298
|
const now = opts.now ?? (() => Date.now() / 1000)
|
|
294
299
|
// jose keeps each issuer's keys, and refetches them for a key it lacks.
|
|
295
300
|
const keys = new Map<string, Promise<ReturnType<typeof createRemoteJWKSet>>>()
|
|
301
|
+
// The key set each URL last answered. jose refetches every 10 minutes and
|
|
302
|
+
// threw when that failed: one slow answer from GitHub's key endpoint failed
|
|
303
|
+
// an upload 502 and lost a DTE worker's entry (2026-10-29). Keys rotate
|
|
304
|
+
// rarely, so the last set answers until the issuer answers again.
|
|
305
|
+
const lastRead = new Map<string, unknown>()
|
|
306
|
+
// The issuers answered from lastRead since their last fetch: a kid that
|
|
307
|
+
// set lacks is then the issuer out of reach, not a token refused.
|
|
308
|
+
const stale = new Set<string>()
|
|
309
|
+
const keySet = async (iss: string, url: string): Promise<unknown> => {
|
|
310
|
+
try {
|
|
311
|
+
const json = await fetchJson(url, lastRead.has(url))
|
|
312
|
+
lastRead.set(url, json)
|
|
313
|
+
stale.delete(iss)
|
|
314
|
+
return json
|
|
315
|
+
} catch (err) {
|
|
316
|
+
if (!lastRead.has(url)) throw err
|
|
317
|
+
stale.add(iss)
|
|
318
|
+
return lastRead.get(url)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
296
321
|
const keysOf = (iss: string) => {
|
|
297
322
|
let set = keys.get(iss)
|
|
298
323
|
if (set === undefined) {
|
|
299
|
-
set = fetchJson(`${iss.replace(/\/$/, '')}/.well-known/openid-configuration
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
+
)
|
|
306
333
|
keys.set(iss, set)
|
|
307
334
|
// A failed discovery is not kept: the next token tries again.
|
|
308
335
|
// quiet: the awaited set rethrows it; this only forgets it
|
|
@@ -346,6 +373,8 @@ function tokenVerifier(opts: OidcOptions): (token: string, req: Request) => Prom
|
|
|
346
373
|
})
|
|
347
374
|
).payload
|
|
348
375
|
} catch (err) {
|
|
376
|
+
if (err instanceof errors.JWKSNoMatchingKey && stale.has(iss))
|
|
377
|
+
throw keysUnread(iss, new Error('a key the keys last read lack, and a refetch failed'))
|
|
349
378
|
throw refusal(err, iss, audience)
|
|
350
379
|
}
|
|
351
380
|
const claim = repoClaimOf(iss)
|
|
@@ -413,6 +442,41 @@ const keysUnread = (iss: string, err: unknown): Refusal =>
|
|
|
413
442
|
`could not read ${iss}'s keys: ${err instanceof Error ? err.message : String(err)}`,
|
|
414
443
|
)
|
|
415
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
|
+
|
|
416
480
|
async function defaultFetchJson(url: string): Promise<unknown> {
|
|
417
481
|
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) })
|
|
418
482
|
if (!res.ok) throw new Error(`${url}: ${res.status}`)
|