@vzn/vx-devtools 0.0.0-stage → 0.0.648
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/LICENSE +21 -0
- package/README.md +93 -2
- package/index.ts +4 -0
- package/package.json +42 -4
- package/src/hash-part.ts +23 -0
- package/src/index.ts +17 -0
- package/src/oidc.ts +420 -0
- package/src/refusal.ts +36 -0
- package/src/terminal.ts +32 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 VZN
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,94 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @vzn/vx-devtools
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Website: <https://vznjs.github.io/vx/> · source: <https://github.com/vznjs/vx>
|
|
4
|
+
|
|
5
|
+
Code vx's servers and plugins share. Core never imports it.
|
|
6
|
+
|
|
7
|
+
## Purpose
|
|
8
|
+
|
|
9
|
+
First, the CI tokens vx's servers take. `@vzn/vx-cache-server` and
|
|
10
|
+
`@vzn/vx-history-server` take one kind of token: a CI job's OIDC token
|
|
11
|
+
(GitHub Actions, GitLab CI). This package is the check both share. It imports only jose, so a server on Workers
|
|
12
|
+
bundles it alone. Clients never import it: each plugin asks its CI for
|
|
13
|
+
its own token. The token alone decides
|
|
14
|
+
what a request reads and where it writes, never a header or a client
|
|
15
|
+
option.
|
|
16
|
+
|
|
17
|
+
## API
|
|
18
|
+
|
|
19
|
+
- `parseAllow(list)` reads `ALLOW` (space-separated `issuer:owner/repo:workflow` entries, the string the DTE relay reads) into `Allowed`s; `oidcAuth(options)` is the `Authorize` checking a request's CI token against
|
|
20
|
+
them (its options an `OidcOptions`), answering its `Access` or throwing a `Refusal` (`status` and what
|
|
21
|
+
to fix). `oidcTokenAuth(options)` is the check for a server whose
|
|
22
|
+
requests name their repository plainly, in the body (`@vzn/vx-history-server`):
|
|
23
|
+
`(req, named?)`, `named` a `Repo`, answers the token's `Access`, refusing a token of
|
|
24
|
+
another repository than `named`; with no token where `readAccess` is
|
|
25
|
+
`public`, `named` when ALLOW names it, or `undefined` when none is named.
|
|
26
|
+
- `errorJson(status, message)` is every server's refusal, `{ code, message }`
|
|
27
|
+
(Turbo's spec's `Error`); `notAllowed(allow)` its `405`, naming the methods in `Allow`.
|
|
28
|
+
- `hashPart(text)` is what a server stores in place of an issuer, a repository name or a scope: the first
|
|
29
|
+
128 bits of its SHA-256 in hex (Web Crypto, so Bun and Workers agree), never the plain value.
|
|
30
|
+
- `statusLine(tone, name, text)` is the line a server prints when it starts (`'ok'`) or refuses to
|
|
31
|
+
(`'err'`): `✓ vx-cache-server listening on …`, painted as vx's own status lines (NO_COLOR, then
|
|
32
|
+
FORCE_COLOR, then a terminal whose `TERM` is not `dumb`), with any lines after the first dim.
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
export class Refusal extends Error {
|
|
36
|
+
readonly status: number
|
|
37
|
+
}
|
|
38
|
+
export function errorJson(status: number, message: string): Response
|
|
39
|
+
export function notAllowed(allow: string): Response
|
|
40
|
+
export interface Allowed {
|
|
41
|
+
readonly issuer: string
|
|
42
|
+
readonly repository: string
|
|
43
|
+
readonly workflow: string
|
|
44
|
+
}
|
|
45
|
+
export interface Access {
|
|
46
|
+
readonly issuer: string
|
|
47
|
+
readonly repository: string
|
|
48
|
+
readonly write?: string
|
|
49
|
+
}
|
|
50
|
+
export type Authorize = (req: Request) => Promise<Access>
|
|
51
|
+
export interface OidcOptions {
|
|
52
|
+
readonly allow: readonly Allowed[]
|
|
53
|
+
readonly audience?: string
|
|
54
|
+
readonly fetchJson?: (url: string) => Promise<unknown>
|
|
55
|
+
readonly now?: () => number
|
|
56
|
+
readonly readAccess?: string
|
|
57
|
+
}
|
|
58
|
+
export function parseAllow(list: string): Allowed[]
|
|
59
|
+
export function oidcAuth(opts: OidcOptions): Authorize
|
|
60
|
+
export interface Repo {
|
|
61
|
+
readonly issuer: string
|
|
62
|
+
readonly repository: string
|
|
63
|
+
}
|
|
64
|
+
export function oidcTokenAuth(
|
|
65
|
+
opts: OidcOptions,
|
|
66
|
+
): (req: Request, named?: Repo) => Promise<Access | undefined>
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## How it decides
|
|
70
|
+
|
|
71
|
+
- jose checks the JWT: `RS256`, the signature against the issuer's keys
|
|
72
|
+
(found through its `/.well-known/openid-configuration`, kept and
|
|
73
|
+
refetched by `createRemoteJWKSet`), `aud`, `exp` and `nbf` within 60 s.
|
|
74
|
+
- With `oidcAuth`, every request names its repository: `vx-issuer` and `vx-repository`,
|
|
75
|
+
the `hashPart`s of the issuer's URL (no trailing slash) and of its
|
|
76
|
+
`org/name` lowercased; one missing or not a hash is `400`.
|
|
77
|
+
- The token's repository is its issuer (a trailing slash, in `ALLOW` or
|
|
78
|
+
the token, is the same issuer) and its name, `org/name` lowercased: `repository` (GitHub) or `project_path` (GitLab), what a
|
|
79
|
+
checkout's origin names (2026-10-25). One `ALLOW` does not name is
|
|
80
|
+
`403`, and so is one other than the request names.
|
|
81
|
+
- The token's workflow (`job_workflow_ref` on GitHub, `ci_config_ref_uri`
|
|
82
|
+
on GitLab, up to the `@` before its ref) must be one an entry names for that
|
|
83
|
+
repository: any other is `403`.
|
|
84
|
+
- With `readAccess: 'public'` (`READ_ACCESS`), a request with no token
|
|
85
|
+
reads the repository it names when `ALLOW` names it (else `403`), and
|
|
86
|
+
writes nothing. Any other value than `public` or unset throws.
|
|
87
|
+
- A token writes its `ref` (GitLab's `ref_path`): a `pull_request` run
|
|
88
|
+
its merge ref, `refs/pull/<n>/merge`, never its `head_ref`, which a fork
|
|
89
|
+
names; a `merge_group` run its base branch for a
|
|
90
|
+
`refs/heads/gh-readonly-queue/<base>/pr-<n>-<sha>` ref. A
|
|
91
|
+
`pull_request_target`, `issue_comment` or `workflow_run` run writes
|
|
92
|
+
nothing: its `ref` is the base while it may run a pull request's code.
|
|
93
|
+
Nor does a GitLab merge request pipeline unless `source_project_path` (GitLab
|
|
94
|
+
19.5+) names its own project: a fork's runs in the parent project.
|
package/index.ts
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx-devtools",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.0.648",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "Code vx's servers and plugins share, never core: first, the CI OIDC token check.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"vx",
|
|
8
|
+
"monorepo",
|
|
9
|
+
"oidc",
|
|
10
|
+
"ci"
|
|
11
|
+
],
|
|
12
|
+
"type": "module",
|
|
13
|
+
"main": "./src/index.ts",
|
|
14
|
+
"types": "./src/index.ts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./src/index.ts",
|
|
18
|
+
"import": "./src/index.ts"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"index.ts",
|
|
23
|
+
"src",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"engines": {
|
|
28
|
+
"bun": ">=1.4.4"
|
|
29
|
+
},
|
|
30
|
+
"//": [
|
|
31
|
+
"jose: checks each JWT (signature, algorithm, audience, times) and keeps the issuers' key sets.",
|
|
32
|
+
"@vzn/vx: vx.config.ts's core, for the tasks only; the package never imports it."
|
|
33
|
+
],
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"jose": "6.2.13"
|
|
36
|
+
},
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/vznjs/vx.git",
|
|
40
|
+
"directory": "packages/vx-devtools"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/vznjs/vx/tree/main/packages/vx-devtools#readme",
|
|
43
|
+
"bugs": "https://github.com/vznjs/vx/issues"
|
|
44
|
+
}
|
package/src/hash-part.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// What a server stores in place of an issuer, a repository name or a scope
|
|
2
|
+
// (2026-10-25: "issuer and scope should be hashed"): a cache object's
|
|
3
|
+
// name and a history row hold no plain value of a token's.
|
|
4
|
+
|
|
5
|
+
const parts = new Map<string, Promise<string>>()
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* SHA-256's first 128 bits of `text` in hex. Web Crypto, so Bun and
|
|
9
|
+
* Workers name alike; a few strings recur per node, so each is hashed once.
|
|
10
|
+
*/
|
|
11
|
+
export function hashPart(text: string): Promise<string> {
|
|
12
|
+
let got = parts.get(text)
|
|
13
|
+
if (got === undefined) {
|
|
14
|
+
if (parts.size >= 10_000) parts.clear()
|
|
15
|
+
got = crypto.subtle
|
|
16
|
+
.digest('SHA-256', new TextEncoder().encode(text))
|
|
17
|
+
.then((d) =>
|
|
18
|
+
[...new Uint8Array(d, 0, 16)].map((b) => b.toString(16).padStart(2, '0')).join(''),
|
|
19
|
+
)
|
|
20
|
+
parts.set(text, got)
|
|
21
|
+
}
|
|
22
|
+
return got
|
|
23
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// @vzn/vx-devtools: code vx's servers and plugins share, never core. First,
|
|
2
|
+
// the CI token check, and the hash a server stores in place of a token's values. It imports only
|
|
3
|
+
// jose, so a server on Workers bundles it alone.
|
|
4
|
+
|
|
5
|
+
export {
|
|
6
|
+
type Access,
|
|
7
|
+
type Allowed,
|
|
8
|
+
type Authorize,
|
|
9
|
+
oidcAuth,
|
|
10
|
+
oidcTokenAuth,
|
|
11
|
+
type OidcOptions,
|
|
12
|
+
parseAllow,
|
|
13
|
+
type Repo,
|
|
14
|
+
} from './oidc.js'
|
|
15
|
+
export { hashPart } from './hash-part.js'
|
|
16
|
+
export { statusLine } from './terminal.js'
|
|
17
|
+
export { errorJson, notAllowed, Refusal } from './refusal.js'
|
package/src/oidc.ts
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
// CI OIDC tokens, the only tokens vx's servers take (@vzn/vx-cache-server,
|
|
2
|
+
// @vzn/vx-history-server): a job's token names its repository and the one
|
|
3
|
+
// scope it writes, its own branch, never a header or a client option. A
|
|
4
|
+
// cache request names its issuer and repository by their hashes (2026-10-25:
|
|
5
|
+
// the user names them, no client guesses), which a token must match; a
|
|
6
|
+
// history request names them plainly in its body (`oidcTokenAuth`).
|
|
7
|
+
// Who may take part is `ALLOW`, the string @vzn/vx-dte-server reads
|
|
8
|
+
// (2026-10-26: that string and nothing else): `issuer:owner/repo:workflow`
|
|
9
|
+
// entries. `READ_ACCESS=public` lets a request with no token read a
|
|
10
|
+
// repository ALLOW names. Any other repository is refused. The JWT itself is
|
|
11
|
+
// jose's to check: signature, algorithm, audience and times.
|
|
12
|
+
|
|
13
|
+
import { hashPart } from './hash-part.js'
|
|
14
|
+
import { Refusal } from './refusal.js'
|
|
15
|
+
import {
|
|
16
|
+
createRemoteJWKSet,
|
|
17
|
+
customFetch,
|
|
18
|
+
decodeJwt,
|
|
19
|
+
errors,
|
|
20
|
+
jwtVerify,
|
|
21
|
+
type JWTPayload,
|
|
22
|
+
} from 'jose'
|
|
23
|
+
|
|
24
|
+
/** One entry of `ALLOW`: the jobs of one workflow file of one repository at one issuer. */
|
|
25
|
+
export interface Allowed {
|
|
26
|
+
/** The issuer's URL, `https://token.actions.githubusercontent.com`. */
|
|
27
|
+
readonly issuer: string
|
|
28
|
+
/** `owner/repo` (a GitLab project's full path), matched without case. */
|
|
29
|
+
readonly repository: string
|
|
30
|
+
/** The workflow file its jobs are defined in (`ci.yml`, GitLab's `.gitlab-ci.yml`), matched without case. */
|
|
31
|
+
readonly workflow: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What a request may do: read its repository's scopes, and write `write` when set. */
|
|
35
|
+
export interface Access {
|
|
36
|
+
/**
|
|
37
|
+
* The issuer, as its tokens name it (`https://token.actions.githubusercontent.com`):
|
|
38
|
+
* it signs the token, so two platforms' repositories never meet.
|
|
39
|
+
*/
|
|
40
|
+
readonly issuer: string
|
|
41
|
+
/**
|
|
42
|
+
* The repository at that issuer, lowercased: `org/name` (`repository`,
|
|
43
|
+
* GitLab's `project_path`, 2026-10-25), what a checkout's origin names.
|
|
44
|
+
*/
|
|
45
|
+
readonly repository: string
|
|
46
|
+
/** The token's own ref: the one scope it writes. */
|
|
47
|
+
readonly write?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type Authorize = (req: Request) => Promise<Access>
|
|
51
|
+
|
|
52
|
+
const GITHUB = 'https://token.actions.githubusercontent.com'
|
|
53
|
+
|
|
54
|
+
/** The headers naming a request's issuer and repository, each its `hashPart`. */
|
|
55
|
+
const ISSUER_HEADER = 'vx-issuer'
|
|
56
|
+
const REPOSITORY_HEADER = 'vx-repository'
|
|
57
|
+
const PART = /^[0-9a-f]{32}$/
|
|
58
|
+
|
|
59
|
+
/** The claim naming a token's repository, `org/name`. */
|
|
60
|
+
const repoClaimOf = (iss: string): string => (iss === GITHUB ? 'repository' : 'project_path')
|
|
61
|
+
/** The claim naming the workflow a token's job is defined in, up to the `@` before its ref. */
|
|
62
|
+
const workflowClaimOf = (iss: string): string =>
|
|
63
|
+
iss === GITHUB ? 'job_workflow_ref' : 'ci_config_ref_uri'
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* `ALLOW`'s entries, space separated, each
|
|
67
|
+
* `issuer:owner/repo:workflow`, as @vzn/vx-dte-server reads it: read from
|
|
68
|
+
* the right, since the issuer holds `:`s of its own.
|
|
69
|
+
*/
|
|
70
|
+
export function parseAllow(list: string): Allowed[] {
|
|
71
|
+
return list
|
|
72
|
+
.split(/\s+/)
|
|
73
|
+
.filter((e) => e !== '')
|
|
74
|
+
.map((e) => {
|
|
75
|
+
// One grammar on every server: read as a separator nowhere, a comma
|
|
76
|
+
// would end up inside a workflow name no token ever carries.
|
|
77
|
+
if (e.includes(','))
|
|
78
|
+
throw new Error(`${e} is not issuer:owner/repo:workflow: entries are space separated`)
|
|
79
|
+
const w = e.lastIndexOf(':')
|
|
80
|
+
const r = e.lastIndexOf(':', w - 1)
|
|
81
|
+
const [issuer, repository, workflow] = [e.slice(0, r), e.slice(r + 1, w), e.slice(w + 1)]
|
|
82
|
+
// Loopback over plain HTTP too: a server's own tests (its container's)
|
|
83
|
+
// run an issuer beside it, and no one else reaches that address.
|
|
84
|
+
// Its host, not its spelling: `http://127.0.0.1:80@x.example` is x.example.
|
|
85
|
+
const https =
|
|
86
|
+
issuer.startsWith('https://') ||
|
|
87
|
+
(issuer.startsWith('http://') && URL.parse(issuer)?.hostname === '127.0.0.1')
|
|
88
|
+
// An entry missing a part splits elsewhere: its issuer is then no URL.
|
|
89
|
+
const url = /^[a-z][a-z0-9+.-]*:\/\/[^/]/i.test(issuer)
|
|
90
|
+
if (r < 0 || !url || !repository.includes('/') || workflow === '') {
|
|
91
|
+
throw new Error(`${e} is not issuer:owner/repo:workflow`)
|
|
92
|
+
}
|
|
93
|
+
if (!https)
|
|
94
|
+
throw new Error(
|
|
95
|
+
`${e}: the issuer is https://, not ${issuer} (plain http only on 127.0.0.1)`,
|
|
96
|
+
)
|
|
97
|
+
return { issuer, repository, workflow }
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** What `workflowClaimOf(iss)` says, up to the `@` before its ref, for a job of `workflow` in `repository`. */
|
|
102
|
+
function workflowPath(iss: string, repository: string, workflow: string): string {
|
|
103
|
+
return iss === GITHUB
|
|
104
|
+
? `${repository}/.github/workflows/${workflow}`
|
|
105
|
+
: `${iss.replace(/^https?:\/\//, '').replace(/\/$/, '')}/${repository}//${workflow}`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const same = (a: string, b: string): boolean => a.toLowerCase() === b.toLowerCase()
|
|
109
|
+
|
|
110
|
+
/** GitHub's merge queue ref, `refs/heads/gh-readonly-queue/<base>/pr-<n>-<sha>`. */
|
|
111
|
+
const QUEUE = /^refs\/heads\/gh-readonly-queue\/(.+)\/pr-\d+-(?:[0-9a-f]{40}|[0-9a-f]{64})$/
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* GitHub events that run in the base branch's context on a pull request's
|
|
115
|
+
* behalf (design cache-signing-2026-10.md, "Events"): the token carries the
|
|
116
|
+
* base's `ref` while the job may run the pull request's code, a fork's
|
|
117
|
+
* included, which must never write the base.
|
|
118
|
+
*/
|
|
119
|
+
const ON_BEHALF = new Set(['pull_request_target', 'issue_comment', 'workflow_run'])
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The scope a token writes: its `ref`, GitLab's `ref_path`. A GitHub pull
|
|
123
|
+
* request's run writes its merge ref, `refs/pull/<n>/merge`, never its
|
|
124
|
+
* `head_ref`: a fork names that, and no claim tells a fork's from the
|
|
125
|
+
* repository's own, so a fork's branch `release` wrote release. A merge
|
|
126
|
+
* queue's (`merge_group`) run writes its base branch, which its code is
|
|
127
|
+
* about to become (2026-10-25: "just strip it"); only its ref is renamed:
|
|
128
|
+
* anyone can push a branch named like a queue ref. A run of an event in
|
|
129
|
+
* `ON_BEHALF` writes nothing. A GitLab merge request pipeline writes its source branch
|
|
130
|
+
* only when `source_project_path` names its own project: a fork's runs in
|
|
131
|
+
* the parent project with the fork's branch as its `ref_path`, and a
|
|
132
|
+
* GitLab before 19.5 names no source project.
|
|
133
|
+
*/
|
|
134
|
+
function branchOf(iss: string, claims: JWTPayload): string | undefined {
|
|
135
|
+
if (iss !== GITHUB && claims['pipeline_source'] === 'merge_request_event') {
|
|
136
|
+
const own = (claim: string) => {
|
|
137
|
+
const p = claims[claim]
|
|
138
|
+
return typeof p === 'string' && same(p, String(claims['project_path']))
|
|
139
|
+
}
|
|
140
|
+
if (
|
|
141
|
+
!own('source_project_path') ||
|
|
142
|
+
(claims['job_project_path'] !== undefined && !own('job_project_path'))
|
|
143
|
+
)
|
|
144
|
+
return undefined
|
|
145
|
+
}
|
|
146
|
+
const event = claims['event_name']
|
|
147
|
+
if (ON_BEHALF.has(event as string)) return undefined
|
|
148
|
+
const ref = iss === GITHUB ? claims['ref'] : claims['ref_path']
|
|
149
|
+
if (typeof ref !== 'string' || !ref.startsWith('refs/') || ref.length > 512) return undefined
|
|
150
|
+
const queued = iss === GITHUB && event === 'merge_group' ? QUEUE.exec(ref) : null
|
|
151
|
+
return queued === null ? ref : `refs/heads/${queued[1]}`
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface OidcOptions {
|
|
155
|
+
/** `ALLOW` (`parseAllow`): who may take part. */
|
|
156
|
+
readonly allow: readonly Allowed[]
|
|
157
|
+
/** The `aud` a token must name; unset, the origin the server is reached at. */
|
|
158
|
+
readonly audience?: string
|
|
159
|
+
/** Reads a URL as JSON; tests serve keys without a network. */
|
|
160
|
+
readonly fetchJson?: (url: string) => Promise<unknown>
|
|
161
|
+
/** Seconds since the epoch; tests move the clock. */
|
|
162
|
+
readonly now?: () => number
|
|
163
|
+
/** `READ_ACCESS`: `public` lets a request with no token read a repository ALLOW names. */
|
|
164
|
+
readonly readAccess?: string | undefined
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Clock skew a token's `exp` / `nbf` may be off by. */
|
|
168
|
+
const LEEWAY_S = 60
|
|
169
|
+
|
|
170
|
+
/** The `Access` a request's verified CI token has; a refused one throws a 401 or 403. */
|
|
171
|
+
export function oidcAuth(opts: OidcOptions): Authorize {
|
|
172
|
+
const open = readAccessOf(opts)
|
|
173
|
+
// The repositories ALLOW names, by their hashes: what a public read may name.
|
|
174
|
+
let named: Promise<Map<string, Access>> | undefined
|
|
175
|
+
const allowed = () =>
|
|
176
|
+
(named ??= Promise.all(
|
|
177
|
+
opts.allow.map(async (a) => {
|
|
178
|
+
const repo = { issuer: issuerOf(a.issuer), repository: a.repository.toLowerCase() }
|
|
179
|
+
return [`${await hashPart(repo.issuer)}/${await hashPart(repo.repository)}`, repo] as [
|
|
180
|
+
string,
|
|
181
|
+
Access,
|
|
182
|
+
]
|
|
183
|
+
}),
|
|
184
|
+
).then((pairs) => new Map(pairs)))
|
|
185
|
+
const verify = tokenVerifier(opts)
|
|
186
|
+
|
|
187
|
+
return async (req) => {
|
|
188
|
+
if (opts.allow.length === 0)
|
|
189
|
+
throw new Refusal(401, 'ALLOW is empty: set it to the CI workflows this server serves')
|
|
190
|
+
const asked = `${req.headers.get(ISSUER_HEADER)}/${req.headers.get(REPOSITORY_HEADER)}`
|
|
191
|
+
if (!asked.split('/').every((p) => PART.test(p)))
|
|
192
|
+
throw new Refusal(
|
|
193
|
+
400,
|
|
194
|
+
`${ISSUER_HEADER} and ${REPOSITORY_HEADER}: the issuer's and the repository's hashes (PROTOCOL.md § Keys)`,
|
|
195
|
+
)
|
|
196
|
+
const bearer = bearerOf(req)
|
|
197
|
+
if (bearer === undefined) {
|
|
198
|
+
if (!open) throw new Refusal(401, 'no bearer token')
|
|
199
|
+
const repo = (await allowed()).get(asked)
|
|
200
|
+
if (repo === undefined)
|
|
201
|
+
throw new Refusal(403, "the repository named is not in this server's ALLOW")
|
|
202
|
+
return repo
|
|
203
|
+
}
|
|
204
|
+
const access = await verify(bearer, req)
|
|
205
|
+
if (`${await hashPart(access.issuer)}/${await hashPart(access.repository)}` !== asked)
|
|
206
|
+
throw new Refusal(
|
|
207
|
+
403,
|
|
208
|
+
`the token is of ${repoClaimOf(access.issuer)} ${access.repository} at ${access.issuer}, not the repository named`,
|
|
209
|
+
)
|
|
210
|
+
return access
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** A repository as a request names it plainly: the issuer's URL and `org/name` there. */
|
|
215
|
+
export interface Repo {
|
|
216
|
+
readonly issuer: string
|
|
217
|
+
readonly repository: string
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* A server whose requests name their repository plainly, in the body
|
|
222
|
+
* (@vzn/vx-history-server). A token must be of `named`, when one is named;
|
|
223
|
+
* with no token, `READ_ACCESS=public` reads a repository ALLOW names, and
|
|
224
|
+
* answers `undefined` where none is named. Else a 401 or 403.
|
|
225
|
+
*/
|
|
226
|
+
export function oidcTokenAuth(
|
|
227
|
+
opts: OidcOptions,
|
|
228
|
+
): (req: Request, named?: Repo) => Promise<Access | undefined> {
|
|
229
|
+
const open = readAccessOf(opts)
|
|
230
|
+
const check = tokenVerifier(opts)
|
|
231
|
+
// A request is asked twice, before its body names the repository and
|
|
232
|
+
// after: its token is verified once.
|
|
233
|
+
const verified = new WeakMap<Request, Promise<Access>>()
|
|
234
|
+
const verify = (token: string, req: Request): Promise<Access> => {
|
|
235
|
+
let got = verified.get(req)
|
|
236
|
+
if (got === undefined) verified.set(req, (got = check(token, req)))
|
|
237
|
+
return got
|
|
238
|
+
}
|
|
239
|
+
return async (req, asked) => {
|
|
240
|
+
if (opts.allow.length === 0)
|
|
241
|
+
throw new Refusal(401, 'ALLOW is empty: set it to the CI workflows this server serves')
|
|
242
|
+
const named = asked === undefined ? undefined : repoOf(asked)
|
|
243
|
+
const bearer = bearerOf(req)
|
|
244
|
+
if (bearer === undefined) {
|
|
245
|
+
if (!open) throw new Refusal(401, 'no bearer token')
|
|
246
|
+
if (named === undefined) return undefined
|
|
247
|
+
if (
|
|
248
|
+
!opts.allow.some(
|
|
249
|
+
(a) => issuerOf(a.issuer) === named.issuer && same(a.repository, named.repository),
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
throw new Refusal(403, `${named.repository} is not in this server's ALLOW`)
|
|
253
|
+
return named
|
|
254
|
+
}
|
|
255
|
+
const access = await verify(bearer, req)
|
|
256
|
+
if (
|
|
257
|
+
named !== undefined &&
|
|
258
|
+
(access.issuer !== named.issuer || access.repository !== named.repository)
|
|
259
|
+
)
|
|
260
|
+
throw new Refusal(
|
|
261
|
+
403,
|
|
262
|
+
`the token is of ${access.repository} at ${access.issuer}, not ${named.repository} at ${named.issuer}`,
|
|
263
|
+
)
|
|
264
|
+
return access
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** A named repository as a token names it: the issuer's URL with no trailing slash, `org/name` lowercased. */
|
|
269
|
+
const repoOf = (r: Repo): Repo => ({
|
|
270
|
+
issuer: issuerOf(r.issuer),
|
|
271
|
+
repository: r.repository.toLowerCase(),
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
function readAccessOf(opts: OidcOptions): boolean {
|
|
275
|
+
const given: unknown = opts.readAccess
|
|
276
|
+
// A Worker var may be JSON: a list was refused "not public".
|
|
277
|
+
if (given !== undefined && given !== '' && given !== 'public')
|
|
278
|
+
throw new Error(
|
|
279
|
+
`READ_ACCESS: public, or unset, not ${typeof given === 'string' ? given : JSON.stringify(given)}`,
|
|
280
|
+
)
|
|
281
|
+
return opts.readAccess === 'public'
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const bearerOf = (req: Request): string | undefined =>
|
|
285
|
+
/^Bearer\s+(\S+)$/i.exec(req.headers.get('authorization') ?? '')?.[1]
|
|
286
|
+
|
|
287
|
+
/** A token checked: jose's checks, then ALLOW's repository and workflow, and the branch it may write. */
|
|
288
|
+
function tokenVerifier(opts: OidcOptions): (token: string, req: Request) => Promise<Access> {
|
|
289
|
+
// A blank one is no origin a token names: every token was "wrong audience".
|
|
290
|
+
if (opts.audience !== undefined && opts.audience.trim() === '')
|
|
291
|
+
throw new Error('AUDIENCE: the aud CI tokens name, or unset, not blank')
|
|
292
|
+
const fetchJson = opts.fetchJson ?? defaultFetchJson
|
|
293
|
+
const now = opts.now ?? (() => Date.now() / 1000)
|
|
294
|
+
// jose keeps each issuer's keys, and refetches them for a key it lacks.
|
|
295
|
+
const keys = new Map<string, Promise<ReturnType<typeof createRemoteJWKSet>>>()
|
|
296
|
+
const keysOf = (iss: string) => {
|
|
297
|
+
let set = keys.get(iss)
|
|
298
|
+
if (set === undefined) {
|
|
299
|
+
set = fetchJson(`${iss.replace(/\/$/, '')}/.well-known/openid-configuration`).then((c) => {
|
|
300
|
+
const uri = (c as { jwks_uri?: unknown }).jwks_uri
|
|
301
|
+
if (typeof uri !== 'string') throw new Error('no jwks_uri')
|
|
302
|
+
return createRemoteJWKSet(new URL(uri), {
|
|
303
|
+
[customFetch]: async (url: string) => Response.json(await fetchJson(url)),
|
|
304
|
+
})
|
|
305
|
+
})
|
|
306
|
+
keys.set(iss, set)
|
|
307
|
+
// A failed discovery is not kept: the next token tries again.
|
|
308
|
+
// quiet: the awaited set rethrows it; this only forgets it
|
|
309
|
+
set.catch(() => keys.get(iss) === set && keys.delete(iss))
|
|
310
|
+
}
|
|
311
|
+
// The issuer's outage, not the token's fault: a 401 tells a client its
|
|
312
|
+
// token is refused, and vx-cache turns the remote off for the run.
|
|
313
|
+
return set.catch((err: unknown) => {
|
|
314
|
+
throw keysUnread(iss, err)
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return async (token, req) => {
|
|
319
|
+
let said: unknown
|
|
320
|
+
try {
|
|
321
|
+
said = decodeJwt(token).iss
|
|
322
|
+
} catch {
|
|
323
|
+
throw new Refusal(401, 'not a JWT')
|
|
324
|
+
}
|
|
325
|
+
// A trailing slash is no other issuer, as a key and a tokenless read
|
|
326
|
+
// name it: one in ALLOW or in the token refused every token.
|
|
327
|
+
const iss = typeof said === 'string' ? issuerOf(said) : undefined
|
|
328
|
+
if (iss === undefined || !opts.allow.some((a) => issuerOf(a.issuer) === iss)) {
|
|
329
|
+
throw new Refusal(
|
|
330
|
+
401,
|
|
331
|
+
typeof said === 'string'
|
|
332
|
+
? `issuer ${said.slice(0, 200)} is not in this server's ALLOW: add issuer:owner/repo:workflow for it`
|
|
333
|
+
: 'the token names no issuer (iss)',
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
const audience = opts.audience ?? new URL(req.url).origin
|
|
337
|
+
let claims: JWTPayload
|
|
338
|
+
try {
|
|
339
|
+
claims = (
|
|
340
|
+
await jwtVerify(token, await keysOf(iss), {
|
|
341
|
+
algorithms: ['RS256'],
|
|
342
|
+
audience,
|
|
343
|
+
clockTolerance: LEEWAY_S,
|
|
344
|
+
currentDate: new Date(now() * 1000),
|
|
345
|
+
requiredClaims: ['exp'],
|
|
346
|
+
})
|
|
347
|
+
).payload
|
|
348
|
+
} catch (err) {
|
|
349
|
+
throw refusal(err, iss, audience)
|
|
350
|
+
}
|
|
351
|
+
const claim = repoClaimOf(iss)
|
|
352
|
+
const named = claims[claim]
|
|
353
|
+
if (typeof named !== 'string') throw new Refusal(403, `no ${claim} claim`)
|
|
354
|
+
// GitHub and GitLab names ignore case; a checkout's origin may spell it either way.
|
|
355
|
+
const repository = named.toLowerCase()
|
|
356
|
+
const ofRepo = opts.allow.filter(
|
|
357
|
+
(a) => issuerOf(a.issuer) === iss && same(a.repository, repository),
|
|
358
|
+
)
|
|
359
|
+
if (ofRepo.length === 0)
|
|
360
|
+
throw new Refusal(403, `${claim} ${repository} is not in this server's ALLOW`)
|
|
361
|
+
const wclaim = workflowClaimOf(iss)
|
|
362
|
+
const ran = claims[wclaim]
|
|
363
|
+
if (typeof ran !== 'string') throw new Refusal(403, `no ${wclaim} claim`)
|
|
364
|
+
// Cut at the `@` before the ref: GitLab's CI file may itself hold one
|
|
365
|
+
// (`.gitlab-ci.yml@group/project`, a file kept in another project).
|
|
366
|
+
const workflow = ran.replace(/@(?:refs\/.*)?$/, '')
|
|
367
|
+
if (!ofRepo.some((a) => same(workflow, workflowPath(iss, a.repository, a.workflow))))
|
|
368
|
+
throw new Refusal(403, `${wclaim} ${workflow} is not in this server's ALLOW`)
|
|
369
|
+
const repo = { issuer: iss, repository }
|
|
370
|
+
const branch = branchOf(iss, claims)
|
|
371
|
+
return branch === undefined ? repo : { ...repo, write: branch }
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** An issuer as a key names it: its URL, no trailing slash. */
|
|
376
|
+
const issuerOf = (iss: string): string => iss.replace(/\/$/, '')
|
|
377
|
+
|
|
378
|
+
/** What jose's refusal of a token says. */
|
|
379
|
+
function refusal(err: unknown, iss: string, audience: string): Refusal {
|
|
380
|
+
if (err instanceof Refusal) return err
|
|
381
|
+
// A refetch of the keys for a kid not held failed, or the issuer served
|
|
382
|
+
// no key set, or one with two keys for a kid: the issuer, not the token.
|
|
383
|
+
// A broken set read "not a JWT".
|
|
384
|
+
if (
|
|
385
|
+
!(err instanceof errors.JOSEError) ||
|
|
386
|
+
err instanceof errors.JWKSTimeout ||
|
|
387
|
+
err instanceof errors.JWKSInvalid ||
|
|
388
|
+
err instanceof errors.JWKSMultipleMatchingKeys
|
|
389
|
+
)
|
|
390
|
+
return keysUnread(iss, err)
|
|
391
|
+
if (err instanceof errors.JOSEAlgNotAllowed) return new Refusal(401, 'alg is not RS256')
|
|
392
|
+
if (err instanceof errors.JWKSNoMatchingKey) return new Refusal(401, 'unknown signing key')
|
|
393
|
+
if (err instanceof errors.JWSSignatureVerificationFailed) return new Refusal(401, 'bad signature')
|
|
394
|
+
if (err instanceof errors.JWTExpired) return new Refusal(401, 'expired')
|
|
395
|
+
if (err instanceof errors.JWTClaimValidationFailed) {
|
|
396
|
+
if (err.claim === 'aud')
|
|
397
|
+
return new Refusal(
|
|
398
|
+
401,
|
|
399
|
+
`the token's aud is ${audOf(err.payload)}, not this server's ${audience}: ask CI for a token for ${audience}, or set the server's AUDIENCE`,
|
|
400
|
+
)
|
|
401
|
+
if (err.claim === 'nbf') return new Refusal(401, 'not yet valid')
|
|
402
|
+
if (err.reason === 'missing') return new Refusal(401, `no ${err.claim} claim`)
|
|
403
|
+
}
|
|
404
|
+
return new Refusal(401, 'not a JWT')
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** A token's `aud`, as a refusal names it: "wrong audience" alone left the fix to a guess. */
|
|
408
|
+
const audOf = (claims: JWTPayload): string => [claims.aud ?? 'unset'].flat().join(', ')
|
|
409
|
+
/** The issuer's keys out of reach, with why: a 503 and a missing jwks_uri read the same. */
|
|
410
|
+
const keysUnread = (iss: string, err: unknown): Refusal =>
|
|
411
|
+
new Refusal(
|
|
412
|
+
502,
|
|
413
|
+
`could not read ${iss}'s keys: ${err instanceof Error ? err.message : String(err)}`,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
async function defaultFetchJson(url: string): Promise<unknown> {
|
|
417
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) })
|
|
418
|
+
if (!res.ok) throw new Error(`${url}: ${res.status}`)
|
|
419
|
+
return res.json()
|
|
420
|
+
}
|
package/src/refusal.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// How vx's servers refuse a request: one body, `{ code, message }`, the
|
|
2
|
+
// shape of Turbo's spec's `Error`, so a client reads every server's
|
|
3
|
+
// refusal the same way and warns with its message.
|
|
4
|
+
|
|
5
|
+
/** A request refused: its HTTP status and what to fix. */
|
|
6
|
+
export class Refusal extends Error {
|
|
7
|
+
constructor(
|
|
8
|
+
readonly status: number,
|
|
9
|
+
message: string,
|
|
10
|
+
) {
|
|
11
|
+
super(message)
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const CODES: Record<number, string> = {
|
|
16
|
+
400: 'bad_request',
|
|
17
|
+
401: 'unauthorized',
|
|
18
|
+
403: 'forbidden',
|
|
19
|
+
404: 'not_found',
|
|
20
|
+
405: 'method_not_allowed',
|
|
21
|
+
413: 'payload_too_large',
|
|
22
|
+
500: 'internal_error',
|
|
23
|
+
502: 'bad_gateway',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A refusal's answer: `{ code, message }`, the message saying what to fix. */
|
|
27
|
+
export function errorJson(status: number, message: string): Response {
|
|
28
|
+
return Response.json({ code: CODES[status] ?? 'error', message }, { status })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A route's wrong method: `405`, naming the ones it takes in `Allow` and the message. */
|
|
32
|
+
export function notAllowed(allow: string): Response {
|
|
33
|
+
const res = errorJson(405, `this route takes ${allow}`)
|
|
34
|
+
res.headers.set('allow', allow)
|
|
35
|
+
return res
|
|
36
|
+
}
|
package/src/terminal.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// The one line a server prints at start or when it refuses to start,
|
|
2
|
+
// painted as vx's own status lines are: a server's log is read beside
|
|
3
|
+
// vx's, and plain white read as noise there.
|
|
4
|
+
|
|
5
|
+
const HUE = { ok: '34;197;94', err: '239;68;68' } as const
|
|
6
|
+
const SYMBOL = { ok: '✓', err: '✗' } as const
|
|
7
|
+
|
|
8
|
+
/** Colour as vx decides it: NO_COLOR, then FORCE_COLOR, then a terminal. */
|
|
9
|
+
function paints(stream: { isTTY?: boolean }): boolean {
|
|
10
|
+
const env = process.env
|
|
11
|
+
if (env['NO_COLOR']) return false
|
|
12
|
+
const force = env['FORCE_COLOR']
|
|
13
|
+
if (force !== undefined) return force !== '0' && force !== 'false'
|
|
14
|
+
return stream.isTTY === true && env['TERM'] !== 'dumb'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `✓ vx-cache-server listening on http://…` or `✗ vx-cache-server <why>`.
|
|
19
|
+
* Lines after the first (a usage text) print dim under it.
|
|
20
|
+
*/
|
|
21
|
+
export function statusLine(
|
|
22
|
+
tone: 'ok' | 'err',
|
|
23
|
+
name: string,
|
|
24
|
+
text: string,
|
|
25
|
+
stream: { isTTY?: boolean } = tone === 'ok' ? process.stdout : process.stderr,
|
|
26
|
+
): string {
|
|
27
|
+
const [head = '', ...rest] = text.split('\n')
|
|
28
|
+
if (!paints(stream)) return `${[`${SYMBOL[tone]} ${name} ${head}`, ...rest].join('\n')}\n`
|
|
29
|
+
const url = head.replace(/(https?:\/\/\S+)/g, '\x1b[38;2;6;182;212m$1\x1b[0m')
|
|
30
|
+
const first = `\x1b[38;2;${HUE[tone]}m${SYMBOL[tone]}\x1b[0m \x1b[1m${name}\x1b[0m ${url}`
|
|
31
|
+
return `${[first, ...rest.map((l) => (l === '' ? l : `\x1b[2m${l}\x1b[0m`))].join('\n')}\n`
|
|
32
|
+
}
|