@coffre/core 0.0.0 → 0.1.0
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 +10 -3
- package/dist/access.d.ts +100 -0
- package/dist/access.js +109 -0
- package/dist/audit.d.ts +119 -0
- package/dist/audit.js +160 -0
- package/dist/dotenv.d.ts +45 -0
- package/dist/dotenv.js +194 -0
- package/dist/envelope.d.ts +50 -0
- package/dist/envelope.js +83 -0
- package/dist/identity.d.ts +433 -0
- package/dist/identity.js +691 -0
- package/dist/kek.d.ts +72 -0
- package/dist/kek.js +486 -0
- package/dist/registry-C0NGpxcc.d.ts +126 -0
- package/dist/schemas.d.ts +18 -0
- package/dist/schemas.js +13 -0
- package/dist/types-Mi7-Tzj9.js +57 -0
- package/dist/vault.d.ts +375 -0
- package/dist/vault.js +45 -0
- package/package.json +75 -5
- package/src/access-fault.ts +61 -0
- package/src/access.ts +152 -0
- package/src/audit/chain.ts +207 -0
- package/src/checkpoint.ts +13 -0
- package/src/context.ts +54 -0
- package/src/dotenv.ts +242 -0
- package/src/envelope.ts +127 -0
- package/src/identity/auth.ts +124 -0
- package/src/identity/index.ts +6 -0
- package/src/identity/signin/config.ts +301 -0
- package/src/identity/signin/github.ts +191 -0
- package/src/identity/signin/index.ts +5 -0
- package/src/identity/signin/oidc.ts +199 -0
- package/src/identity/signin/sealed.ts +55 -0
- package/src/identity/signin/types.ts +94 -0
- package/src/identity/tokens.ts +45 -0
- package/src/identity/types.ts +51 -0
- package/src/identity/verifier.ts +86 -0
- package/src/kek/aws-kms.ts +279 -0
- package/src/kek/cancellation.ts +56 -0
- package/src/kek/index.ts +6 -0
- package/src/kek/local.ts +122 -0
- package/src/kek/registry.ts +63 -0
- package/src/kek/sigv4.ts +75 -0
- package/src/kek/types.ts +79 -0
- package/src/schemas.ts +11 -0
- package/src/vault.ts +276 -0
package/src/envelope.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
|
2
|
+
import type { SecretContext } from './context.ts';
|
|
3
|
+
import { encodeAad } from './context.ts';
|
|
4
|
+
import type { KekRegistry } from './kek/registry.ts';
|
|
5
|
+
import { DEK_BYTES } from './kek/types.ts';
|
|
6
|
+
|
|
7
|
+
export * from './context.ts';
|
|
8
|
+
|
|
9
|
+
const IV_BYTES = 12;
|
|
10
|
+
const TAG_BYTES = 16;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Current envelope format version. Stored on every row so the format can change
|
|
14
|
+
* without a bulk re-encryption.
|
|
15
|
+
*/
|
|
16
|
+
export const ENVELOPE_VERSION = 1;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One sealed secret value, exactly as it is stored in `secret_versions`.
|
|
20
|
+
*
|
|
21
|
+
* The AAD binding is implemented here, in our own envelope layer, rather than
|
|
22
|
+
* via a provider "encryption context" feature. That keeps the stored format
|
|
23
|
+
* portable: moving to a different KMS changes only how `wrappedDek` is produced.
|
|
24
|
+
*/
|
|
25
|
+
export type Envelope = {
|
|
26
|
+
envelopeVersion: number;
|
|
27
|
+
kekProvider: string;
|
|
28
|
+
kekId: string;
|
|
29
|
+
kekVersion: string;
|
|
30
|
+
wrappedDek: Buffer;
|
|
31
|
+
iv: Buffer;
|
|
32
|
+
authTag: Buffer;
|
|
33
|
+
ciphertext: Buffer;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** The part of an envelope the data key produces: everything but the wrapped key. */
|
|
37
|
+
export type Sealed = Pick<Envelope, 'envelopeVersion' | 'iv' | 'authTag' | 'ciphertext'>;
|
|
38
|
+
|
|
39
|
+
/** A fresh data key. One per version, so no key ever encrypts two values. */
|
|
40
|
+
export function freshDek(): Buffer {
|
|
41
|
+
return randomBytes(DEK_BYTES);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Encrypt a secret value under `dek`, bound to `ctx`. Whoever holds the key
|
|
46
|
+
* encryption key wraps `dek` separately: the vault, in coffre.
|
|
47
|
+
*/
|
|
48
|
+
export function encrypt(plaintext: Buffer, ctx: SecretContext, dek: Buffer): Sealed {
|
|
49
|
+
const iv = randomBytes(IV_BYTES);
|
|
50
|
+
const cipher = createCipheriv('aes-256-gcm', dek, iv);
|
|
51
|
+
cipher.setAAD(encodeAad(ctx));
|
|
52
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
53
|
+
return { envelopeVersion: ENVELOPE_VERSION, iv, authTag: cipher.getAuthTag(), ciphertext };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Decrypt with an unwrapped `dek`. Throws if the envelope was sealed for a
|
|
58
|
+
* different project, environment or secret than `ctx` describes, because
|
|
59
|
+
* the AAD will not match. This is the property that makes relocating a
|
|
60
|
+
* ciphertext across environments fail closed.
|
|
61
|
+
*/
|
|
62
|
+
export function decrypt(envelope: Sealed, ctx: SecretContext, dek: Buffer): Buffer {
|
|
63
|
+
if (envelope.envelopeVersion !== ENVELOPE_VERSION) {
|
|
64
|
+
throw new Error(`unsupported envelope version: ${envelope.envelopeVersion}`);
|
|
65
|
+
}
|
|
66
|
+
if (envelope.iv.length !== IV_BYTES) {
|
|
67
|
+
throw new Error('envelope IV has the wrong length');
|
|
68
|
+
}
|
|
69
|
+
if (envelope.authTag.length !== TAG_BYTES) {
|
|
70
|
+
throw new Error('envelope auth tag has the wrong length');
|
|
71
|
+
}
|
|
72
|
+
const decipher = createDecipheriv('aes-256-gcm', dek, envelope.iv);
|
|
73
|
+
decipher.setAAD(encodeAad(ctx));
|
|
74
|
+
decipher.setAuthTag(envelope.authTag);
|
|
75
|
+
return Buffer.concat([decipher.update(envelope.ciphertext), decipher.final()]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Encrypt a secret value under a fresh DEK, and wrap that DEK under the
|
|
80
|
+
* registry's primary KEK: `encrypt` and the wrap in one, where both halves
|
|
81
|
+
* are in one place.
|
|
82
|
+
*/
|
|
83
|
+
export async function seal(
|
|
84
|
+
plaintext: Buffer,
|
|
85
|
+
ctx: SecretContext,
|
|
86
|
+
keks: KekRegistry,
|
|
87
|
+
): Promise<Envelope> {
|
|
88
|
+
const dek = freshDek();
|
|
89
|
+
try {
|
|
90
|
+
const sealed = encrypt(plaintext, ctx, dek);
|
|
91
|
+
const wrapped = await keks.wrap(dek, ctx);
|
|
92
|
+
return {
|
|
93
|
+
...sealed,
|
|
94
|
+
kekProvider: wrapped.kekProvider,
|
|
95
|
+
kekId: wrapped.kekId,
|
|
96
|
+
kekVersion: wrapped.kekVersion,
|
|
97
|
+
wrappedDek: wrapped.bytes,
|
|
98
|
+
};
|
|
99
|
+
} finally {
|
|
100
|
+
dek.fill(0);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Unwrap the envelope's DEK under the registry and decrypt: `decrypt` and the unwrap in one. */
|
|
105
|
+
export async function open(
|
|
106
|
+
envelope: Envelope,
|
|
107
|
+
ctx: SecretContext,
|
|
108
|
+
keks: KekRegistry,
|
|
109
|
+
): Promise<Buffer> {
|
|
110
|
+
if (envelope.envelopeVersion !== ENVELOPE_VERSION) {
|
|
111
|
+
throw new Error(`unsupported envelope version: ${envelope.envelopeVersion}`);
|
|
112
|
+
}
|
|
113
|
+
const dek = await keks.unwrap(
|
|
114
|
+
{
|
|
115
|
+
kekProvider: envelope.kekProvider,
|
|
116
|
+
kekId: envelope.kekId,
|
|
117
|
+
kekVersion: envelope.kekVersion,
|
|
118
|
+
bytes: envelope.wrappedDek,
|
|
119
|
+
},
|
|
120
|
+
ctx,
|
|
121
|
+
);
|
|
122
|
+
try {
|
|
123
|
+
return decrypt(envelope, ctx, dek);
|
|
124
|
+
} finally {
|
|
125
|
+
dek.fill(0);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { defineSignin, type SigninConfig } from './signin/config.ts';
|
|
2
|
+
import type { SigninProvider } from './signin/types.ts';
|
|
3
|
+
import type { AccessVerifierConfig } from './types.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Who vouches for the person at the other end of a request.
|
|
7
|
+
*
|
|
8
|
+
* - `signin`: coffre itself, after the person signs in with one of the
|
|
9
|
+
* deployment's providers: GitHub, Google, Microsoft, any OIDC issuer, or
|
|
10
|
+
* one of its own.
|
|
11
|
+
* - `cloudflare`: Cloudflare Access, in front of coffre.
|
|
12
|
+
*
|
|
13
|
+
* Either way, coffre decides who is a member, and issues its own sessions,
|
|
14
|
+
* CLI logins and service tokens.
|
|
15
|
+
*/
|
|
16
|
+
export type AuthConfig =
|
|
17
|
+
| {
|
|
18
|
+
mode: 'signin';
|
|
19
|
+
signin: SigninConfig;
|
|
20
|
+
}
|
|
21
|
+
| {
|
|
22
|
+
mode: 'cloudflare';
|
|
23
|
+
access: AccessVerifierConfig;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* What a deployment writes as `auth`: `signin(…)` or `cloudflareAccess(…)`.
|
|
28
|
+
* Each checks its own options when called; sign-in is finished against the
|
|
29
|
+
* deployment's public URL, which its callbacks are built from.
|
|
30
|
+
*/
|
|
31
|
+
export type Auth = {
|
|
32
|
+
resolve(publicUrl: string): AuthConfig;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function httpsOrigin(what: string, value: string): URL {
|
|
36
|
+
let url: URL;
|
|
37
|
+
try {
|
|
38
|
+
url = new URL(value);
|
|
39
|
+
} catch {
|
|
40
|
+
throw new Error(`${what} must be an absolute URL`);
|
|
41
|
+
}
|
|
42
|
+
if (
|
|
43
|
+
url.protocol !== 'https:' ||
|
|
44
|
+
url.username !== '' ||
|
|
45
|
+
url.password !== '' ||
|
|
46
|
+
url.pathname !== '/' ||
|
|
47
|
+
url.search !== '' ||
|
|
48
|
+
url.hash !== ''
|
|
49
|
+
) {
|
|
50
|
+
throw new Error(`${what} must be an HTTPS origin, with no path, query or credentials`);
|
|
51
|
+
}
|
|
52
|
+
return url;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function checkAudience(audience: string): string {
|
|
56
|
+
if (audience.length === 0 || audience.length > 64 || /\s/.test(audience)) {
|
|
57
|
+
throw new Error('the Access audience must be a non-whitespace Access AUD tag of at most 64 characters');
|
|
58
|
+
}
|
|
59
|
+
return audience;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Cloudflare Access in front of coffre: every request carries Access's
|
|
64
|
+
* signed assertion, checked against the team's keys and the application's
|
|
65
|
+
* AUD tag.
|
|
66
|
+
*
|
|
67
|
+
* cloudflareAccess({ teamDomain: 'acme.cloudflareaccess.com', audience: env.ACCESS_AUD })
|
|
68
|
+
*/
|
|
69
|
+
export function cloudflareAccess(options: { teamDomain: string; audience: string }): Auth {
|
|
70
|
+
const domain = options.teamDomain.trim();
|
|
71
|
+
const issuerUrl = httpsOrigin('the Access team domain', domain.includes('://') ? domain : `https://${domain}`);
|
|
72
|
+
if (
|
|
73
|
+
issuerUrl.port !== '' ||
|
|
74
|
+
!issuerUrl.hostname.endsWith('.cloudflareaccess.com') ||
|
|
75
|
+
issuerUrl.hostname === 'cloudflareaccess.com'
|
|
76
|
+
) {
|
|
77
|
+
throw new Error('the Access team domain must be <team>.cloudflareaccess.com');
|
|
78
|
+
}
|
|
79
|
+
const issuer = issuerUrl.origin;
|
|
80
|
+
const config: AuthConfig = {
|
|
81
|
+
mode: 'cloudflare',
|
|
82
|
+
access: {
|
|
83
|
+
issuer,
|
|
84
|
+
jwksUrl: `${issuer}/cdn-cgi/access/certs`,
|
|
85
|
+
audience: checkAudience(options.audience.trim()),
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
return { resolve: () => config };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type SigninOptions = {
|
|
92
|
+
/** At least one: `github`, `google`, `microsoft`, `oidc`, or the deployment's own `SigninProvider`. */
|
|
93
|
+
providers: SigninProvider[];
|
|
94
|
+
/** Heading on the sign-in page, e.g. "Acme secrets". */
|
|
95
|
+
title?: string;
|
|
96
|
+
/** One line under it, e.g. "Use your acme.example Google account." */
|
|
97
|
+
note?: string;
|
|
98
|
+
/** Absolute lifetime of a browser session; 12 hours unless set, at most a week. */
|
|
99
|
+
browserSessionHours?: number;
|
|
100
|
+
/** Absolute lifetime of a CLI session; 30 days unless set, at most a year. */
|
|
101
|
+
cliSessionDays?: number;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* coffre's own sign-in page, with these providers:
|
|
106
|
+
*
|
|
107
|
+
* signin({ providers: [github({ clientId, clientSecret, organization: 'acme' })] })
|
|
108
|
+
*/
|
|
109
|
+
export function signin(options: SigninOptions): Auth {
|
|
110
|
+
// Checked now, where the deployment wrote it, and again with the URL.
|
|
111
|
+
defineSignin({ ...toSignin(options), publicUrl: 'https://coffre.invalid' });
|
|
112
|
+
return {
|
|
113
|
+
resolve: (publicUrl) => ({ mode: 'signin', signin: defineSignin({ ...toSignin(options), publicUrl }) }),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function toSignin(options: SigninOptions) {
|
|
118
|
+
return {
|
|
119
|
+
providers: options.providers,
|
|
120
|
+
page: { title: options.title, note: options.note },
|
|
121
|
+
browserSessionHours: options.browserSessionHours,
|
|
122
|
+
cliSessionDays: options.cliSessionDays,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sign-in providers, and the sign-in they make up.
|
|
3
|
+
*
|
|
4
|
+
* coffre speaks two protocols: OpenID Connect, which covers Google, Microsoft,
|
|
5
|
+
* Okta, Auth0, Keycloak, Authentik, Clerk, WorkOS and nearly everyone else,
|
|
6
|
+
* and GitHub's own OAuth, because GitHub is the one provider people want that
|
|
7
|
+
* does not speak OIDC. Everything named here is a preset of one of those two:
|
|
8
|
+
* `google()` is OIDC with Google's issuer and a Workspace-domain check,
|
|
9
|
+
* `microsoft()` is OIDC with a tenant's issuer. A provider that is not listed
|
|
10
|
+
* needs no code, only `oidc()` and its issuer URL.
|
|
11
|
+
*
|
|
12
|
+
* A deployment writes them in its own code, where a misspelt option fails
|
|
13
|
+
* the typecheck:
|
|
14
|
+
*
|
|
15
|
+
* signin({ providers: [github({ clientId, clientSecret: env.GITHUB_SECRET, organization: 'acme' })] })
|
|
16
|
+
*
|
|
17
|
+
* Each returns a `SigninProvider`, and a provider that speaks neither can be
|
|
18
|
+
* one of the deployment's own, in the same list.
|
|
19
|
+
*/
|
|
20
|
+
import { GitHubSigninProvider } from './github.ts';
|
|
21
|
+
import { OidcSigninProvider } from './oidc.ts';
|
|
22
|
+
import type { SigninBrand, SigninProvider } from './types.ts';
|
|
23
|
+
|
|
24
|
+
type ProviderBase = {
|
|
25
|
+
id: string;
|
|
26
|
+
label: string;
|
|
27
|
+
brand: SigninBrand;
|
|
28
|
+
clientId: string;
|
|
29
|
+
clientSecret: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type OidcProviderConfig = ProviderBase & {
|
|
33
|
+
/** Issuer identifier; discovery is fetched from `${issuer}/.well-known/openid-configuration`. */
|
|
34
|
+
issuer: string;
|
|
35
|
+
scopes: string[];
|
|
36
|
+
/** Extra authorization request parameters, such as Google's `hd` hint. */
|
|
37
|
+
authorizationParams: Record<string, string>;
|
|
38
|
+
/**
|
|
39
|
+
* The Google Workspace domain the ID token's `hd` claim must equal. The
|
|
40
|
+
* `hd` request parameter only filters Google's account picker; the claim is
|
|
41
|
+
* what proves membership.
|
|
42
|
+
*/
|
|
43
|
+
hostedDomain: string | null;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type GitHubProviderConfig = ProviderBase & {
|
|
47
|
+
/** https://github.com, or a GitHub Enterprise Server host. */
|
|
48
|
+
webUrl: string;
|
|
49
|
+
/** https://api.github.com, or `${host}/api/v3` on Enterprise Server. */
|
|
50
|
+
apiUrl: string;
|
|
51
|
+
/** When set, only active members of this organization may sign in. */
|
|
52
|
+
organization: string | null;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type SigninPage = {
|
|
56
|
+
/** Heading on the sign-in page, e.g. "Acme secrets". */
|
|
57
|
+
title: string;
|
|
58
|
+
/** One line under it, e.g. "Use your acme.example Google account." */
|
|
59
|
+
note: string | null;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export type SigninConfig = {
|
|
63
|
+
/** The origin people reach coffre at. Callback URLs are built from it, never from the Host header. */
|
|
64
|
+
publicUrl: string;
|
|
65
|
+
providers: SigninProvider[];
|
|
66
|
+
page: SigninPage;
|
|
67
|
+
/** Absolute lifetime of a browser session. */
|
|
68
|
+
browserSessionHours: number;
|
|
69
|
+
/** Absolute lifetime of a CLI session. */
|
|
70
|
+
cliSessionDays: number;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const PROVIDER_ID = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
74
|
+
const BRANDS: readonly SigninBrand[] = ['github', 'google', 'microsoft', 'oidc'];
|
|
75
|
+
|
|
76
|
+
type Credentials = { clientId: string; clientSecret: string };
|
|
77
|
+
|
|
78
|
+
function checkId(id: string): string {
|
|
79
|
+
if (typeof id !== 'string' || !PROVIDER_ID.test(id)) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`sign-in provider id "${id}" must be 1-32 lowercase letters, digits or dashes`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
return id;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function credentials(id: string, options: Credentials): Credentials {
|
|
88
|
+
if (!options.clientId || !options.clientSecret) {
|
|
89
|
+
throw new Error(`sign-in provider ${id} needs a client id and a client secret`);
|
|
90
|
+
}
|
|
91
|
+
return { clientId: options.clientId, clientSecret: options.clientSecret };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Any OpenID Connect provider, by issuer URL.
|
|
96
|
+
*
|
|
97
|
+
* Okta, Auth0, Keycloak, Authentik, Zitadel, Clerk, WorkOS, Dex and the like
|
|
98
|
+
* all live here; so does a broker, for providers that only speak SAML or LDAP.
|
|
99
|
+
*/
|
|
100
|
+
export function oidc(options: OidcOptions): OidcSigninProvider {
|
|
101
|
+
return new OidcSigninProvider(oidcConfig(options));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
type OidcOptions = Credentials & {
|
|
105
|
+
id: string;
|
|
106
|
+
label: string;
|
|
107
|
+
issuer: string;
|
|
108
|
+
scopes?: string[];
|
|
109
|
+
authorizationParams?: Record<string, string>;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
function oidcConfig(options: OidcOptions): OidcProviderConfig {
|
|
113
|
+
const id = checkId(options.id);
|
|
114
|
+
return {
|
|
115
|
+
id,
|
|
116
|
+
label: options.label,
|
|
117
|
+
brand: 'oidc',
|
|
118
|
+
...credentials(id, options),
|
|
119
|
+
issuer: issuerUrl(options.issuer, `sign-in provider ${id}`),
|
|
120
|
+
scopes: options.scopes ?? ['openid', 'email', 'profile'],
|
|
121
|
+
authorizationParams: options.authorizationParams ?? {},
|
|
122
|
+
hostedDomain: null,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Google accounts; with `domain`, only that Google Workspace's accounts. */
|
|
127
|
+
export function google(
|
|
128
|
+
options: Credentials & { id?: string; label?: string; domain?: string },
|
|
129
|
+
): OidcSigninProvider {
|
|
130
|
+
const domain = options.domain?.trim().toLowerCase() || null;
|
|
131
|
+
return new OidcSigninProvider({
|
|
132
|
+
...oidcConfig({
|
|
133
|
+
id: options.id ?? 'google',
|
|
134
|
+
label: options.label ?? 'Google',
|
|
135
|
+
issuer: 'https://accounts.google.com',
|
|
136
|
+
clientId: options.clientId,
|
|
137
|
+
clientSecret: options.clientSecret,
|
|
138
|
+
// `select_account` stops Google from silently reusing whichever of
|
|
139
|
+
// several signed-in accounts was used last.
|
|
140
|
+
authorizationParams: domain
|
|
141
|
+
? { hd: domain, prompt: 'select_account' }
|
|
142
|
+
: { prompt: 'select_account' },
|
|
143
|
+
}),
|
|
144
|
+
brand: 'google',
|
|
145
|
+
hostedDomain: domain,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const TENANT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Microsoft Entra ID, for one tenant.
|
|
153
|
+
*
|
|
154
|
+
* The tenant must be its GUID. Entra's multi-tenant endpoints (`common`,
|
|
155
|
+
* `organizations`) publish an issuer template rather than an issuer, which
|
|
156
|
+
* standard OIDC validation rejects, and "any work account in the world" is not
|
|
157
|
+
* an audience a secrets manager wants anyway.
|
|
158
|
+
*
|
|
159
|
+
* Entra email claims do not prove address ownership. Sign in through another
|
|
160
|
+
* provider and link the Microsoft account first; later sign-ins use its subject.
|
|
161
|
+
*/
|
|
162
|
+
export function microsoft(
|
|
163
|
+
options: Credentials & { id?: string; label?: string; tenant: string },
|
|
164
|
+
): OidcSigninProvider {
|
|
165
|
+
const tenant = options.tenant.trim();
|
|
166
|
+
if (!TENANT_ID.test(tenant)) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
'the Microsoft sign-in tenant must be the directory (tenant) ID, a GUID, not a domain or "common"',
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
return new OidcSigninProvider({
|
|
172
|
+
...oidcConfig({
|
|
173
|
+
id: options.id ?? 'microsoft',
|
|
174
|
+
label: options.label ?? 'Microsoft',
|
|
175
|
+
issuer: `https://login.microsoftonline.com/${tenant.toLowerCase()}/v2.0`,
|
|
176
|
+
clientId: options.clientId,
|
|
177
|
+
clientSecret: options.clientSecret,
|
|
178
|
+
authorizationParams: { prompt: 'select_account' },
|
|
179
|
+
}),
|
|
180
|
+
brand: 'microsoft',
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** GitHub accounts; with `organization`, only that organization's members. */
|
|
185
|
+
export function github(
|
|
186
|
+
options: Credentials & {
|
|
187
|
+
id?: string;
|
|
188
|
+
label?: string;
|
|
189
|
+
organization?: string;
|
|
190
|
+
webUrl?: string;
|
|
191
|
+
apiUrl?: string;
|
|
192
|
+
},
|
|
193
|
+
): GitHubSigninProvider {
|
|
194
|
+
const id = checkId(options.id ?? 'github');
|
|
195
|
+
return new GitHubSigninProvider({
|
|
196
|
+
id,
|
|
197
|
+
label: options.label ?? 'GitHub',
|
|
198
|
+
brand: 'github',
|
|
199
|
+
...credentials(id, options),
|
|
200
|
+
webUrl: baseUrl(options.webUrl ?? 'https://github.com', `sign-in provider ${id} web URL`),
|
|
201
|
+
apiUrl: baseUrl(options.apiUrl ?? 'https://api.github.com', `sign-in provider ${id} API URL`),
|
|
202
|
+
organization: options.organization?.trim() || null,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function isLoopback(url: URL): boolean {
|
|
207
|
+
return url.hostname === '127.0.0.1' || url.hostname === 'localhost' || url.hostname === '[::1]';
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** HTTPS, except on loopback, where the dev IdP runs. */
|
|
211
|
+
function checkedUrl(value: string, what: string): URL {
|
|
212
|
+
let url: URL;
|
|
213
|
+
try {
|
|
214
|
+
url = new URL(value);
|
|
215
|
+
} catch {
|
|
216
|
+
throw new Error(`${what} must be an absolute URL`);
|
|
217
|
+
}
|
|
218
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && isLoopback(url))) {
|
|
219
|
+
throw new Error(`${what} must use HTTPS`);
|
|
220
|
+
}
|
|
221
|
+
if (url.username !== '' || url.password !== '' || url.search !== '' || url.hash !== '') {
|
|
222
|
+
throw new Error(`${what} must not carry credentials, a query or a fragment`);
|
|
223
|
+
}
|
|
224
|
+
return url;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Issuers are compared byte for byte with the `iss` of every token, so the
|
|
229
|
+
* configured value is kept as written, less a trailing slash nobody means.
|
|
230
|
+
*/
|
|
231
|
+
function issuerUrl(value: string, what: string): string {
|
|
232
|
+
checkedUrl(value, `${what} issuer`);
|
|
233
|
+
return value.endsWith('/') ? value.slice(0, -1) : value;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function baseUrl(value: string, what: string): string {
|
|
237
|
+
const url = checkedUrl(value, what);
|
|
238
|
+
return url.href.endsWith('/') ? url.href.slice(0, -1) : url.href;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function publicOrigin(value: string): string {
|
|
242
|
+
const url = checkedUrl(value, 'the public URL');
|
|
243
|
+
if (url.pathname !== '/') {
|
|
244
|
+
throw new Error('the public URL must be an origin, with no path');
|
|
245
|
+
}
|
|
246
|
+
return url.origin;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Sign-in configuration, checked: at least one provider, each whole, ids unique, lifetimes sane. */
|
|
250
|
+
export function defineSignin(options: {
|
|
251
|
+
publicUrl: string;
|
|
252
|
+
providers: SigninProvider[];
|
|
253
|
+
page?: { title?: string; note?: string };
|
|
254
|
+
browserSessionHours?: number;
|
|
255
|
+
cliSessionDays?: number;
|
|
256
|
+
}): SigninConfig {
|
|
257
|
+
if (options.providers.length === 0) {
|
|
258
|
+
throw new Error('sign-in needs at least one provider');
|
|
259
|
+
}
|
|
260
|
+
const seen = new Set<string>();
|
|
261
|
+
for (const provider of options.providers) {
|
|
262
|
+
checkProvider(provider);
|
|
263
|
+
if (seen.has(provider.id)) throw new Error(`sign-in provider id "${provider.id}" is used twice`);
|
|
264
|
+
seen.add(provider.id);
|
|
265
|
+
}
|
|
266
|
+
return {
|
|
267
|
+
publicUrl: publicOrigin(options.publicUrl),
|
|
268
|
+
providers: options.providers,
|
|
269
|
+
page: {
|
|
270
|
+
title: options.page?.title ?? 'Sign in to coffre',
|
|
271
|
+
note: options.page?.note ?? null,
|
|
272
|
+
},
|
|
273
|
+
browserSessionHours: lifetime(options.browserSessionHours, 'browserSessionHours', 12, 24 * 7),
|
|
274
|
+
cliSessionDays: lifetime(options.cliSessionDays, 'cliSessionDays', 30, 365),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** A deployment's own provider is checked like coffre's: it is only typed, not trusted. */
|
|
279
|
+
function checkProvider(provider: SigninProvider): void {
|
|
280
|
+
checkId(provider?.id);
|
|
281
|
+
if (typeof provider.issuer !== 'string' || provider.issuer.trim() === '') {
|
|
282
|
+
throw new Error(`sign-in provider ${provider.id} needs a stable issuer`);
|
|
283
|
+
}
|
|
284
|
+
if (typeof provider.label !== 'string' || provider.label.trim() === '') {
|
|
285
|
+
throw new Error(`sign-in provider ${provider.id} needs a label`);
|
|
286
|
+
}
|
|
287
|
+
if (!BRANDS.includes(provider.brand)) {
|
|
288
|
+
throw new Error(`sign-in provider ${provider.id}'s brand must be one of ${BRANDS.join(', ')}`);
|
|
289
|
+
}
|
|
290
|
+
if (typeof provider.start !== 'function' || typeof provider.finish !== 'function') {
|
|
291
|
+
throw new Error(`sign-in provider ${provider.id} needs start() and finish()`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function lifetime(value: number | undefined, name: string, fallback: number, max: number): number {
|
|
296
|
+
if (value === undefined) return fallback;
|
|
297
|
+
if (!Number.isFinite(value) || value <= 0 || value > max) {
|
|
298
|
+
throw new Error(`${name} must be a number above 0 and at most ${max}`);
|
|
299
|
+
}
|
|
300
|
+
return value;
|
|
301
|
+
}
|