@coffre/core 0.1.15 → 0.1.17
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/identity.d.ts +119 -2
- package/dist/identity.js +126 -5
- package/dist/vault.d.ts +6 -0
- package/dist/workloads.d.ts +123 -0
- package/dist/workloads.js +264 -0
- package/package.json +7 -2
- package/src/identity/auth.ts +8 -3
- package/src/identity/index.ts +2 -0
- package/src/identity/signin/config.ts +51 -0
- package/src/identity/workload-token.ts +163 -0
- package/src/identity/workloads.ts +304 -0
- package/src/vault.ts +6 -0
package/dist/identity.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { BindingClaims, BindingInvalid, BindingPolicy, GITHUB_EVENTS, GITHUB_ISSUER, GITLAB_ISSUER, GITLAB_PIPELINE_SOURCES, MAX_BINDINGS, MAX_CLAIMS, MAX_CLAIM_BYTES, REQUIRED_CLAIMS, WORKLOAD_PROFILES, WorkloadProfile, canonicalClaims, checkBinding, checkFetchUrl, defaultIssuer, fullRef, githubReusable, githubWorkflow, gitlabProject, workflowRef } from "./workloads.js";
|
|
2
|
+
import { JSONWebKeySet, JWTPayload } from "jose";
|
|
2
3
|
//#region src/identity/signin/types.d.ts
|
|
3
4
|
/**
|
|
4
5
|
* What a provider reports about the person who just signed in.
|
|
@@ -189,6 +190,46 @@ export type SigninConfig = {
|
|
|
189
190
|
browserSessionHours: number;
|
|
190
191
|
/** Absolute lifetime of a CLI session. */
|
|
191
192
|
cliSessionDays: number;
|
|
193
|
+
/** CI runs signing in as services with their platform's ID token, when on (`workloads.ts`). */
|
|
194
|
+
workloads: WorkloadsConfig | null;
|
|
195
|
+
};
|
|
196
|
+
/**
|
|
197
|
+
* Workload identity: CI runs that sign in as a service with the ID token
|
|
198
|
+
* their platform signs for them, through the trust bindings an owner sets
|
|
199
|
+
* on the service (docs/design/oidc.md). Off unless the deployment turns it on.
|
|
200
|
+
*/
|
|
201
|
+
export type WorkloadsOptions = {
|
|
202
|
+
/**
|
|
203
|
+
* The two limits every exchange passes before coffre does any work for
|
|
204
|
+
* it: one per source address, and one for all sources together. On
|
|
205
|
+
* Workers, two rate-limiting bindings (`env.WORKLOADS_PER_SOURCE`,
|
|
206
|
+
* `env.WORKLOADS_TOTAL`), which Cloudflare counts per location; on Node,
|
|
207
|
+
* `processLimits()` from `@coffre/server/node`, which counts per process.
|
|
208
|
+
* Required: an exchange is never without them.
|
|
209
|
+
*/
|
|
210
|
+
limits: WorkloadLimits;
|
|
211
|
+
/**
|
|
212
|
+
* Lets a binding's issuer be plain HTTP on loopback, as the dev IdP's is:
|
|
213
|
+
* for local development and conformance. Refused unless the public URL is
|
|
214
|
+
* loopback too, so no instance anyone else can reach ever trusts one.
|
|
215
|
+
*/
|
|
216
|
+
allowLoopbackIssuersForDevelopment?: boolean;
|
|
217
|
+
};
|
|
218
|
+
/** Cloudflare's rate-limiting binding, as coffre calls it; `processLimits()` has the same shape. */
|
|
219
|
+
export type RateLimiter = {
|
|
220
|
+
limit(options: {
|
|
221
|
+
key: string;
|
|
222
|
+
}): Promise<{
|
|
223
|
+
success: boolean;
|
|
224
|
+
}>;
|
|
225
|
+
};
|
|
226
|
+
export type WorkloadLimits = {
|
|
227
|
+
perSource: RateLimiter;
|
|
228
|
+
total: RateLimiter;
|
|
229
|
+
};
|
|
230
|
+
export type WorkloadsConfig = {
|
|
231
|
+
allowLoopback: boolean;
|
|
232
|
+
limits: WorkloadLimits;
|
|
192
233
|
};
|
|
193
234
|
type Credentials = {
|
|
194
235
|
clientId: string;
|
|
@@ -249,6 +290,7 @@ export declare function defineSignin(options: {
|
|
|
249
290
|
};
|
|
250
291
|
browserSessionHours?: number;
|
|
251
292
|
cliSessionDays?: number;
|
|
293
|
+
workloads?: WorkloadsOptions;
|
|
252
294
|
}): SigninConfig;
|
|
253
295
|
//#endregion
|
|
254
296
|
//#region src/identity/types.d.ts
|
|
@@ -350,6 +392,8 @@ export type SigninOptions = {
|
|
|
350
392
|
browserSessionHours?: number;
|
|
351
393
|
/** Absolute lifetime of a CLI session; 30 days unless set, at most a year. */
|
|
352
394
|
cliSessionDays?: number;
|
|
395
|
+
/** Lets CI runs sign in as services with their platform's ID token: `{}` turns it on. */
|
|
396
|
+
workloads?: WorkloadsOptions;
|
|
353
397
|
};
|
|
354
398
|
/**
|
|
355
399
|
* coffre's own sign-in page, with these providers:
|
|
@@ -430,4 +474,77 @@ export declare class AccessIdentityVerifier implements IdentityVerifier {
|
|
|
430
474
|
* asking.
|
|
431
475
|
*/
|
|
432
476
|
export declare function toPrincipal(claims: AccessClaims): Principal;
|
|
433
|
-
//#endregion
|
|
477
|
+
//#endregion
|
|
478
|
+
//#region src/identity/workload-token.d.ts
|
|
479
|
+
/**
|
|
480
|
+
* An ID token a CI platform signed for a run, as the exchange checks it
|
|
481
|
+
* (docs/design/oidc.md, section 3). The checks run in this order; each
|
|
482
|
+
* refusal says which, as a reason a CI user can act on.
|
|
483
|
+
*/
|
|
484
|
+
/** A compact JWS is three base64url parts; the whole token at most this long. */
|
|
485
|
+
export declare const MAX_TOKEN_BYTES: number;
|
|
486
|
+
/** Each side of every time check. */
|
|
487
|
+
export declare const CLOCK_TOLERANCE_SECONDS = 30;
|
|
488
|
+
/** The oldest token accepted, by its `iat`: tokens handed to a job at its start fit, day-old ones do not. */
|
|
489
|
+
export declare const MAX_TOKEN_AGE_SECONDS = 3600;
|
|
490
|
+
/** RS256 and ES256 (P-256), as for Access assertions: never `none`, never HS*. */
|
|
491
|
+
export declare const WORKLOAD_ALGORITHMS: readonly ["RS256", "ES256"];
|
|
492
|
+
export type WorkloadRefusal =
|
|
493
|
+
/** Not a compact JWS, or claims that are not the right types. */
|
|
494
|
+
'malformed' |
|
|
495
|
+
/** The signature does not verify under the issuer's keys. */
|
|
496
|
+
'signature' |
|
|
497
|
+
/** Signed by a key the issuer's set does not hold, now. */
|
|
498
|
+
'unknown_key' |
|
|
499
|
+
/** Past its `exp`. */
|
|
500
|
+
'expired' |
|
|
501
|
+
/** Its `iat` is more than an hour ago, or not yet. */
|
|
502
|
+
'too_old' |
|
|
503
|
+
/** Its `nbf` is still to come. */
|
|
504
|
+
'not_yet_valid' |
|
|
505
|
+
/** Its audience is not this instance alone. */
|
|
506
|
+
'audience';
|
|
507
|
+
export declare class WorkloadTokenRefused extends Error {
|
|
508
|
+
readonly reason: WorkloadRefusal;
|
|
509
|
+
constructor(reason: WorkloadRefusal, message: string);
|
|
510
|
+
}
|
|
511
|
+
/** What a token says before anything about it is trusted: enough to find the binding it would match. */
|
|
512
|
+
export type DecodedToken = {
|
|
513
|
+
header: Record<string, unknown>;
|
|
514
|
+
claims: Record<string, unknown> & {
|
|
515
|
+
iss: string;
|
|
516
|
+
sub: string;
|
|
517
|
+
exp: number;
|
|
518
|
+
iat: number;
|
|
519
|
+
};
|
|
520
|
+
/**
|
|
521
|
+
* SHA-256 of `header.payload`, exactly as received: what the signature
|
|
522
|
+
* covers, and so the one spelling of the token. Hashing the whole token
|
|
523
|
+
* would not do: an ES256 signature (r, s) has a twin, (r, n − s), that
|
|
524
|
+
* verifies the same claims, so the whole token has two spellings.
|
|
525
|
+
*/
|
|
526
|
+
signingInputHash: Buffer;
|
|
527
|
+
};
|
|
528
|
+
/** A token's parts and claims, checked for shape and types only. Throws `WorkloadTokenRefused('malformed')`. */
|
|
529
|
+
export declare function decodeWorkloadToken(token: string): DecodedToken;
|
|
530
|
+
/**
|
|
531
|
+
* Verify a token under an issuer's keys: the signature by jose, with the
|
|
532
|
+
* algorithms fixed and nothing taken from the header but `alg` and `kid`;
|
|
533
|
+
* the issuer exactly; `exp`, `nbf` and `iat` within the tolerance, `iat` at
|
|
534
|
+
* most an hour old; and the audience, this instance's public URL alone.
|
|
535
|
+
* Returns the verified claims.
|
|
536
|
+
*/
|
|
537
|
+
export declare function verifyWorkloadToken(token: string, keys: JSONWebKeySet, expected: {
|
|
538
|
+
issuer: string;
|
|
539
|
+
audience: string;
|
|
540
|
+
now: Date;
|
|
541
|
+
}): Promise<JWTPayload & DecodedToken['claims']>;
|
|
542
|
+
/**
|
|
543
|
+
* Why a token issued at `iat` may not be exchanged at `now`, both in
|
|
544
|
+
* seconds, or null: issued an hour ago at most, exactly, and no further
|
|
545
|
+
* ahead than the clock tolerance. Verification and the exchange's commit
|
|
546
|
+
* both ask it.
|
|
547
|
+
*/
|
|
548
|
+
export declare function issuedRefusal(iat: number, now: number): WorkloadTokenRefused | null;
|
|
549
|
+
//#endregion
|
|
550
|
+
export { BindingClaims, BindingInvalid, BindingPolicy, GITHUB_EVENTS, GITHUB_ISSUER, GITLAB_ISSUER, GITLAB_PIPELINE_SOURCES, type JSONWebKeySet, MAX_BINDINGS, MAX_CLAIMS, MAX_CLAIM_BYTES, REQUIRED_CLAIMS, WORKLOAD_PROFILES, WorkloadProfile, canonicalClaims, checkBinding, checkFetchUrl, defaultIssuer, fullRef, githubReusable, githubWorkflow, gitlabProject, workflowRef };
|
package/dist/identity.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { BindingInvalid, GITHUB_EVENTS, GITHUB_ISSUER, GITLAB_ISSUER, GITLAB_PIPELINE_SOURCES, MAX_BINDINGS, MAX_CLAIMS, MAX_CLAIM_BYTES, REQUIRED_CLAIMS, WORKLOAD_PROFILES, canonicalClaims, checkBinding, checkFetchUrl, defaultIssuer, fullRef, githubReusable, githubWorkflow, gitlabProject, workflowRef } from "./workloads.js";
|
|
1
2
|
import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes } from "node:crypto";
|
|
2
3
|
import * as oauth from "oauth4webapi";
|
|
3
|
-
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
4
|
+
import { createLocalJWKSet, createRemoteJWKSet, errors, jwtVerify } from "jose";
|
|
4
5
|
//#region src/identity/signin/types.ts
|
|
5
6
|
var SigninError = class extends Error {
|
|
6
7
|
code;
|
|
@@ -450,7 +451,19 @@ function defineSignin(options) {
|
|
|
450
451
|
note: options.page?.note ?? null
|
|
451
452
|
},
|
|
452
453
|
browserSessionHours: lifetime(options.browserSessionHours, "browserSessionHours", 12, 168),
|
|
453
|
-
cliSessionDays: lifetime(options.cliSessionDays, "cliSessionDays", 30, 365)
|
|
454
|
+
cliSessionDays: lifetime(options.cliSessionDays, "cliSessionDays", 30, 365),
|
|
455
|
+
workloads: options.workloads === void 0 ? null : workloadsConfig(options.workloads, publicOrigin(options.publicUrl))
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
/** Workloads, checked: both limits given, each one a limiter; loopback issuers only on a loopback instance. */
|
|
459
|
+
function workloadsConfig(options, publicUrl) {
|
|
460
|
+
const limiter = (value) => typeof value?.limit === "function";
|
|
461
|
+
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()");
|
|
462
|
+
const loopback = options.allowLoopbackIssuersForDevelopment === true;
|
|
463
|
+
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`);
|
|
464
|
+
return {
|
|
465
|
+
allowLoopback: loopback,
|
|
466
|
+
limits: options.limits
|
|
454
467
|
};
|
|
455
468
|
}
|
|
456
469
|
/** A deployment's own provider is checked like coffre's: it is only typed, not trusted. */
|
|
@@ -512,7 +525,7 @@ function cloudflareAccess(options) {
|
|
|
512
525
|
function signin(options) {
|
|
513
526
|
defineSignin({
|
|
514
527
|
...toSignin(options),
|
|
515
|
-
publicUrl: "
|
|
528
|
+
publicUrl: "http://127.0.0.1"
|
|
516
529
|
});
|
|
517
530
|
return { resolve: (publicUrl) => ({
|
|
518
531
|
mode: "signin",
|
|
@@ -530,7 +543,8 @@ function toSignin(options) {
|
|
|
530
543
|
note: options.note
|
|
531
544
|
},
|
|
532
545
|
browserSessionHours: options.browserSessionHours,
|
|
533
|
-
cliSessionDays: options.cliSessionDays
|
|
546
|
+
cliSessionDays: options.cliSessionDays,
|
|
547
|
+
workloads: options.workloads
|
|
534
548
|
};
|
|
535
549
|
}
|
|
536
550
|
//#endregion
|
|
@@ -688,4 +702,111 @@ function toPrincipal(claims) {
|
|
|
688
702
|
throw new Error("token verified but carries no usable identity: expected common_name (service) or sub+email (user)");
|
|
689
703
|
}
|
|
690
704
|
//#endregion
|
|
691
|
-
|
|
705
|
+
//#region src/identity/workload-token.ts
|
|
706
|
+
/**
|
|
707
|
+
* An ID token a CI platform signed for a run, as the exchange checks it
|
|
708
|
+
* (docs/design/oidc.md, section 3). The checks run in this order; each
|
|
709
|
+
* refusal says which, as a reason a CI user can act on.
|
|
710
|
+
*/
|
|
711
|
+
/** A compact JWS is three base64url parts; the whole token at most this long. */
|
|
712
|
+
const MAX_TOKEN_BYTES = 8192;
|
|
713
|
+
/** Each side of every time check. */
|
|
714
|
+
const CLOCK_TOLERANCE_SECONDS = 30;
|
|
715
|
+
/** The oldest token accepted, by its `iat`: tokens handed to a job at its start fit, day-old ones do not. */
|
|
716
|
+
const MAX_TOKEN_AGE_SECONDS = 3600;
|
|
717
|
+
/** RS256 and ES256 (P-256), as for Access assertions: never `none`, never HS*. */
|
|
718
|
+
const WORKLOAD_ALGORITHMS = ["RS256", "ES256"];
|
|
719
|
+
var WorkloadTokenRefused = class extends Error {
|
|
720
|
+
reason;
|
|
721
|
+
constructor(reason, message) {
|
|
722
|
+
super(message);
|
|
723
|
+
this.name = "WorkloadTokenRefused";
|
|
724
|
+
this.reason = reason;
|
|
725
|
+
}
|
|
726
|
+
};
|
|
727
|
+
const PART = /^[A-Za-z0-9_-]+$/;
|
|
728
|
+
/** A token's parts and claims, checked for shape and types only. Throws `WorkloadTokenRefused('malformed')`. */
|
|
729
|
+
function decodeWorkloadToken(token) {
|
|
730
|
+
const malformed = (why) => new WorkloadTokenRefused("malformed", `the token ${why}`);
|
|
731
|
+
if (typeof token !== "string" || token.length === 0) throw malformed("is empty");
|
|
732
|
+
if (token.length > 8192) throw malformed(`is longer than ${MAX_TOKEN_BYTES} bytes`);
|
|
733
|
+
const parts = token.split(".");
|
|
734
|
+
if (parts.length !== 3 || !parts.every((part) => PART.test(part))) throw malformed("is not a compact JWS");
|
|
735
|
+
const json = (part) => {
|
|
736
|
+
let value;
|
|
737
|
+
try {
|
|
738
|
+
value = JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
|
|
739
|
+
} catch {
|
|
740
|
+
throw malformed("has a part that is not JSON");
|
|
741
|
+
}
|
|
742
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw malformed("has a part that is not an object");
|
|
743
|
+
return value;
|
|
744
|
+
};
|
|
745
|
+
const [header, claims] = [json(parts[0]), json(parts[1])];
|
|
746
|
+
for (const name of ["iss", "sub"]) if (typeof claims[name] !== "string" || claims[name] === "") throw malformed(`has no ${name}, as a nonempty string`);
|
|
747
|
+
for (const name of ["exp", "iat"]) if (typeof claims[name] !== "number" || !Number.isFinite(claims[name])) throw malformed(`has no ${name}, as a finite number`);
|
|
748
|
+
if (claims.nbf !== void 0 && (typeof claims.nbf !== "number" || !Number.isFinite(claims.nbf))) throw malformed("has an nbf that is not a finite number");
|
|
749
|
+
return {
|
|
750
|
+
header,
|
|
751
|
+
claims,
|
|
752
|
+
signingInputHash: createHash("sha256").update(`${parts[0]}.${parts[1]}`, "ascii").digest()
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Verify a token under an issuer's keys: the signature by jose, with the
|
|
757
|
+
* algorithms fixed and nothing taken from the header but `alg` and `kid`;
|
|
758
|
+
* the issuer exactly; `exp`, `nbf` and `iat` within the tolerance, `iat` at
|
|
759
|
+
* most an hour old; and the audience, this instance's public URL alone.
|
|
760
|
+
* Returns the verified claims.
|
|
761
|
+
*/
|
|
762
|
+
async function verifyWorkloadToken(token, keys, expected) {
|
|
763
|
+
let payload;
|
|
764
|
+
try {
|
|
765
|
+
({payload} = await jwtVerify(token, createLocalJWKSet(keys), {
|
|
766
|
+
algorithms: [...WORKLOAD_ALGORITHMS],
|
|
767
|
+
issuer: expected.issuer,
|
|
768
|
+
clockTolerance: 30,
|
|
769
|
+
requiredClaims: [
|
|
770
|
+
"iss",
|
|
771
|
+
"sub",
|
|
772
|
+
"exp",
|
|
773
|
+
"iat"
|
|
774
|
+
],
|
|
775
|
+
currentDate: expected.now
|
|
776
|
+
}));
|
|
777
|
+
} catch (error) {
|
|
778
|
+
throw refusalOf(error);
|
|
779
|
+
}
|
|
780
|
+
const issued = issuedRefusal(payload.iat, expected.now.getTime() / 1e3);
|
|
781
|
+
if (issued !== null) throw issued;
|
|
782
|
+
const { aud } = payload;
|
|
783
|
+
if (!(aud === expected.audience || Array.isArray(aud) && aud.length === 1 && aud[0] === expected.audience)) throw new WorkloadTokenRefused("audience", `the token is for ${JSON.stringify(aud ?? null)}, not ${expected.audience} alone`);
|
|
784
|
+
return payload;
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* Why a token issued at `iat` may not be exchanged at `now`, both in
|
|
788
|
+
* seconds, or null: issued an hour ago at most, exactly, and no further
|
|
789
|
+
* ahead than the clock tolerance. Verification and the exchange's commit
|
|
790
|
+
* both ask it.
|
|
791
|
+
*/
|
|
792
|
+
function issuedRefusal(iat, now) {
|
|
793
|
+
if (now - iat > 3600) return new WorkloadTokenRefused("too_old", `the token was issued more than ${MAX_TOKEN_AGE_SECONDS / 60} minutes ago`);
|
|
794
|
+
if (iat - now > 30) return new WorkloadTokenRefused("too_old", "the token says it was issued in the future");
|
|
795
|
+
return null;
|
|
796
|
+
}
|
|
797
|
+
function refusalOf(error) {
|
|
798
|
+
if (error instanceof errors.JWKSNoMatchingKey) return new WorkloadTokenRefused("unknown_key", "the token's key is not among the issuer's");
|
|
799
|
+
if (error instanceof errors.JWTExpired) return error.claim === "iat" ? new WorkloadTokenRefused("too_old", `the token was issued more than ${MAX_TOKEN_AGE_SECONDS / 60} minutes ago`) : new WorkloadTokenRefused("expired", "the token has expired");
|
|
800
|
+
if (error instanceof errors.JWTClaimValidationFailed) {
|
|
801
|
+
if (error.claim === "nbf") return new WorkloadTokenRefused("not_yet_valid", "the token is not valid yet");
|
|
802
|
+
if (error.claim === "iat") return new WorkloadTokenRefused("too_old", "the token says it was issued in the future");
|
|
803
|
+
if (error.claim === "iss") return new WorkloadTokenRefused("signature", "the token is not from the binding's issuer");
|
|
804
|
+
return new WorkloadTokenRefused("malformed", `the token's ${error.claim} is not as required`);
|
|
805
|
+
}
|
|
806
|
+
if (error instanceof errors.JOSEAlgNotAllowed || error instanceof errors.JOSENotSupported) return new WorkloadTokenRefused("signature", "the token is signed with an algorithm coffre does not accept");
|
|
807
|
+
if (error instanceof errors.JWSSignatureVerificationFailed || error instanceof errors.JWSInvalid || error instanceof errors.JWTInvalid) return new WorkloadTokenRefused("signature", "the token's signature does not verify");
|
|
808
|
+
if (error instanceof errors.JOSEError) return new WorkloadTokenRefused("signature", `the token does not verify: ${error.code}`);
|
|
809
|
+
return new WorkloadTokenRefused("signature", "the token does not verify");
|
|
810
|
+
}
|
|
811
|
+
//#endregion
|
|
812
|
+
export { ACCESS_JWT_HEADER, AccessIdentityVerifier, BindingInvalid, CLOCK_TOLERANCE_SECONDS, 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, cloudflareAccess, decodeWorkloadToken, defaultIssuer, defineSignin, deriveKey, fullRef, generateToken, github, githubReusable, githubWorkflow, gitlabProject, google, hashToken, isCoffreToken, issuedRefusal, microsoft, oidc, publicOrigin, seal, signin, toPrincipal, tokenHint, unseal, verifyWorkloadToken, workflowRef };
|
package/dist/vault.d.ts
CHANGED
|
@@ -276,6 +276,12 @@ type Correlation = {
|
|
|
276
276
|
* reads, a write's versions, an access change. Its entries share it.
|
|
277
277
|
*/
|
|
278
278
|
operationId?: string | null;
|
|
279
|
+
/**
|
|
280
|
+
* The credential the request came in on, when a trust binding issued it
|
|
281
|
+
* for a CI run: the vault copies it into the entries it writes, and never
|
|
282
|
+
* decides on it, so that a read leads back to its run (docs/design/oidc.md).
|
|
283
|
+
*/
|
|
284
|
+
credentialId?: string | null;
|
|
279
285
|
};
|
|
280
286
|
export type UnwrapInput = Correlation & {
|
|
281
287
|
principal: string;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
//#region src/identity/workloads.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Trust bindings: which CI runs may sign in as a service, by the ID token
|
|
4
|
+
* their platform signs for them (docs/design/oidc.md).
|
|
5
|
+
*
|
|
6
|
+
* A binding names a profile, an issuer, and claims a token must carry, each
|
|
7
|
+
* a top-level string matched exactly. The profile sets the minimum: the
|
|
8
|
+
* server refuses a binding that lacks a claim its profile requires, from the
|
|
9
|
+
* UI, the CLI or the API alike. Say a service is bound to `deploy.yml`,
|
|
10
|
+
* pushed to `main` of `acme/api`:
|
|
11
|
+
*
|
|
12
|
+
* { profile: 'github', issuer: 'https://token.actions.githubusercontent.com',
|
|
13
|
+
* claims: { repository_owner_id: '9919', repository_id: '41532',
|
|
14
|
+
* workflow_ref: 'acme/api/.github/workflows/deploy.yml@refs/heads/main',
|
|
15
|
+
* ref: 'refs/heads/main', event_name: 'push' } }
|
|
16
|
+
*/
|
|
17
|
+
export declare const GITHUB_ISSUER = "https://token.actions.githubusercontent.com";
|
|
18
|
+
export declare const GITLAB_ISSUER = "https://gitlab.com";
|
|
19
|
+
/**
|
|
20
|
+
* - `github`: a workflow of the repository.
|
|
21
|
+
* - `github-reusable`: a reusable workflow, pinned to one commit, called from
|
|
22
|
+
* one repository at one ref.
|
|
23
|
+
* - `github-reusable-organization`: the same, called from any repository of
|
|
24
|
+
* the organization: an explicit choice, never what a missing claim means.
|
|
25
|
+
* - `gitlab`: a project's pipelines, outside merge requests.
|
|
26
|
+
* - `custom`: any other issuer, by its subject, which the owner vouches for.
|
|
27
|
+
*/
|
|
28
|
+
export type WorkloadProfile = 'github' | 'github-reusable' | 'github-reusable-organization' | 'gitlab' | 'custom';
|
|
29
|
+
export declare const WORKLOAD_PROFILES: readonly WorkloadProfile[];
|
|
30
|
+
/** The claims each profile requires; a binding may add more, to narrow it. */
|
|
31
|
+
export declare const REQUIRED_CLAIMS: Record<WorkloadProfile, readonly string[]>;
|
|
32
|
+
/**
|
|
33
|
+
* Events triggered by someone with write access, at a ref the binding names
|
|
34
|
+
* exactly. `pull_request`, `pull_request_target`, `workflow_run` and the
|
|
35
|
+
* rest are refused, even when their ref happens to match: their runs can
|
|
36
|
+
* carry a stranger's code.
|
|
37
|
+
*/
|
|
38
|
+
export declare const GITHUB_EVENTS: readonly ["push", "workflow_dispatch", "schedule", "release"];
|
|
39
|
+
/** Pipelines that run the project's own code, at its own ref: never a merge request's. */
|
|
40
|
+
export declare const GITLAB_PIPELINE_SOURCES: readonly ["push", "web", "schedule"];
|
|
41
|
+
/** At most this many live bindings per service, and claims per binding. */
|
|
42
|
+
export declare const MAX_BINDINGS = 16;
|
|
43
|
+
export declare const MAX_CLAIMS = 16;
|
|
44
|
+
/** The longest claim value, in bytes. */
|
|
45
|
+
export declare const MAX_CLAIM_BYTES = 256;
|
|
46
|
+
export type BindingClaims = Record<string, string>;
|
|
47
|
+
/** What makes a binding, checked: its profile, its issuer as a URL, and its claims, sorted. */
|
|
48
|
+
export type BindingPolicy = {
|
|
49
|
+
profile: WorkloadProfile;
|
|
50
|
+
issuer: string;
|
|
51
|
+
claims: BindingClaims;
|
|
52
|
+
};
|
|
53
|
+
/** A binding that breaks a rule, said so an owner can fix it. */
|
|
54
|
+
export declare class BindingInvalid extends Error {
|
|
55
|
+
constructor(message: string);
|
|
56
|
+
}
|
|
57
|
+
/** The issuer a profile means when none is given: github.com's or gitlab.com's. A GitHub Enterprise Server or a self-managed GitLab names its own. */
|
|
58
|
+
export declare function defaultIssuer(profile: WorkloadProfile): string | null;
|
|
59
|
+
/**
|
|
60
|
+
* Check a binding against its profile, and return it as stored: the
|
|
61
|
+
* issuer as given, the claims sorted by name. Throws `BindingInvalid`.
|
|
62
|
+
*/
|
|
63
|
+
export declare function checkBinding(input: {
|
|
64
|
+
profile: string;
|
|
65
|
+
issuer: string | null;
|
|
66
|
+
claims: Record<string, unknown>;
|
|
67
|
+
}, options?: {
|
|
68
|
+
allowLoopback?: boolean;
|
|
69
|
+
}): BindingPolicy;
|
|
70
|
+
/** The claims as the MAC and the database hold them: JSON, sorted by name. */
|
|
71
|
+
export declare function canonicalClaims(claims: BindingClaims): string;
|
|
72
|
+
/**
|
|
73
|
+
* A URL coffre may fetch for a binding: an issuer, or the keys its
|
|
74
|
+
* discovery names. `https` on port 443, a host name rather than an address,
|
|
75
|
+
* and nothing that names this machine or its network. Loopback over plain
|
|
76
|
+
* HTTP only where the deployment allows it, for the dev IdP. Where the name
|
|
77
|
+
* resolves is checked again when connecting, on Node (`addresses.ts` in the
|
|
78
|
+
* server).
|
|
79
|
+
*/
|
|
80
|
+
export declare function checkFetchUrl(value: string, what: string, options: {
|
|
81
|
+
allowLoopback?: boolean;
|
|
82
|
+
path: boolean;
|
|
83
|
+
query: boolean;
|
|
84
|
+
}): URL;
|
|
85
|
+
/** `refs/heads/main`, or `refs/tags/v1`, as GitHub names a ref. */
|
|
86
|
+
export declare function fullRef(kind: 'branch' | 'tag', name: string): string;
|
|
87
|
+
/** A workflow file at a ref, as GitHub's tokens name it: `acme/api/.github/workflows/deploy.yml@refs/heads/main`. */
|
|
88
|
+
export declare function workflowRef(repository: string, file: string, ref: string): string;
|
|
89
|
+
/** A workflow of the repository, run by `event` at `ref`. */
|
|
90
|
+
export declare function githubWorkflow(input: {
|
|
91
|
+
repository: string;
|
|
92
|
+
repositoryId: string;
|
|
93
|
+
ownerId: string;
|
|
94
|
+
workflow: string;
|
|
95
|
+
ref: string;
|
|
96
|
+
event: string;
|
|
97
|
+
}): Omit<BindingPolicy, 'issuer'>;
|
|
98
|
+
/**
|
|
99
|
+
* A reusable workflow at one commit, called at `ref`: from one repository,
|
|
100
|
+
* or, with `repositoryId` null, from any repository of the organization. A
|
|
101
|
+
* `caller` workflow narrows it to one calling workflow.
|
|
102
|
+
*/
|
|
103
|
+
export declare function githubReusable(input: {
|
|
104
|
+
repositoryId: string | null;
|
|
105
|
+
ownerId: string;
|
|
106
|
+
ref: string;
|
|
107
|
+
event: string;
|
|
108
|
+
called: string;
|
|
109
|
+
sha: string;
|
|
110
|
+
caller?: {
|
|
111
|
+
repository: string;
|
|
112
|
+
workflow: string;
|
|
113
|
+
};
|
|
114
|
+
}): Omit<BindingPolicy, 'issuer'>;
|
|
115
|
+
/** A GitLab project's pipelines from `source`, at a branch or tag. */
|
|
116
|
+
export declare function gitlabProject(input: {
|
|
117
|
+
namespaceId: string;
|
|
118
|
+
projectId: string;
|
|
119
|
+
refType: 'branch' | 'tag';
|
|
120
|
+
ref: string;
|
|
121
|
+
source: string;
|
|
122
|
+
}): Omit<BindingPolicy, 'issuer'>;
|
|
123
|
+
//#endregion
|