@coffre/core 0.0.0 → 0.1.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/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/dist/identity.js
ADDED
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes } from "node:crypto";
|
|
2
|
+
import * as oauth from "oauth4webapi";
|
|
3
|
+
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
4
|
+
//#region src/identity/signin/types.ts
|
|
5
|
+
var SigninError = class extends Error {
|
|
6
|
+
code;
|
|
7
|
+
constructor(code, message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.code = code;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
//#endregion
|
|
13
|
+
//#region src/identity/signin/github.ts
|
|
14
|
+
/**
|
|
15
|
+
* GitHub's OAuth web flow, which is OAuth 2.0 without OpenID Connect: no ID
|
|
16
|
+
* token, so the profile comes from the REST API with the access token the
|
|
17
|
+
* code is exchanged for. That token is used for these two or three calls and
|
|
18
|
+
* then dropped; coffre never stores it.
|
|
19
|
+
*
|
|
20
|
+
* Works with an OAuth App or a GitHub App's user authorization, on github.com
|
|
21
|
+
* or GitHub Enterprise Server.
|
|
22
|
+
*/
|
|
23
|
+
var GitHubSigninProvider = class {
|
|
24
|
+
id;
|
|
25
|
+
issuer;
|
|
26
|
+
label;
|
|
27
|
+
brand;
|
|
28
|
+
/** What it was made from, as checked. */
|
|
29
|
+
config;
|
|
30
|
+
#fetch;
|
|
31
|
+
constructor(config, options = {}) {
|
|
32
|
+
this.id = config.id;
|
|
33
|
+
this.issuer = config.apiUrl;
|
|
34
|
+
this.label = config.label;
|
|
35
|
+
this.brand = config.brand;
|
|
36
|
+
this.config = config;
|
|
37
|
+
this.#fetch = options.fetch ?? ((input, init) => fetch(input, init));
|
|
38
|
+
}
|
|
39
|
+
async start(redirectUri, options = {}) {
|
|
40
|
+
const pending = {
|
|
41
|
+
state: oauth.generateRandomState(),
|
|
42
|
+
codeVerifier: oauth.generateRandomCodeVerifier(),
|
|
43
|
+
nonce: null
|
|
44
|
+
};
|
|
45
|
+
const url = new URL(`${this.config.webUrl}/login/oauth/authorize`);
|
|
46
|
+
const params = url.searchParams;
|
|
47
|
+
params.set("client_id", this.config.clientId);
|
|
48
|
+
params.set("redirect_uri", redirectUri);
|
|
49
|
+
params.set("scope", this.config.organization ? "read:user user:email read:org" : "read:user user:email");
|
|
50
|
+
params.set("state", pending.state);
|
|
51
|
+
params.set("code_challenge", await oauth.calculatePKCECodeChallenge(pending.codeVerifier));
|
|
52
|
+
params.set("code_challenge_method", "S256");
|
|
53
|
+
params.set("allow_signup", "false");
|
|
54
|
+
if (options.loginHint !== void 0) params.set("login", options.loginHint);
|
|
55
|
+
return {
|
|
56
|
+
url,
|
|
57
|
+
pending
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
async finish(callbackUrl, redirectUri, pending) {
|
|
61
|
+
const label = this.config.label;
|
|
62
|
+
const query = callbackUrl.searchParams;
|
|
63
|
+
if (query.get("state") !== pending.state) throw new SigninError("state_mismatch", `the ${label} callback does not match this sign-in`);
|
|
64
|
+
const refused = query.get("error");
|
|
65
|
+
if (refused !== null) throw new SigninError("provider_denied", `${label} refused the sign-in: ${refused}`);
|
|
66
|
+
const code = query.get("code");
|
|
67
|
+
if (code === null || code === "") throw new SigninError("invalid_response", `the ${label} callback carries no code`);
|
|
68
|
+
const token = await this.#exchange(code, redirectUri, pending.codeVerifier);
|
|
69
|
+
const user = await this.#api(token, "/user");
|
|
70
|
+
if (typeof user?.id !== "number") throw new SigninError("invalid_response", `${label} did not say who signed in`);
|
|
71
|
+
const organization = this.config.organization;
|
|
72
|
+
if (organization !== null && !await this.#isMember(token, organization)) throw new SigninError("not_in_organization", `only members of the ${organization} organization may sign in with ${label}`);
|
|
73
|
+
const listed = await this.#api(token, "/user/emails");
|
|
74
|
+
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());
|
|
75
|
+
return {
|
|
76
|
+
subject: String(user.id),
|
|
77
|
+
emails: [...new Set(emails)],
|
|
78
|
+
name: user.name ?? user.login
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
async #exchange(code, redirectUri, codeVerifier) {
|
|
82
|
+
const label = this.config.label;
|
|
83
|
+
const response = await this.#send(`${this.config.webUrl}/login/oauth/access_token`, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: {
|
|
86
|
+
accept: "application/json",
|
|
87
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
88
|
+
},
|
|
89
|
+
body: new URLSearchParams({
|
|
90
|
+
client_id: this.config.clientId,
|
|
91
|
+
client_secret: this.config.clientSecret,
|
|
92
|
+
code,
|
|
93
|
+
redirect_uri: redirectUri,
|
|
94
|
+
code_verifier: codeVerifier
|
|
95
|
+
})
|
|
96
|
+
});
|
|
97
|
+
const body = await readJson(response);
|
|
98
|
+
if (!response.ok || body === null) throw new SigninError("invalid_response", `${label} answered the code exchange with ${response.status}`);
|
|
99
|
+
if (typeof body.error === "string") throw new SigninError("provider_denied", `${label} refused the code: ${body.error}`);
|
|
100
|
+
if (typeof body.access_token !== "string" || body.access_token === "") throw new SigninError("invalid_response", `${label} returned no access token`);
|
|
101
|
+
return body.access_token;
|
|
102
|
+
}
|
|
103
|
+
async #isMember(token, organization) {
|
|
104
|
+
const response = await this.#send(`${this.config.apiUrl}/user/memberships/orgs/${encodeURIComponent(organization)}`, { headers: apiHeaders(token) });
|
|
105
|
+
if (response.status === 404 || response.status === 403) return false;
|
|
106
|
+
const body = await readJson(response);
|
|
107
|
+
if (!response.ok || body === null) throw new SigninError("invalid_response", `${this.config.label} answered the membership check with ${response.status}`);
|
|
108
|
+
return body.state === "active";
|
|
109
|
+
}
|
|
110
|
+
async #api(token, path) {
|
|
111
|
+
const response = await this.#send(`${this.config.apiUrl}${path}`, { headers: apiHeaders(token) });
|
|
112
|
+
if (!response.ok) throw new SigninError("invalid_response", `${this.config.label} answered ${path} with ${response.status}`);
|
|
113
|
+
return await readJson(response);
|
|
114
|
+
}
|
|
115
|
+
async #send(url, init) {
|
|
116
|
+
try {
|
|
117
|
+
return await this.#fetch(url, {
|
|
118
|
+
...init,
|
|
119
|
+
redirect: "manual",
|
|
120
|
+
signal: AbortSignal.timeout(1e4)
|
|
121
|
+
});
|
|
122
|
+
} catch (cause) {
|
|
123
|
+
const error = new SigninError("provider_unavailable", `${this.config.label} could not be reached`);
|
|
124
|
+
error.cause = cause;
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
function apiHeaders(token) {
|
|
130
|
+
return {
|
|
131
|
+
accept: "application/vnd.github+json",
|
|
132
|
+
authorization: `Bearer ${token}`,
|
|
133
|
+
"user-agent": "coffre",
|
|
134
|
+
"x-github-api-version": "2022-11-28"
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
async function readJson(response) {
|
|
138
|
+
try {
|
|
139
|
+
return await response.json();
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
//#endregion
|
|
145
|
+
//#region src/identity/signin/oidc.ts
|
|
146
|
+
/** Discovery documents change rarely; an hour keeps one fetch off every sign-in. */
|
|
147
|
+
const DISCOVERY_TTL_MS = 36e5;
|
|
148
|
+
/**
|
|
149
|
+
* The authorization code flow with PKCE, against any OpenID Connect issuer.
|
|
150
|
+
*
|
|
151
|
+
* Protocol work is delegated to oauth4webapi: discovery with issuer
|
|
152
|
+
* validation, the RFC 9207 `iss` check on the callback, the token request and
|
|
153
|
+
* ID token claim validation (issuer, audience, expiry, nonce). The ID token is
|
|
154
|
+
* received straight from the token endpoint over TLS with client
|
|
155
|
+
* authentication, which OpenID Connect Core 3.1.3.7 accepts in place of
|
|
156
|
+
* checking its signature.
|
|
157
|
+
*/
|
|
158
|
+
var OidcSigninProvider = class {
|
|
159
|
+
id;
|
|
160
|
+
issuer;
|
|
161
|
+
label;
|
|
162
|
+
brand;
|
|
163
|
+
/** What it was made from, as checked. */
|
|
164
|
+
config;
|
|
165
|
+
#fetch;
|
|
166
|
+
#insecure;
|
|
167
|
+
#discovered = null;
|
|
168
|
+
constructor(config, options = {}) {
|
|
169
|
+
this.id = config.id;
|
|
170
|
+
this.issuer = config.issuer;
|
|
171
|
+
this.label = config.label;
|
|
172
|
+
this.brand = config.brand;
|
|
173
|
+
this.config = config;
|
|
174
|
+
this.#fetch = options.fetch;
|
|
175
|
+
this.#insecure = new URL(config.issuer).protocol === "http:";
|
|
176
|
+
}
|
|
177
|
+
#http() {
|
|
178
|
+
const options = { signal: () => AbortSignal.timeout(1e4) };
|
|
179
|
+
if (this.#fetch !== void 0) {
|
|
180
|
+
const fetchImpl = this.#fetch;
|
|
181
|
+
options[oauth.customFetch] = (url, init) => fetchImpl(url, init);
|
|
182
|
+
}
|
|
183
|
+
if (this.#insecure) options[oauth.allowInsecureRequests] = true;
|
|
184
|
+
return options;
|
|
185
|
+
}
|
|
186
|
+
get #client() {
|
|
187
|
+
return { client_id: this.config.clientId };
|
|
188
|
+
}
|
|
189
|
+
async #server() {
|
|
190
|
+
if (this.#discovered !== null && Date.now() - this.#discovered.fetchedAt < DISCOVERY_TTL_MS) return this.#discovered.server;
|
|
191
|
+
const issuer = new URL(this.config.issuer);
|
|
192
|
+
let server;
|
|
193
|
+
try {
|
|
194
|
+
const response = await oauth.discoveryRequest(issuer, {
|
|
195
|
+
...this.#http(),
|
|
196
|
+
algorithm: "oidc"
|
|
197
|
+
});
|
|
198
|
+
server = await oauth.processDiscoveryResponse(issuer, response);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
throw unavailable(this.config.label, error);
|
|
201
|
+
}
|
|
202
|
+
this.#discovered = {
|
|
203
|
+
server,
|
|
204
|
+
fetchedAt: Date.now()
|
|
205
|
+
};
|
|
206
|
+
return server;
|
|
207
|
+
}
|
|
208
|
+
async start(redirectUri, options = {}) {
|
|
209
|
+
const server = await this.#server();
|
|
210
|
+
if (server.authorization_endpoint === void 0) throw new SigninError("invalid_response", `${this.config.label} publishes no authorization endpoint`);
|
|
211
|
+
const pending = {
|
|
212
|
+
state: oauth.generateRandomState(),
|
|
213
|
+
codeVerifier: oauth.generateRandomCodeVerifier(),
|
|
214
|
+
nonce: oauth.generateRandomNonce()
|
|
215
|
+
};
|
|
216
|
+
const url = new URL(server.authorization_endpoint);
|
|
217
|
+
const params = url.searchParams;
|
|
218
|
+
params.set("client_id", this.config.clientId);
|
|
219
|
+
params.set("redirect_uri", redirectUri);
|
|
220
|
+
params.set("response_type", "code");
|
|
221
|
+
params.set("scope", this.config.scopes.join(" "));
|
|
222
|
+
params.set("state", pending.state);
|
|
223
|
+
params.set("nonce", pending.nonce);
|
|
224
|
+
params.set("code_challenge", await oauth.calculatePKCECodeChallenge(pending.codeVerifier));
|
|
225
|
+
params.set("code_challenge_method", "S256");
|
|
226
|
+
for (const [name, value] of Object.entries(this.config.authorizationParams)) params.set(name, value);
|
|
227
|
+
if (options.loginHint !== void 0) params.set("login_hint", options.loginHint);
|
|
228
|
+
return {
|
|
229
|
+
url,
|
|
230
|
+
pending
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
async finish(callbackUrl, redirectUri, pending) {
|
|
234
|
+
const server = await this.#server();
|
|
235
|
+
const label = this.config.label;
|
|
236
|
+
let params;
|
|
237
|
+
try {
|
|
238
|
+
params = oauth.validateAuthResponse(server, this.#client, callbackUrl, pending.state);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (error instanceof oauth.AuthorizationResponseError) throw new SigninError("provider_denied", `${label} refused the sign-in: ${error.error}`);
|
|
241
|
+
throw new SigninError("state_mismatch", `the ${label} callback does not match this sign-in`);
|
|
242
|
+
}
|
|
243
|
+
let claims;
|
|
244
|
+
try {
|
|
245
|
+
const response = await oauth.authorizationCodeGrantRequest(server, this.#client, oauth.ClientSecretPost(this.config.clientSecret), params, redirectUri, pending.codeVerifier, this.#http());
|
|
246
|
+
const result = await oauth.processAuthorizationCodeResponse(server, this.#client, response, {
|
|
247
|
+
expectedNonce: pending.nonce ?? oauth.expectNoNonce,
|
|
248
|
+
requireIdToken: true
|
|
249
|
+
});
|
|
250
|
+
claims = oauth.getValidatedIdTokenClaims(result);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
if (error instanceof SigninError) throw error;
|
|
253
|
+
if (error instanceof oauth.ResponseBodyError) throw new SigninError("provider_denied", `${label} refused the code: ${error.error}`);
|
|
254
|
+
if (isNetworkError(error)) throw unavailable(label, error);
|
|
255
|
+
throw new SigninError("invalid_response", `${label} returned a token coffre cannot trust`);
|
|
256
|
+
}
|
|
257
|
+
return this.#profile(claims);
|
|
258
|
+
}
|
|
259
|
+
#profile(claims) {
|
|
260
|
+
const label = this.config.label;
|
|
261
|
+
if (this.config.hostedDomain !== null && claims.hd !== this.config.hostedDomain) throw new SigninError("wrong_domain", `only ${this.config.hostedDomain} accounts may sign in with ${label}`);
|
|
262
|
+
const email = typeof claims.email === "string" ? claims.email.trim().toLowerCase() : "";
|
|
263
|
+
const authoritative = this.config.brand !== "google" || email.endsWith("@gmail.com") || typeof claims.hd === "string" && claims.hd !== "";
|
|
264
|
+
const emails = email !== "" && claims.email_verified === true && authoritative && this.config.brand !== "microsoft" ? [email] : [];
|
|
265
|
+
return {
|
|
266
|
+
subject: claims.sub,
|
|
267
|
+
emails,
|
|
268
|
+
name: typeof claims.name === "string" ? claims.name : null
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
function isNetworkError(error) {
|
|
273
|
+
return error instanceof TypeError || error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
|
|
274
|
+
}
|
|
275
|
+
function unavailable(label, cause) {
|
|
276
|
+
const error = new SigninError("provider_unavailable", `${label} could not be reached`);
|
|
277
|
+
error.cause = cause;
|
|
278
|
+
return error;
|
|
279
|
+
}
|
|
280
|
+
//#endregion
|
|
281
|
+
//#region src/identity/signin/config.ts
|
|
282
|
+
/**
|
|
283
|
+
* Sign-in providers, and the sign-in they make up.
|
|
284
|
+
*
|
|
285
|
+
* coffre speaks two protocols: OpenID Connect, which covers Google, Microsoft,
|
|
286
|
+
* Okta, Auth0, Keycloak, Authentik, Clerk, WorkOS and nearly everyone else,
|
|
287
|
+
* and GitHub's own OAuth, because GitHub is the one provider people want that
|
|
288
|
+
* does not speak OIDC. Everything named here is a preset of one of those two:
|
|
289
|
+
* `google()` is OIDC with Google's issuer and a Workspace-domain check,
|
|
290
|
+
* `microsoft()` is OIDC with a tenant's issuer. A provider that is not listed
|
|
291
|
+
* needs no code, only `oidc()` and its issuer URL.
|
|
292
|
+
*
|
|
293
|
+
* A deployment writes them in its own code, where a misspelt option fails
|
|
294
|
+
* the typecheck:
|
|
295
|
+
*
|
|
296
|
+
* signin({ providers: [github({ clientId, clientSecret: env.GITHUB_SECRET, organization: 'acme' })] })
|
|
297
|
+
*
|
|
298
|
+
* Each returns a `SigninProvider`, and a provider that speaks neither can be
|
|
299
|
+
* one of the deployment's own, in the same list.
|
|
300
|
+
*/
|
|
301
|
+
const PROVIDER_ID = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
302
|
+
const BRANDS = [
|
|
303
|
+
"github",
|
|
304
|
+
"google",
|
|
305
|
+
"microsoft",
|
|
306
|
+
"oidc"
|
|
307
|
+
];
|
|
308
|
+
function checkId(id) {
|
|
309
|
+
if (typeof id !== "string" || !PROVIDER_ID.test(id)) throw new Error(`sign-in provider id "${id}" must be 1-32 lowercase letters, digits or dashes`);
|
|
310
|
+
return id;
|
|
311
|
+
}
|
|
312
|
+
function credentials(id, options) {
|
|
313
|
+
if (!options.clientId || !options.clientSecret) throw new Error(`sign-in provider ${id} needs a client id and a client secret`);
|
|
314
|
+
return {
|
|
315
|
+
clientId: options.clientId,
|
|
316
|
+
clientSecret: options.clientSecret
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Any OpenID Connect provider, by issuer URL.
|
|
321
|
+
*
|
|
322
|
+
* Okta, Auth0, Keycloak, Authentik, Zitadel, Clerk, WorkOS, Dex and the like
|
|
323
|
+
* all live here; so does a broker, for providers that only speak SAML or LDAP.
|
|
324
|
+
*/
|
|
325
|
+
function oidc(options) {
|
|
326
|
+
return new OidcSigninProvider(oidcConfig(options));
|
|
327
|
+
}
|
|
328
|
+
function oidcConfig(options) {
|
|
329
|
+
const id = checkId(options.id);
|
|
330
|
+
return {
|
|
331
|
+
id,
|
|
332
|
+
label: options.label,
|
|
333
|
+
brand: "oidc",
|
|
334
|
+
...credentials(id, options),
|
|
335
|
+
issuer: issuerUrl(options.issuer, `sign-in provider ${id}`),
|
|
336
|
+
scopes: options.scopes ?? [
|
|
337
|
+
"openid",
|
|
338
|
+
"email",
|
|
339
|
+
"profile"
|
|
340
|
+
],
|
|
341
|
+
authorizationParams: options.authorizationParams ?? {},
|
|
342
|
+
hostedDomain: null
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
/** Google accounts; with `domain`, only that Google Workspace's accounts. */
|
|
346
|
+
function google(options) {
|
|
347
|
+
const domain = options.domain?.trim().toLowerCase() || null;
|
|
348
|
+
return new OidcSigninProvider({
|
|
349
|
+
...oidcConfig({
|
|
350
|
+
id: options.id ?? "google",
|
|
351
|
+
label: options.label ?? "Google",
|
|
352
|
+
issuer: "https://accounts.google.com",
|
|
353
|
+
clientId: options.clientId,
|
|
354
|
+
clientSecret: options.clientSecret,
|
|
355
|
+
authorizationParams: domain ? {
|
|
356
|
+
hd: domain,
|
|
357
|
+
prompt: "select_account"
|
|
358
|
+
} : { prompt: "select_account" }
|
|
359
|
+
}),
|
|
360
|
+
brand: "google",
|
|
361
|
+
hostedDomain: domain
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
const TENANT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
365
|
+
/**
|
|
366
|
+
* Microsoft Entra ID, for one tenant.
|
|
367
|
+
*
|
|
368
|
+
* The tenant must be its GUID. Entra's multi-tenant endpoints (`common`,
|
|
369
|
+
* `organizations`) publish an issuer template rather than an issuer, which
|
|
370
|
+
* standard OIDC validation rejects, and "any work account in the world" is not
|
|
371
|
+
* an audience a secrets manager wants anyway.
|
|
372
|
+
*
|
|
373
|
+
* Entra email claims do not prove address ownership. Sign in through another
|
|
374
|
+
* provider and link the Microsoft account first; later sign-ins use its subject.
|
|
375
|
+
*/
|
|
376
|
+
function microsoft(options) {
|
|
377
|
+
const tenant = options.tenant.trim();
|
|
378
|
+
if (!TENANT_ID.test(tenant)) throw new Error("the Microsoft sign-in tenant must be the directory (tenant) ID, a GUID, not a domain or \"common\"");
|
|
379
|
+
return new OidcSigninProvider({
|
|
380
|
+
...oidcConfig({
|
|
381
|
+
id: options.id ?? "microsoft",
|
|
382
|
+
label: options.label ?? "Microsoft",
|
|
383
|
+
issuer: `https://login.microsoftonline.com/${tenant.toLowerCase()}/v2.0`,
|
|
384
|
+
clientId: options.clientId,
|
|
385
|
+
clientSecret: options.clientSecret,
|
|
386
|
+
authorizationParams: { prompt: "select_account" }
|
|
387
|
+
}),
|
|
388
|
+
brand: "microsoft"
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
/** GitHub accounts; with `organization`, only that organization's members. */
|
|
392
|
+
function github(options) {
|
|
393
|
+
const id = checkId(options.id ?? "github");
|
|
394
|
+
return new GitHubSigninProvider({
|
|
395
|
+
id,
|
|
396
|
+
label: options.label ?? "GitHub",
|
|
397
|
+
brand: "github",
|
|
398
|
+
...credentials(id, options),
|
|
399
|
+
webUrl: baseUrl(options.webUrl ?? "https://github.com", `sign-in provider ${id} web URL`),
|
|
400
|
+
apiUrl: baseUrl(options.apiUrl ?? "https://api.github.com", `sign-in provider ${id} API URL`),
|
|
401
|
+
organization: options.organization?.trim() || null
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
function isLoopback(url) {
|
|
405
|
+
return url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "[::1]";
|
|
406
|
+
}
|
|
407
|
+
/** HTTPS, except on loopback, where the dev IdP runs. */
|
|
408
|
+
function checkedUrl(value, what) {
|
|
409
|
+
let url;
|
|
410
|
+
try {
|
|
411
|
+
url = new URL(value);
|
|
412
|
+
} catch {
|
|
413
|
+
throw new Error(`${what} must be an absolute URL`);
|
|
414
|
+
}
|
|
415
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback(url))) throw new Error(`${what} must use HTTPS`);
|
|
416
|
+
if (url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "") throw new Error(`${what} must not carry credentials, a query or a fragment`);
|
|
417
|
+
return url;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Issuers are compared byte for byte with the `iss` of every token, so the
|
|
421
|
+
* configured value is kept as written, less a trailing slash nobody means.
|
|
422
|
+
*/
|
|
423
|
+
function issuerUrl(value, what) {
|
|
424
|
+
checkedUrl(value, `${what} issuer`);
|
|
425
|
+
return value.endsWith("/") ? value.slice(0, -1) : value;
|
|
426
|
+
}
|
|
427
|
+
function baseUrl(value, what) {
|
|
428
|
+
const url = checkedUrl(value, what);
|
|
429
|
+
return url.href.endsWith("/") ? url.href.slice(0, -1) : url.href;
|
|
430
|
+
}
|
|
431
|
+
function publicOrigin(value) {
|
|
432
|
+
const url = checkedUrl(value, "the public URL");
|
|
433
|
+
if (url.pathname !== "/") throw new Error("the public URL must be an origin, with no path");
|
|
434
|
+
return url.origin;
|
|
435
|
+
}
|
|
436
|
+
/** Sign-in configuration, checked: at least one provider, each whole, ids unique, lifetimes sane. */
|
|
437
|
+
function defineSignin(options) {
|
|
438
|
+
if (options.providers.length === 0) throw new Error("sign-in needs at least one provider");
|
|
439
|
+
const seen = /* @__PURE__ */ new Set();
|
|
440
|
+
for (const provider of options.providers) {
|
|
441
|
+
checkProvider(provider);
|
|
442
|
+
if (seen.has(provider.id)) throw new Error(`sign-in provider id "${provider.id}" is used twice`);
|
|
443
|
+
seen.add(provider.id);
|
|
444
|
+
}
|
|
445
|
+
return {
|
|
446
|
+
publicUrl: publicOrigin(options.publicUrl),
|
|
447
|
+
providers: options.providers,
|
|
448
|
+
page: {
|
|
449
|
+
title: options.page?.title ?? "Sign in to coffre",
|
|
450
|
+
note: options.page?.note ?? null
|
|
451
|
+
},
|
|
452
|
+
browserSessionHours: lifetime(options.browserSessionHours, "browserSessionHours", 12, 168),
|
|
453
|
+
cliSessionDays: lifetime(options.cliSessionDays, "cliSessionDays", 30, 365)
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
/** A deployment's own provider is checked like coffre's: it is only typed, not trusted. */
|
|
457
|
+
function checkProvider(provider) {
|
|
458
|
+
checkId(provider?.id);
|
|
459
|
+
if (typeof provider.issuer !== "string" || provider.issuer.trim() === "") throw new Error(`sign-in provider ${provider.id} needs a stable issuer`);
|
|
460
|
+
if (typeof provider.label !== "string" || provider.label.trim() === "") throw new Error(`sign-in provider ${provider.id} needs a label`);
|
|
461
|
+
if (!BRANDS.includes(provider.brand)) throw new Error(`sign-in provider ${provider.id}'s brand must be one of ${BRANDS.join(", ")}`);
|
|
462
|
+
if (typeof provider.start !== "function" || typeof provider.finish !== "function") throw new Error(`sign-in provider ${provider.id} needs start() and finish()`);
|
|
463
|
+
}
|
|
464
|
+
function lifetime(value, name, fallback, max) {
|
|
465
|
+
if (value === void 0) return fallback;
|
|
466
|
+
if (!Number.isFinite(value) || value <= 0 || value > max) throw new Error(`${name} must be a number above 0 and at most ${max}`);
|
|
467
|
+
return value;
|
|
468
|
+
}
|
|
469
|
+
//#endregion
|
|
470
|
+
//#region src/identity/auth.ts
|
|
471
|
+
function httpsOrigin(what, value) {
|
|
472
|
+
let url;
|
|
473
|
+
try {
|
|
474
|
+
url = new URL(value);
|
|
475
|
+
} catch {
|
|
476
|
+
throw new Error(`${what} must be an absolute URL`);
|
|
477
|
+
}
|
|
478
|
+
if (url.protocol !== "https:" || url.username !== "" || url.password !== "" || url.pathname !== "/" || url.search !== "" || url.hash !== "") throw new Error(`${what} must be an HTTPS origin, with no path, query or credentials`);
|
|
479
|
+
return url;
|
|
480
|
+
}
|
|
481
|
+
function checkAudience(audience) {
|
|
482
|
+
if (audience.length === 0 || audience.length > 64 || /\s/.test(audience)) throw new Error("the Access audience must be a non-whitespace Access AUD tag of at most 64 characters");
|
|
483
|
+
return audience;
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Cloudflare Access in front of coffre: every request carries Access's
|
|
487
|
+
* signed assertion, checked against the team's keys and the application's
|
|
488
|
+
* AUD tag.
|
|
489
|
+
*
|
|
490
|
+
* cloudflareAccess({ teamDomain: 'acme.cloudflareaccess.com', audience: env.ACCESS_AUD })
|
|
491
|
+
*/
|
|
492
|
+
function cloudflareAccess(options) {
|
|
493
|
+
const domain = options.teamDomain.trim();
|
|
494
|
+
const issuerUrl = httpsOrigin("the Access team domain", domain.includes("://") ? domain : `https://${domain}`);
|
|
495
|
+
if (issuerUrl.port !== "" || !issuerUrl.hostname.endsWith(".cloudflareaccess.com") || issuerUrl.hostname === "cloudflareaccess.com") throw new Error("the Access team domain must be <team>.cloudflareaccess.com");
|
|
496
|
+
const issuer = issuerUrl.origin;
|
|
497
|
+
const config = {
|
|
498
|
+
mode: "cloudflare",
|
|
499
|
+
access: {
|
|
500
|
+
issuer,
|
|
501
|
+
jwksUrl: `${issuer}/cdn-cgi/access/certs`,
|
|
502
|
+
audience: checkAudience(options.audience.trim())
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
return { resolve: () => config };
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* coffre's own sign-in page, with these providers:
|
|
509
|
+
*
|
|
510
|
+
* signin({ providers: [github({ clientId, clientSecret, organization: 'acme' })] })
|
|
511
|
+
*/
|
|
512
|
+
function signin(options) {
|
|
513
|
+
defineSignin({
|
|
514
|
+
...toSignin(options),
|
|
515
|
+
publicUrl: "https://coffre.invalid"
|
|
516
|
+
});
|
|
517
|
+
return { resolve: (publicUrl) => ({
|
|
518
|
+
mode: "signin",
|
|
519
|
+
signin: defineSignin({
|
|
520
|
+
...toSignin(options),
|
|
521
|
+
publicUrl
|
|
522
|
+
})
|
|
523
|
+
}) };
|
|
524
|
+
}
|
|
525
|
+
function toSignin(options) {
|
|
526
|
+
return {
|
|
527
|
+
providers: options.providers,
|
|
528
|
+
page: {
|
|
529
|
+
title: options.title,
|
|
530
|
+
note: options.note
|
|
531
|
+
},
|
|
532
|
+
browserSessionHours: options.browserSessionHours,
|
|
533
|
+
cliSessionDays: options.cliSessionDays
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
//#endregion
|
|
537
|
+
//#region src/identity/signin/sealed.ts
|
|
538
|
+
const IV_BYTES = 12;
|
|
539
|
+
const VERSION = "v1";
|
|
540
|
+
/**
|
|
541
|
+
* Derive a purpose-bound key from one of coffre's existing 32-byte secrets.
|
|
542
|
+
*
|
|
543
|
+
* HKDF with a distinct `info` string gives a key that is independent of the
|
|
544
|
+
* source for every practical purpose, so the sign-in cookie needs no new
|
|
545
|
+
* secret to provision and a leak of one derived key says nothing about the
|
|
546
|
+
* others.
|
|
547
|
+
*/
|
|
548
|
+
function deriveKey(secret, purpose) {
|
|
549
|
+
return Buffer.from(hkdfSync("sha256", secret, Buffer.alloc(0), `coffre/${purpose}`, 32));
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Encrypt a small JSON value for a round trip through the browser.
|
|
553
|
+
*
|
|
554
|
+
* AES-256-GCM authenticates as well as hides, so a tampered or forged value
|
|
555
|
+
* fails to open rather than decoding into something else. The expiry sits
|
|
556
|
+
* inside the ciphertext, which keeps an old cookie from being replayed past
|
|
557
|
+
* it even by a client that ignores Max-Age.
|
|
558
|
+
*/
|
|
559
|
+
function seal(key, value, ttlSeconds, now = Date.now()) {
|
|
560
|
+
const iv = randomBytes(IV_BYTES);
|
|
561
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
562
|
+
cipher.setAAD(Buffer.from(VERSION));
|
|
563
|
+
const plaintext = Buffer.from(JSON.stringify({
|
|
564
|
+
exp: now + ttlSeconds * 1e3,
|
|
565
|
+
value
|
|
566
|
+
}));
|
|
567
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
568
|
+
return `${VERSION}.${Buffer.concat([
|
|
569
|
+
iv,
|
|
570
|
+
cipher.getAuthTag(),
|
|
571
|
+
ciphertext
|
|
572
|
+
]).toString("base64url")}`;
|
|
573
|
+
}
|
|
574
|
+
/** The sealed value, or null when it is malformed, forged, tampered with or expired. */
|
|
575
|
+
function unseal(key, sealed, now = Date.now()) {
|
|
576
|
+
if (!sealed?.startsWith(`${VERSION}.`)) return null;
|
|
577
|
+
const raw = Buffer.from(sealed.slice(3), "base64url");
|
|
578
|
+
if (raw.length <= 28) return null;
|
|
579
|
+
try {
|
|
580
|
+
const decipher = createDecipheriv("aes-256-gcm", key, raw.subarray(0, IV_BYTES));
|
|
581
|
+
decipher.setAAD(Buffer.from(VERSION));
|
|
582
|
+
decipher.setAuthTag(raw.subarray(IV_BYTES, 28));
|
|
583
|
+
const plaintext = Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]);
|
|
584
|
+
const parsed = JSON.parse(plaintext.toString("utf8"));
|
|
585
|
+
if (typeof parsed.exp !== "number" || parsed.exp <= now) return null;
|
|
586
|
+
return parsed.value;
|
|
587
|
+
} catch {
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
//#endregion
|
|
592
|
+
//#region src/identity/tokens.ts
|
|
593
|
+
const PREFIX = {
|
|
594
|
+
browser: "coffre_web_",
|
|
595
|
+
cli: "coffre_cli_",
|
|
596
|
+
service: "coffre_svc_"
|
|
597
|
+
};
|
|
598
|
+
const TOKEN = /^coffre_(web|cli|svc)_[A-Za-z0-9_-]{43}$/;
|
|
599
|
+
function generateToken(kind) {
|
|
600
|
+
return PREFIX[kind] + randomBytes(32).toString("base64url");
|
|
601
|
+
}
|
|
602
|
+
/** Whether a string has the shape of a coffre token, before any lookup. */
|
|
603
|
+
function isCoffreToken(value) {
|
|
604
|
+
return TOKEN.test(value);
|
|
605
|
+
}
|
|
606
|
+
function hashToken(token) {
|
|
607
|
+
return createHash("sha256").update(token, "utf8").digest();
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Enough of a token to recognize it in a list, never enough to use it: the
|
|
611
|
+
* kind prefix and the last four characters. The prefix is matched rather than
|
|
612
|
+
* cut at the last underscore, because base64url uses `_` too.
|
|
613
|
+
*/
|
|
614
|
+
function tokenHint(token) {
|
|
615
|
+
return `${Object.values(PREFIX).find((candidate) => token.startsWith(candidate)) ?? ""}…${token.slice(-4)}`;
|
|
616
|
+
}
|
|
617
|
+
//#endregion
|
|
618
|
+
//#region src/identity/types.ts
|
|
619
|
+
/** Access delivers the JWT to the origin in this header. */
|
|
620
|
+
const ACCESS_JWT_HEADER = "cf-access-jwt-assertion";
|
|
621
|
+
//#endregion
|
|
622
|
+
//#region src/identity/verifier.ts
|
|
623
|
+
/**
|
|
624
|
+
* Signature algorithms we accept, as an explicit allowlist.
|
|
625
|
+
*
|
|
626
|
+
* Without this, the algorithm is taken from the attacker-controlled token
|
|
627
|
+
* header, which is how `alg: none` and RS256->HS256 confusion work. jose
|
|
628
|
+
* defaults to permitting whatever the key supports; we do not rely on that.
|
|
629
|
+
*/
|
|
630
|
+
const ALLOWED_ALGORITHMS = ["RS256", "ES256"];
|
|
631
|
+
function asNonEmptyString(value) {
|
|
632
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Verifies Cloudflare Access JWTs.
|
|
636
|
+
*
|
|
637
|
+
* Signature and claim verification for the TanStack request boundary. The
|
|
638
|
+
* boundary also rejects `x-middleware-subrequest` before calling this verifier.
|
|
639
|
+
*/
|
|
640
|
+
var AccessIdentityVerifier = class {
|
|
641
|
+
#config;
|
|
642
|
+
#jwks;
|
|
643
|
+
constructor(config) {
|
|
644
|
+
this.#config = config;
|
|
645
|
+
this.#jwks = createRemoteJWKSet(new URL(config.jwksUrl), {
|
|
646
|
+
cooldownDuration: 3e4,
|
|
647
|
+
cacheMaxAge: 6e5
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
async verify(token) {
|
|
651
|
+
if (typeof token !== "string" || token.length === 0) throw new Error("missing or empty Access token");
|
|
652
|
+
const { payload } = await jwtVerify(token, this.#jwks, {
|
|
653
|
+
issuer: this.#config.issuer,
|
|
654
|
+
audience: this.#config.audience,
|
|
655
|
+
algorithms: [...ALLOWED_ALGORITHMS],
|
|
656
|
+
clockTolerance: this.#config.clockToleranceSeconds ?? 0
|
|
657
|
+
});
|
|
658
|
+
return toPrincipal(payload);
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
/**
|
|
662
|
+
* Derive the caller from verified claims.
|
|
663
|
+
*
|
|
664
|
+
* Order matters. A service token is identified by `common_name` and carries an
|
|
665
|
+
* empty `sub`; an identity token carries both `sub` and `email`. Anything else
|
|
666
|
+
* is rejected rather than being allowed through as an authenticated caller
|
|
667
|
+
* with no name -- an audit row with a null actor answers no question worth
|
|
668
|
+
* asking.
|
|
669
|
+
*/
|
|
670
|
+
function toPrincipal(claims) {
|
|
671
|
+
const commonName = asNonEmptyString(claims.common_name);
|
|
672
|
+
if (commonName !== null) return {
|
|
673
|
+
type: "service",
|
|
674
|
+
id: commonName,
|
|
675
|
+
commonName
|
|
676
|
+
};
|
|
677
|
+
const email = asNonEmptyString(claims.email);
|
|
678
|
+
const subject = asNonEmptyString(claims.sub);
|
|
679
|
+
if (email !== null && subject !== null) {
|
|
680
|
+
const id = email.toLowerCase();
|
|
681
|
+
return {
|
|
682
|
+
type: "user",
|
|
683
|
+
id,
|
|
684
|
+
email: id,
|
|
685
|
+
subject
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
throw new Error("token verified but carries no usable identity: expected common_name (service) or sub+email (user)");
|
|
689
|
+
}
|
|
690
|
+
//#endregion
|
|
691
|
+
export { ACCESS_JWT_HEADER, AccessIdentityVerifier, GitHubSigninProvider, OidcSigninProvider, SigninError, cloudflareAccess, defineSignin, deriveKey, generateToken, github, google, hashToken, isCoffreToken, microsoft, oidc, publicOrigin, seal, signin, toPrincipal, tokenHint, unseal };
|