@coffre/core 0.5.1 → 0.6.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/dist/{access-CwsFLfTz.d.ts → access-BpcBLDW9.d.ts} +16 -8
- package/dist/{access-pnJ-u4x2.js → access-DvwZzqRR.js} +17 -12
- package/dist/access.d.ts +2 -2
- package/dist/access.js +2 -2
- package/dist/identity.d.ts +146 -3
- package/dist/identity.js +386 -10
- package/dist/schemas.d.ts +3 -3
- package/dist/schemas.js +1 -1
- package/dist/vault.d.ts +56 -23
- package/dist/workloads.d.ts +23 -6
- package/dist/workloads.js +42 -10
- package/package.json +2 -2
- package/src/access-conversion.ts +1 -1
- package/src/access.ts +18 -12
- package/src/identity/github-app.ts +347 -0
- package/src/identity/index.ts +1 -0
- package/src/identity/signin/config.ts +66 -13
- package/src/identity/signin/github.ts +17 -5
- package/src/identity/workloads.ts +45 -8
- package/src/vault.ts +54 -18
package/dist/identity.js
CHANGED
|
@@ -2,6 +2,328 @@ import { BindingInvalid, EVENT_EXPOSURE, GITHUB_EVENTS, GITHUB_ISSUER, GITLAB_IS
|
|
|
2
2
|
import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes } from "node:crypto";
|
|
3
3
|
import * as oauth from "oauth4webapi";
|
|
4
4
|
import { createLocalJWKSet, createRemoteJWKSet, errors, jwtVerify } from "jose";
|
|
5
|
+
//#region src/identity/github-app.ts
|
|
6
|
+
/** GitHub could not be asked, or refused the app: the message says which, for a person, and carries no credential. */
|
|
7
|
+
var GitHubAppUnavailable = class extends Error {
|
|
8
|
+
constructor(message, options) {
|
|
9
|
+
super(message, options);
|
|
10
|
+
this.name = "GitHubAppUnavailable";
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
/** GitHub answers 100 per page at most. */
|
|
14
|
+
const PAGE = 100;
|
|
15
|
+
/** Requests one listing makes at most, tokens included: well under a Worker's subrequest limit. */
|
|
16
|
+
const MAX_LISTING_REQUESTS = 40;
|
|
17
|
+
/** A token GitHub issues for an hour is used until this long before it ends, so that none expires mid-call. */
|
|
18
|
+
const TOKEN_MARGIN_MS = 3e5;
|
|
19
|
+
/** Each request, from DNS to the last byte. */
|
|
20
|
+
const DEADLINE_MS = 1e4;
|
|
21
|
+
var GitHubApp = class {
|
|
22
|
+
appId;
|
|
23
|
+
webUrl;
|
|
24
|
+
apiUrl;
|
|
25
|
+
/** Its OAuth client's ID, which is no secret; null when it was given none. */
|
|
26
|
+
clientId;
|
|
27
|
+
#clientSecret;
|
|
28
|
+
#pkcs8;
|
|
29
|
+
#fetch;
|
|
30
|
+
#now;
|
|
31
|
+
#key = null;
|
|
32
|
+
#app = null;
|
|
33
|
+
#tokens = /* @__PURE__ */ new Map();
|
|
34
|
+
constructor(options, io = {}) {
|
|
35
|
+
this.appId = options.appId;
|
|
36
|
+
this.webUrl = options.webUrl ?? "https://github.com";
|
|
37
|
+
this.apiUrl = options.apiUrl;
|
|
38
|
+
this.clientId = options.client?.clientId ?? null;
|
|
39
|
+
this.#clientSecret = options.client?.clientSecret ?? null;
|
|
40
|
+
this.#pkcs8 = options.pkcs8;
|
|
41
|
+
this.#fetch = io.fetch ?? ((input, init) => fetch(input, init));
|
|
42
|
+
this.#now = io.now ?? Date.now;
|
|
43
|
+
}
|
|
44
|
+
/** Its OAuth client, for `github({ app })`; null when it was given none. */
|
|
45
|
+
signinClient() {
|
|
46
|
+
return this.clientId === null || this.#clientSecret === null ? null : {
|
|
47
|
+
clientId: this.clientId,
|
|
48
|
+
clientSecret: this.#clientSecret
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** Every repository of every account the app is installed on, as many as `MAX_LISTING_REQUESTS` reach. */
|
|
52
|
+
async repositories() {
|
|
53
|
+
const budget = { left: 40 };
|
|
54
|
+
this.#app ??= await this.#appInfo(budget);
|
|
55
|
+
const { installations, more } = await this.#installations(budget);
|
|
56
|
+
let truncated = more;
|
|
57
|
+
/** An installation's repositories, its first page paid from `own`, and the rest from what the listing has left. */
|
|
58
|
+
const list = async (installation, own) => {
|
|
59
|
+
const repositories = [];
|
|
60
|
+
try {
|
|
61
|
+
for (let page = 1;; page++) {
|
|
62
|
+
const body = record(await this.#asInstallation(installation.id, `/installation/repositories?per_page=${PAGE}&page=${page}`, { budget: own }));
|
|
63
|
+
const items = Array.isArray(body.repositories) ? body.repositories : [];
|
|
64
|
+
repositories.push(...items.flatMap((item) => repository(item) ?? []));
|
|
65
|
+
const total = typeof body.total_count === "number" ? body.total_count : 0;
|
|
66
|
+
if (items.length < PAGE || page * PAGE >= total) return repositories;
|
|
67
|
+
}
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error !== SPENT) throw error;
|
|
70
|
+
truncated = true;
|
|
71
|
+
return repositories;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
const listed = [];
|
|
75
|
+
for (let rest = installations; rest.length > 0;) {
|
|
76
|
+
const wave = [];
|
|
77
|
+
for (const installation of rest) {
|
|
78
|
+
const worst = this.#kept(installation.id) === null ? 2 : 3;
|
|
79
|
+
if (budget.left < worst) break;
|
|
80
|
+
budget.left -= worst;
|
|
81
|
+
wave.push({
|
|
82
|
+
installation,
|
|
83
|
+
own: {
|
|
84
|
+
left: worst,
|
|
85
|
+
then: budget
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
if (wave.length === 0) {
|
|
90
|
+
truncated = true;
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
rest = rest.slice(wave.length);
|
|
94
|
+
listed.push(...await Promise.all(wave.map(({ installation, own }) => list(installation, own))));
|
|
95
|
+
for (const { own } of wave) budget.left += own.left;
|
|
96
|
+
}
|
|
97
|
+
const byId = new Map(listed.flat().map((each) => [each.repositoryId, each]));
|
|
98
|
+
return {
|
|
99
|
+
app: this.#app,
|
|
100
|
+
installations: installations.map(({ account }) => account),
|
|
101
|
+
repositories: [...byId.values()].sort((a, b) => a.github.localeCompare(b.github)),
|
|
102
|
+
truncated
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/** `owner/name`'s IDs, when the app is installed on it; null when it is not, its installation is suspended, or GitHub knows no such repository. */
|
|
106
|
+
async repository(fullName) {
|
|
107
|
+
const { id, suspended_at: suspended } = record(await this.#asApp(`/repos/${fullName}/installation`, { missing: true }));
|
|
108
|
+
if (typeof id !== "number" || suspended !== null && suspended !== void 0) return null;
|
|
109
|
+
return repository(await this.#asInstallation(id, `/repos/${fullName}`, { missing: true }));
|
|
110
|
+
}
|
|
111
|
+
async #appInfo(budget) {
|
|
112
|
+
const app = record(await this.#asApp("/app", { budget }));
|
|
113
|
+
if (typeof app.name !== "string" || typeof app.html_url !== "string") throw new GitHubAppUnavailable("GitHub answered /app without the app");
|
|
114
|
+
return {
|
|
115
|
+
name: app.name,
|
|
116
|
+
installUrl: `${app.html_url}/installations/new`
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/** The installations not suspended, of the first page: `more` when there may be others. */
|
|
120
|
+
async #installations(budget) {
|
|
121
|
+
const answer = await this.#asApp(`/app/installations?per_page=${PAGE}`, { budget });
|
|
122
|
+
const items = Array.isArray(answer) ? answer : [];
|
|
123
|
+
return {
|
|
124
|
+
installations: items.flatMap((item) => {
|
|
125
|
+
const { id, account, suspended_at: suspended } = record(item);
|
|
126
|
+
const login = record(account).login;
|
|
127
|
+
return typeof id === "number" && typeof login === "string" && (suspended === null || suspended === void 0) ? [{
|
|
128
|
+
id,
|
|
129
|
+
account: login
|
|
130
|
+
}] : [];
|
|
131
|
+
}).sort((a, b) => a.account.localeCompare(b.account)),
|
|
132
|
+
more: items.length >= PAGE
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
async #asApp(path, options = {}) {
|
|
136
|
+
return this.#call(path, {
|
|
137
|
+
method: "GET",
|
|
138
|
+
token: await this.#jwt(),
|
|
139
|
+
...options
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
/** As the installation, with its token; once more with a fresh one should GitHub refuse the one kept (the app was reinstalled, say). */
|
|
143
|
+
async #asInstallation(id, path, options = {}) {
|
|
144
|
+
const kept = this.#kept(id);
|
|
145
|
+
const fresh = kept === null;
|
|
146
|
+
const token = kept ?? await this.#issueToken(id, options.budget);
|
|
147
|
+
const answer = await this.#call(path, {
|
|
148
|
+
method: "GET",
|
|
149
|
+
token,
|
|
150
|
+
...options,
|
|
151
|
+
refused: !fresh
|
|
152
|
+
});
|
|
153
|
+
if (answer !== REFUSED) return answer;
|
|
154
|
+
return this.#call(path, {
|
|
155
|
+
method: "GET",
|
|
156
|
+
token: await this.#issueToken(id, options.budget),
|
|
157
|
+
...options
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
/** The installation's token, while it has more than `TOKEN_MARGIN_MS` to live. */
|
|
161
|
+
#kept(id) {
|
|
162
|
+
const kept = this.#tokens.get(id);
|
|
163
|
+
return kept !== void 0 && kept.expiresAt - 3e5 > this.#now() ? kept.token : null;
|
|
164
|
+
}
|
|
165
|
+
/** https://docs.github.com/en/rest/apps/apps#create-an-installation-access-token-for-an-app */
|
|
166
|
+
async #issueToken(id, budget) {
|
|
167
|
+
this.#tokens.delete(id);
|
|
168
|
+
const answer = record(await this.#call(`/app/installations/${id}/access_tokens`, {
|
|
169
|
+
method: "POST",
|
|
170
|
+
token: await this.#jwt(),
|
|
171
|
+
budget
|
|
172
|
+
}));
|
|
173
|
+
const expiresAt = typeof answer.expires_at === "string" ? Date.parse(answer.expires_at) : NaN;
|
|
174
|
+
if (typeof answer.token !== "string" || !Number.isFinite(expiresAt)) throw new GitHubAppUnavailable("GitHub issued no installation token");
|
|
175
|
+
this.#tokens.set(id, {
|
|
176
|
+
token: answer.token,
|
|
177
|
+
expiresAt
|
|
178
|
+
});
|
|
179
|
+
return answer.token;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* The app's JWT: RS256 over its ID, for nine minutes, issued a minute
|
|
183
|
+
* back for a clock running ahead (GitHub refuses more than ten).
|
|
184
|
+
* https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app
|
|
185
|
+
*/
|
|
186
|
+
async #jwt() {
|
|
187
|
+
const key = this.#key ?? (this.#key = await importKey(this.#pkcs8));
|
|
188
|
+
const now = Math.floor(this.#now() / 1e3);
|
|
189
|
+
const input = `${base64url(JSON.stringify({
|
|
190
|
+
alg: "RS256",
|
|
191
|
+
typ: "JWT"
|
|
192
|
+
}))}.${base64url(JSON.stringify({
|
|
193
|
+
iat: now - 60,
|
|
194
|
+
exp: now + 540,
|
|
195
|
+
iss: this.appId
|
|
196
|
+
}))}`;
|
|
197
|
+
const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, new TextEncoder().encode(input));
|
|
198
|
+
return `${input}.${base64url(new Uint8Array(signature))}`;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* One request, spent from `budget` when it has one, and thrown as
|
|
202
|
+
* `SPENT` when none is left. A 404 is null where `missing` allows it; a
|
|
203
|
+
* 401 is `REFUSED` where `refused` asks, for a token to be issued again;
|
|
204
|
+
* any other answer than a 2xx says GitHub's status and the path, and a
|
|
205
|
+
* redirect is never followed.
|
|
206
|
+
*/
|
|
207
|
+
async #call(path, request) {
|
|
208
|
+
if (request.budget !== void 0 && !spend(request.budget)) throw SPENT;
|
|
209
|
+
let response;
|
|
210
|
+
try {
|
|
211
|
+
response = await this.#fetch(`${this.apiUrl}${path}`, {
|
|
212
|
+
method: request.method,
|
|
213
|
+
redirect: "manual",
|
|
214
|
+
signal: AbortSignal.timeout(DEADLINE_MS),
|
|
215
|
+
headers: {
|
|
216
|
+
accept: "application/vnd.github+json",
|
|
217
|
+
authorization: `Bearer ${request.token}`,
|
|
218
|
+
"user-agent": "coffre",
|
|
219
|
+
"x-github-api-version": "2022-11-28"
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
} catch (cause) {
|
|
223
|
+
throw new GitHubAppUnavailable("GitHub could not be reached", { cause });
|
|
224
|
+
}
|
|
225
|
+
if (response.ok) try {
|
|
226
|
+
return await response.json();
|
|
227
|
+
} catch {
|
|
228
|
+
throw new GitHubAppUnavailable(`GitHub answered ${path.split("?")[0]} with no JSON`);
|
|
229
|
+
}
|
|
230
|
+
await response.body?.cancel();
|
|
231
|
+
if (response.status === 404 && request.missing === true) return null;
|
|
232
|
+
if (response.status === 401 && request.refused === true) return REFUSED;
|
|
233
|
+
if (response.status === 401) throw new GitHubAppUnavailable(`GitHub refused the app (401): check its app ID and private key`);
|
|
234
|
+
throw new GitHubAppUnavailable(`GitHub answered ${path.split("?")[0]} with ${response.status}`);
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
/** A kept token GitHub refused. */
|
|
238
|
+
const REFUSED = Symbol("refused");
|
|
239
|
+
function spend(budget) {
|
|
240
|
+
for (let at = budget; at !== void 0; at = at.then) if (at.left > 0) {
|
|
241
|
+
at.left--;
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
/** None left: the listing stops there, and says it is partial. */
|
|
247
|
+
const SPENT = Symbol("spent");
|
|
248
|
+
/**
|
|
249
|
+
* A private key's PEM as PKCS #8, which Web Crypto imports: GitHub's
|
|
250
|
+
* download is PKCS #1 (`BEGIN RSA PRIVATE KEY`), wrapped here as an RSA
|
|
251
|
+
* key's PKCS #8. Escaped newlines, as an environment variable sometimes
|
|
252
|
+
* holds them, are newlines. Never says what the key was.
|
|
253
|
+
*/
|
|
254
|
+
function pkcs8Of(pem) {
|
|
255
|
+
const text = typeof pem === "string" ? pem.replaceAll("\\n", "\n").trim() : "";
|
|
256
|
+
const match = /^-----BEGIN (RSA )?PRIVATE KEY-----\s*([A-Za-z0-9+/=\s]+?)\s*-----END \1PRIVATE KEY-----$/.exec(text);
|
|
257
|
+
if (match === null) throw new Error("githubApp(…).privateKey must be the app's private key, the PEM file GitHub downloads (BEGIN RSA PRIVATE KEY)");
|
|
258
|
+
const der = new Uint8Array(Buffer.from(match[2].replace(/\s+/g, ""), "base64"));
|
|
259
|
+
if (match[1] === void 0) return der;
|
|
260
|
+
const algorithm = [
|
|
261
|
+
2,
|
|
262
|
+
1,
|
|
263
|
+
0,
|
|
264
|
+
48,
|
|
265
|
+
13,
|
|
266
|
+
6,
|
|
267
|
+
9,
|
|
268
|
+
42,
|
|
269
|
+
134,
|
|
270
|
+
72,
|
|
271
|
+
134,
|
|
272
|
+
247,
|
|
273
|
+
13,
|
|
274
|
+
1,
|
|
275
|
+
1,
|
|
276
|
+
1,
|
|
277
|
+
5,
|
|
278
|
+
0
|
|
279
|
+
];
|
|
280
|
+
const octets = [4, ...derLength(der.length)];
|
|
281
|
+
const body = algorithm.length + octets.length + der.length;
|
|
282
|
+
const out = new Uint8Array(new ArrayBuffer(1 + derLength(body).length + body));
|
|
283
|
+
out.set([
|
|
284
|
+
48,
|
|
285
|
+
...derLength(body),
|
|
286
|
+
...algorithm,
|
|
287
|
+
...octets
|
|
288
|
+
]);
|
|
289
|
+
out.set(der, out.length - der.length);
|
|
290
|
+
return out;
|
|
291
|
+
}
|
|
292
|
+
function derLength(length) {
|
|
293
|
+
if (length < 128) return [length];
|
|
294
|
+
const bytes = [];
|
|
295
|
+
for (let rest = length; rest > 0; rest >>= 8) bytes.unshift(rest & 255);
|
|
296
|
+
return [128 | bytes.length, ...bytes];
|
|
297
|
+
}
|
|
298
|
+
async function importKey(pkcs8) {
|
|
299
|
+
try {
|
|
300
|
+
return await crypto.subtle.importKey("pkcs8", pkcs8, {
|
|
301
|
+
name: "RSASSA-PKCS1-v1_5",
|
|
302
|
+
hash: "SHA-256"
|
|
303
|
+
}, false, ["sign"]);
|
|
304
|
+
} catch {
|
|
305
|
+
throw new GitHubAppUnavailable("the GitHub App's private key is not an RSA private key");
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
function base64url(value) {
|
|
309
|
+
return Buffer.from(value).toString("base64url");
|
|
310
|
+
}
|
|
311
|
+
function record(value) {
|
|
312
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
|
|
313
|
+
}
|
|
314
|
+
/** A repository as GitHub's API answers it, by the IDs a binding names. */
|
|
315
|
+
function repository(value) {
|
|
316
|
+
const { id, full_name: name, owner, private: hidden } = record(value);
|
|
317
|
+
const ownerId = record(owner).id;
|
|
318
|
+
if (typeof name !== "string" || !Number.isSafeInteger(id) || !Number.isSafeInteger(ownerId)) return null;
|
|
319
|
+
return {
|
|
320
|
+
github: name,
|
|
321
|
+
repositoryId: String(id),
|
|
322
|
+
ownerId: String(ownerId),
|
|
323
|
+
private: hidden === true
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
//#endregion
|
|
5
327
|
//#region src/identity/signin/types.ts
|
|
6
328
|
var SigninError = class extends Error {
|
|
7
329
|
code;
|
|
@@ -70,7 +392,9 @@ var GitHubSigninProvider = class {
|
|
|
70
392
|
const user = await this.#api(token, "/user");
|
|
71
393
|
if (typeof user?.id !== "number") throw new SigninError("invalid_response", `${label} did not say who signed in`);
|
|
72
394
|
const organization = this.config.organization;
|
|
73
|
-
|
|
395
|
+
const standing = organization === null ? "member" : await this.#membership(token, organization);
|
|
396
|
+
if (standing === "unseen") throw new SigninError("not_in_organization", `${label} would not show ${organization}'s members to coffre: a GitHub App needs the Members organization permission, read, and to be installed on ${organization}; an OAuth app, the organization's approval`);
|
|
397
|
+
if (standing === "outside") throw new SigninError("not_in_organization", `only members of the ${organization} organization may sign in with ${label}`);
|
|
74
398
|
const listed = await this.#api(token, "/user/emails");
|
|
75
399
|
const emails = (Array.isArray(listed) ? listed : []).filter((entry) => entry.verified && typeof entry.email === "string").sort((a, b) => Number(b.primary) - Number(a.primary)).map((entry) => entry.email.trim().toLowerCase());
|
|
76
400
|
return {
|
|
@@ -101,12 +425,20 @@ var GitHubSigninProvider = class {
|
|
|
101
425
|
if (typeof body.access_token !== "string" || body.access_token === "") throw new SigninError("invalid_response", `${label} returned no access token`);
|
|
102
426
|
return body.access_token;
|
|
103
427
|
}
|
|
104
|
-
|
|
428
|
+
/**
|
|
429
|
+
* Whether the person is an active member: `outside` when GitHub says they
|
|
430
|
+
* are not (404), `unseen` when it will not say (403), which a GitHub App's
|
|
431
|
+
* user token gets without the Members permission or an installation on
|
|
432
|
+
* the organization, and an OAuth app's where the organization restricts
|
|
433
|
+
* it. Either way, no sign-in.
|
|
434
|
+
*/
|
|
435
|
+
async #membership(token, organization) {
|
|
105
436
|
const response = await this.#send(`${this.config.apiUrl}/user/memberships/orgs/${encodeURIComponent(organization)}`, { headers: apiHeaders(token) });
|
|
106
|
-
if (response.status === 404
|
|
437
|
+
if (response.status === 404) return "outside";
|
|
438
|
+
if (response.status === 403) return "unseen";
|
|
107
439
|
const body = await readJson(response);
|
|
108
440
|
if (!response.ok || body === null) throw new SigninError("invalid_response", `${this.config.label} answered the membership check with ${response.status}`);
|
|
109
|
-
return body.state === "active";
|
|
441
|
+
return body.state === "active" ? "member" : "outside";
|
|
110
442
|
}
|
|
111
443
|
async #api(token, path) {
|
|
112
444
|
const response = await this.#send(`${this.config.apiUrl}${path}`, { headers: apiHeaders(token) });
|
|
@@ -389,19 +721,59 @@ function microsoft(options) {
|
|
|
389
721
|
brand: "microsoft"
|
|
390
722
|
});
|
|
391
723
|
}
|
|
392
|
-
/**
|
|
724
|
+
/**
|
|
725
|
+
* GitHub accounts; with `organization`, only that organization's members.
|
|
726
|
+
* Through an OAuth app, or a GitHub App's OAuth client, by its credentials;
|
|
727
|
+
* or the deployment's GitHub App, `github({ app })`, the same value its
|
|
728
|
+
* `workloads.github` lists repositories with:
|
|
729
|
+
*
|
|
730
|
+
* const app = githubApp({ appId, privateKey, clientId, clientSecret });
|
|
731
|
+
* signin({ providers: [github({ app, organization: 'acme' })], workloads: { limits, github: app } })
|
|
732
|
+
*/
|
|
393
733
|
function github(options) {
|
|
394
734
|
const id = checkId(options.id ?? "github");
|
|
735
|
+
const how = (() => {
|
|
736
|
+
if (!("app" in options)) return options;
|
|
737
|
+
const client = options.app instanceof GitHubApp ? options.app.signinClient() : null;
|
|
738
|
+
if (client === null) throw new Error(`sign-in provider ${id} signs in with the GitHub App's OAuth client: githubApp({ appId, privateKey, clientId, clientSecret })`);
|
|
739
|
+
return {
|
|
740
|
+
...client,
|
|
741
|
+
webUrl: options.app.webUrl,
|
|
742
|
+
apiUrl: options.app.apiUrl
|
|
743
|
+
};
|
|
744
|
+
})();
|
|
395
745
|
return new GitHubSigninProvider({
|
|
396
746
|
id,
|
|
397
747
|
label: options.label ?? "GitHub",
|
|
398
748
|
brand: "github",
|
|
399
|
-
...credentials(id,
|
|
400
|
-
webUrl: baseUrl(
|
|
401
|
-
apiUrl: baseUrl(
|
|
749
|
+
...credentials(id, how),
|
|
750
|
+
webUrl: baseUrl(how.webUrl ?? "https://github.com", `sign-in provider ${id} web URL`),
|
|
751
|
+
apiUrl: baseUrl(how.apiUrl ?? "https://api.github.com", `sign-in provider ${id} API URL`),
|
|
402
752
|
organization: options.organization?.trim() || null
|
|
403
753
|
});
|
|
404
754
|
}
|
|
755
|
+
/**
|
|
756
|
+
* The deployment's GitHub App, checked where it is written: its numeric ID
|
|
757
|
+
* and private key, and its OAuth client when it signs people in too. One
|
|
758
|
+
* value, passed to each side that uses it: `github({ app })` and
|
|
759
|
+
* `workloads.github` (`../github-app.ts`).
|
|
760
|
+
*/
|
|
761
|
+
function githubApp(options) {
|
|
762
|
+
const appId = String(options.appId ?? "").trim();
|
|
763
|
+
if (!/^[1-9][0-9]{0,19}$/.test(appId)) throw new Error("githubApp(…).appId must be the app's numeric ID (its settings page, \"App ID\"), not its client ID");
|
|
764
|
+
const [clientId, clientSecret] = [options.clientId?.trim() || null, options.clientSecret || null];
|
|
765
|
+
if (clientId === null !== (clientSecret === null)) throw new Error("githubApp(…) takes its OAuth client whole, clientId and clientSecret, or neither");
|
|
766
|
+
return new GitHubApp({
|
|
767
|
+
appId,
|
|
768
|
+
pkcs8: pkcs8Of(options.privateKey),
|
|
769
|
+
webUrl: baseUrl(options.webUrl ?? "https://github.com", "githubApp(…).webUrl"),
|
|
770
|
+
apiUrl: baseUrl(options.apiUrl ?? "https://api.github.com", "githubApp(…).apiUrl"),
|
|
771
|
+
...clientId === null || clientSecret === null ? {} : { client: {
|
|
772
|
+
clientId,
|
|
773
|
+
clientSecret
|
|
774
|
+
} }
|
|
775
|
+
});
|
|
776
|
+
}
|
|
405
777
|
function isLoopback(url) {
|
|
406
778
|
return url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]";
|
|
407
779
|
}
|
|
@@ -473,9 +845,13 @@ function workloadsConfig(options, publicUrl) {
|
|
|
473
845
|
if (!limiter(options.limits?.perSource) || !limiter(options.limits?.total)) throw new Error("workloads need their limits, per source and in total: on Workers, two rate-limiting bindings; on Node, processLimits()");
|
|
474
846
|
const loopback = options.allowLoopbackIssuersForDevelopment === true;
|
|
475
847
|
if (loopback && !isLoopback(new URL(publicUrl))) throw new Error(`workloads.allowLoopbackIssuersForDevelopment is for an instance on loopback, and ${publicUrl} is not one: turn it off`);
|
|
848
|
+
const app = options.github;
|
|
849
|
+
if (app !== void 0 && !(app instanceof GitHubApp)) throw new Error("workloads.github is the deployment's GitHub App: githubApp({ appId, privateKey })");
|
|
850
|
+
if (app !== void 0 && app.apiUrl !== "https://api.github.com" && !isLoopback(new URL(app.apiUrl))) throw new Error("workloads.github is for github.com: GitHub Enterprise Server is not supported yet; sign in with its app, and leave workloads.github out");
|
|
476
851
|
return {
|
|
477
852
|
allowLoopback: loopback,
|
|
478
|
-
limits: options.limits
|
|
853
|
+
limits: options.limits,
|
|
854
|
+
github: app ?? null
|
|
479
855
|
};
|
|
480
856
|
}
|
|
481
857
|
/** A deployment's own provider is checked like coffre's: it is only typed, not trusted. */
|
|
@@ -830,4 +1206,4 @@ function refusalOf(error) {
|
|
|
830
1206
|
return new WorkloadTokenRefused("signature", "the token does not verify");
|
|
831
1207
|
}
|
|
832
1208
|
//#endregion
|
|
833
|
-
export { ACCESS_JWT_HEADER, AccessIdentityVerifier, BindingInvalid, CLOCK_TOLERANCE_SECONDS, EVENT_EXPOSURE, GITHUB_EVENTS, GITHUB_ISSUER, GITLAB_ISSUER, GITLAB_PIPELINE_SOURCES, GitHubSigninProvider, MAX_BINDINGS, MAX_CLAIMS, MAX_CLAIM_BYTES, MAX_TOKEN_AGE_SECONDS, MAX_TOKEN_BYTES, OidcSigninProvider, REQUIRED_CLAIMS, SigninError, WORKLOAD_ALGORITHMS, WORKLOAD_PROFILES, WorkloadTokenRefused, canonicalClaims, checkBinding, checkFetchUrl, claimValues, cloudflareAccess, decodeWorkloadToken, defaultIssuer, defineSignin, deriveKey, differingClaims, fullRef, generateToken, github, githubEventsAt, githubReusable, githubWorkflow, gitlabProject, google, hashToken, isCoffreToken, issuedRefusal, microsoft, oidc, publicOrigin, seal, signin, toPrincipal, tokenHint, tokenKind, unseal, verifyWorkloadToken, workflowRef };
|
|
1209
|
+
export { ACCESS_JWT_HEADER, AccessIdentityVerifier, BindingInvalid, CLOCK_TOLERANCE_SECONDS, EVENT_EXPOSURE, GITHUB_EVENTS, GITHUB_ISSUER, GITLAB_ISSUER, GITLAB_PIPELINE_SOURCES, GitHubApp, GitHubAppUnavailable, GitHubSigninProvider, MAX_BINDINGS, MAX_CLAIMS, MAX_CLAIM_BYTES, MAX_LISTING_REQUESTS, MAX_TOKEN_AGE_SECONDS, MAX_TOKEN_BYTES, OidcSigninProvider, REQUIRED_CLAIMS, SigninError, TOKEN_MARGIN_MS, WORKLOAD_ALGORITHMS, WORKLOAD_PROFILES, WorkloadTokenRefused, canonicalClaims, checkBinding, checkFetchUrl, claimValues, cloudflareAccess, decodeWorkloadToken, defaultIssuer, defineSignin, deriveKey, differingClaims, fullRef, generateToken, github, githubApp, githubEventsAt, githubReusable, githubWorkflow, gitlabProject, google, hashToken, isCoffreToken, issuedRefusal, microsoft, oidc, pkcs8Of, publicOrigin, seal, signin, toPrincipal, tokenHint, tokenKind, unseal, verifyWorkloadToken, workflowRef };
|
package/dist/schemas.d.ts
CHANGED
|
@@ -29,11 +29,11 @@ export declare const principalType: z.ZodEnum<{
|
|
|
29
29
|
}>;
|
|
30
30
|
export declare const principalId: z.ZodString;
|
|
31
31
|
export declare const instanceRole: z.ZodEnum<{
|
|
32
|
-
member: "member";
|
|
33
|
-
auditor: "auditor";
|
|
34
32
|
developer: "developer";
|
|
35
|
-
|
|
33
|
+
auditor: "auditor";
|
|
36
34
|
owner: "owner";
|
|
35
|
+
member: "member";
|
|
36
|
+
admin: "admin";
|
|
37
37
|
}>;
|
|
38
38
|
/** Which projects or environments a scope takes in, by slug: `all`, `{ only: [...] }` or `{ except: [...] }`. */
|
|
39
39
|
export declare const scopeFilter: z.ZodUnion<readonly [z.ZodLiteral<"all">, z.ZodObject<{
|
package/dist/schemas.js
CHANGED
package/dist/vault.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { _ as Role, b as ServiceSetting, c as InstanceRole, i as GrantPlace, t as CredentialScope, v as Scope } from "./access-
|
|
1
|
+
import { _ as Role, b as ServiceSetting, c as InstanceRole, i as GrantPlace, t as CredentialScope, v as Scope } from "./access-BpcBLDW9.js";
|
|
2
2
|
//#region src/access-fault.d.ts
|
|
3
3
|
/**
|
|
4
4
|
* Why the vault's members and grants do not follow from its log, as facts
|
|
@@ -138,27 +138,33 @@ export interface Vault {
|
|
|
138
138
|
seqs: number[];
|
|
139
139
|
}>>;
|
|
140
140
|
/**
|
|
141
|
-
* What one principal holds right now: the app asks once per request
|
|
142
|
-
*
|
|
143
|
-
* themselves, and need not ask.
|
|
141
|
+
* What one principal holds right now: the app asks once per request,
|
|
142
|
+
* and at sign-in for each verified email. A decision, its rows checked:
|
|
143
|
+
* lists of who holds what read the rows themselves, and need not ask.
|
|
144
|
+
* A person the vault's `owners` names who is no Owner of all of it
|
|
145
|
+
* becomes one here, logged (`reason: 'owners'`).
|
|
144
146
|
*/
|
|
145
147
|
access(principal: string): Promise<Access>;
|
|
146
|
-
/**
|
|
148
|
+
/**
|
|
149
|
+
* Set roles at several places for one principal: all of it, or none.
|
|
150
|
+
* With `admit`, a service account that is not a member is admitted
|
|
151
|
+
* first, in the same decision, as `admit` would: `admitted` says so.
|
|
152
|
+
*/
|
|
147
153
|
setAccess(input: SetAccessInput): Promise<Outcome<{
|
|
148
154
|
changes: AccessChange[];
|
|
155
|
+
admitted?: Admitted;
|
|
149
156
|
}>>;
|
|
150
157
|
/**
|
|
151
158
|
* Add a member, bring back a removed one, or change a person's instance
|
|
152
159
|
* role and its scope. `generation` is theirs now: sessions and tokens of
|
|
153
160
|
* an earlier one are dead.
|
|
154
161
|
*/
|
|
155
|
-
admit(input: AdmitInput): Promise<Outcome<
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
/** Remove a member: revoke every grant and refuse them until admitted again, from `generation` on. */
|
|
162
|
+
admit(input: AdmitInput): Promise<Outcome<Admitted>>;
|
|
163
|
+
/**
|
|
164
|
+
* Remove a member: revoke every grant and refuse them until admitted
|
|
165
|
+
* again, from `generation` on. An Owner, only an Owner of the whole
|
|
166
|
+
* instance removes, and never the last.
|
|
167
|
+
*/
|
|
162
168
|
remove(input: RemoveInput): Promise<Outcome<{
|
|
163
169
|
revoked: Grant[];
|
|
164
170
|
generation: number;
|
|
@@ -191,12 +197,21 @@ export interface Vault {
|
|
|
191
197
|
/**
|
|
192
198
|
* What the vault's configuration says, which the app shows: the keys its
|
|
193
199
|
* checkpoints verify under, by the key id each checkpoint names (the one
|
|
194
|
-
* it signs with now, and those of the KEKs it replaced)
|
|
195
|
-
* admins, as principals.
|
|
200
|
+
* it signs with now, and those of the KEKs it replaced).
|
|
196
201
|
*/
|
|
197
202
|
about(): Promise<{
|
|
198
203
|
checkpointKeys: Record<string, CheckpointKey>;
|
|
199
|
-
|
|
204
|
+
/**
|
|
205
|
+
* The emails of those the deployment names Owners of the whole instance
|
|
206
|
+
* (`owners`), lowercased: the vault makes them Owners, and refuses to
|
|
207
|
+
* demote or remove them. Absent from a vault of 0.5, which names none.
|
|
208
|
+
*/
|
|
209
|
+
owners?: string[];
|
|
210
|
+
/**
|
|
211
|
+
* @deprecated Always empty: an app of 0.5, deployed after this vault,
|
|
212
|
+
* lists and checks root admins here until it is. Gone in 0.7.
|
|
213
|
+
*/
|
|
214
|
+
rootAdmins: [];
|
|
200
215
|
}>;
|
|
201
216
|
/**
|
|
202
217
|
* What an escrowed vault key is checked against: the vault key it wraps
|
|
@@ -248,8 +263,12 @@ export type RefusalCode =
|
|
|
248
263
|
'ended' |
|
|
249
264
|
/** Not allowed to manage access, members or the log. */
|
|
250
265
|
'not_allowed' |
|
|
251
|
-
/**
|
|
252
|
-
|
|
266
|
+
/**
|
|
267
|
+
* The change would leave the instance without an Owner of all of it: the
|
|
268
|
+
* last one is not demoted, narrowed or removed, by anyone, until another
|
|
269
|
+
* is made.
|
|
270
|
+
*/
|
|
271
|
+
'last_owner' |
|
|
253
272
|
/** Well-formed, but not something the rules allow: a project role on an environment, a service account with an instance role. */
|
|
254
273
|
'invalid' |
|
|
255
274
|
/** The vault's own log no longer carries the head the last checkpoint signed, or does not rehash since. */
|
|
@@ -407,22 +426,19 @@ export type Access = {
|
|
|
407
426
|
/**
|
|
408
427
|
* `unknown` for someone never admitted, `tampered` for a member whose row
|
|
409
428
|
* fails the vault's integrity check: refused everything, as `removed` is.
|
|
410
|
-
* Root admins are always active.
|
|
411
429
|
*/
|
|
412
430
|
status: 'active' | 'removed' | 'unknown' | 'tampered';
|
|
413
431
|
/** Advanced by removal, even if the app cannot commit its credential revocations. */
|
|
414
432
|
generation: number;
|
|
415
|
-
isRootAdmin: boolean;
|
|
416
433
|
/**
|
|
417
434
|
* Their instance role and its scope, while active: `member` everywhere
|
|
418
|
-
* for service accounts and anyone not active
|
|
419
|
-
* root admins. Scopes name projects by id.
|
|
435
|
+
* for service accounts and anyone not active. Scopes name projects by id.
|
|
420
436
|
*/
|
|
421
437
|
role: InstanceRole;
|
|
422
438
|
scope: Scope;
|
|
423
439
|
/** Live grants only, and none unless active. */
|
|
424
440
|
grants: Grant[];
|
|
425
|
-
/** When the status last changed, and who changed it; null for
|
|
441
|
+
/** When the status last changed, and who changed it; null for strangers. */
|
|
426
442
|
since: string | null;
|
|
427
443
|
by: string | null;
|
|
428
444
|
};
|
|
@@ -439,6 +455,12 @@ export type SetAccessInput = Correlation & Through & {
|
|
|
439
455
|
actor: string;
|
|
440
456
|
principal: string;
|
|
441
457
|
changes: GrantChange[];
|
|
458
|
+
/**
|
|
459
|
+
* Admit the principal, a service account, unless it is a member: a
|
|
460
|
+
* grant refused admits nobody. A vault older than this refuses one that
|
|
461
|
+
* is not a member, `not_a_member`, and admits nobody either.
|
|
462
|
+
*/
|
|
463
|
+
admit?: boolean;
|
|
442
464
|
};
|
|
443
465
|
export type AdmitInput = Correlation & Through & {
|
|
444
466
|
actor: string;
|
|
@@ -446,11 +468,22 @@ export type AdmitInput = Correlation & Through & {
|
|
|
446
468
|
/**
|
|
447
469
|
* A person's instance role, and where it applies (everywhere, left out).
|
|
448
470
|
* Left out, an existing member keeps theirs and a new one is a `member`.
|
|
449
|
-
* Changing it takes someone who runs the instance, about someone else
|
|
471
|
+
* Changing it takes someone who runs the instance, about someone else;
|
|
472
|
+
* making someone an Owner, or changing an Owner's, an Owner of the whole
|
|
473
|
+
* instance, who may change their own too. It never leaves the instance
|
|
474
|
+
* without an Owner of all of it (`last_owner`), nor demotes one the
|
|
475
|
+
* deployment names (`owners`; `not_allowed`).
|
|
450
476
|
*/
|
|
451
477
|
role?: InstanceRole;
|
|
452
478
|
scope?: Scope;
|
|
453
479
|
};
|
|
480
|
+
/** A member as `admit` left them; `created` when they were not one before. */
|
|
481
|
+
export type Admitted = {
|
|
482
|
+
created: boolean;
|
|
483
|
+
role: InstanceRole;
|
|
484
|
+
scope: Scope;
|
|
485
|
+
generation: number;
|
|
486
|
+
};
|
|
454
487
|
export type RemoveInput = Correlation & Through & {
|
|
455
488
|
actor: string;
|
|
456
489
|
principal: string;
|