@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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +10 -3
  3. package/dist/access.d.ts +100 -0
  4. package/dist/access.js +109 -0
  5. package/dist/audit.d.ts +119 -0
  6. package/dist/audit.js +160 -0
  7. package/dist/dotenv.d.ts +45 -0
  8. package/dist/dotenv.js +194 -0
  9. package/dist/envelope.d.ts +50 -0
  10. package/dist/envelope.js +83 -0
  11. package/dist/identity.d.ts +433 -0
  12. package/dist/identity.js +691 -0
  13. package/dist/kek.d.ts +72 -0
  14. package/dist/kek.js +486 -0
  15. package/dist/registry-C0NGpxcc.d.ts +126 -0
  16. package/dist/schemas.d.ts +18 -0
  17. package/dist/schemas.js +13 -0
  18. package/dist/types-Mi7-Tzj9.js +57 -0
  19. package/dist/vault.d.ts +375 -0
  20. package/dist/vault.js +45 -0
  21. package/package.json +75 -5
  22. package/src/access-fault.ts +61 -0
  23. package/src/access.ts +152 -0
  24. package/src/audit/chain.ts +207 -0
  25. package/src/checkpoint.ts +13 -0
  26. package/src/context.ts +54 -0
  27. package/src/dotenv.ts +242 -0
  28. package/src/envelope.ts +127 -0
  29. package/src/identity/auth.ts +124 -0
  30. package/src/identity/index.ts +6 -0
  31. package/src/identity/signin/config.ts +301 -0
  32. package/src/identity/signin/github.ts +191 -0
  33. package/src/identity/signin/index.ts +5 -0
  34. package/src/identity/signin/oidc.ts +199 -0
  35. package/src/identity/signin/sealed.ts +55 -0
  36. package/src/identity/signin/types.ts +94 -0
  37. package/src/identity/tokens.ts +45 -0
  38. package/src/identity/types.ts +51 -0
  39. package/src/identity/verifier.ts +86 -0
  40. package/src/kek/aws-kms.ts +279 -0
  41. package/src/kek/cancellation.ts +56 -0
  42. package/src/kek/index.ts +6 -0
  43. package/src/kek/local.ts +122 -0
  44. package/src/kek/registry.ts +63 -0
  45. package/src/kek/sigv4.ts +75 -0
  46. package/src/kek/types.ts +79 -0
  47. package/src/schemas.ts +11 -0
  48. package/src/vault.ts +276 -0
@@ -0,0 +1,191 @@
1
+ import * as oauth from 'oauth4webapi';
2
+
3
+ import type { GitHubProviderConfig } from './config.ts';
4
+ import {
5
+ SigninError,
6
+ type PendingSignin,
7
+ type ProviderOptions,
8
+ type SigninBrand,
9
+ type SigninProfile,
10
+ type SigninProvider,
11
+ } from './types.ts';
12
+
13
+ type GitHubUser = { id: number; login: string; name: string | null };
14
+ type GitHubEmail = { email: string; primary: boolean; verified: boolean };
15
+
16
+ /**
17
+ * GitHub's OAuth web flow, which is OAuth 2.0 without OpenID Connect: no ID
18
+ * token, so the profile comes from the REST API with the access token the
19
+ * code is exchanged for. That token is used for these two or three calls and
20
+ * then dropped; coffre never stores it.
21
+ *
22
+ * Works with an OAuth App or a GitHub App's user authorization, on github.com
23
+ * or GitHub Enterprise Server.
24
+ */
25
+ export class GitHubSigninProvider implements SigninProvider {
26
+ readonly id: string;
27
+ readonly issuer: string;
28
+ readonly label: string;
29
+ readonly brand: SigninBrand;
30
+ /** What it was made from, as checked. */
31
+ readonly config: GitHubProviderConfig;
32
+ readonly #fetch: typeof fetch;
33
+
34
+ constructor(config: GitHubProviderConfig, options: ProviderOptions = {}) {
35
+ this.id = config.id;
36
+ this.issuer = config.apiUrl;
37
+ this.label = config.label;
38
+ this.brand = config.brand;
39
+ this.config = config;
40
+ this.#fetch = options.fetch ?? ((input, init) => fetch(input, init));
41
+ }
42
+
43
+ async start(redirectUri: string, options: { loginHint?: string } = {}) {
44
+ const pending: PendingSignin = {
45
+ state: oauth.generateRandomState(),
46
+ codeVerifier: oauth.generateRandomCodeVerifier(),
47
+ nonce: null,
48
+ };
49
+
50
+ const url = new URL(`${this.config.webUrl}/login/oauth/authorize`);
51
+ const params = url.searchParams;
52
+ params.set('client_id', this.config.clientId);
53
+ params.set('redirect_uri', redirectUri);
54
+ // `read:org` lets the membership check see private memberships; without
55
+ // it, GitHub only reports members who made theirs public.
56
+ params.set('scope', this.config.organization ? 'read:user user:email read:org' : 'read:user user:email');
57
+ params.set('state', pending.state);
58
+ params.set('code_challenge', await oauth.calculatePKCECodeChallenge(pending.codeVerifier));
59
+ params.set('code_challenge_method', 'S256');
60
+ params.set('allow_signup', 'false');
61
+ if (options.loginHint !== undefined) params.set('login', options.loginHint);
62
+
63
+ return { url, pending };
64
+ }
65
+
66
+ async finish(callbackUrl: URL, redirectUri: string, pending: PendingSignin): Promise<SigninProfile> {
67
+ const label = this.config.label;
68
+ const query = callbackUrl.searchParams;
69
+
70
+ if (query.get('state') !== pending.state) {
71
+ throw new SigninError('state_mismatch', `the ${label} callback does not match this sign-in`);
72
+ }
73
+ const refused = query.get('error');
74
+ if (refused !== null) {
75
+ throw new SigninError('provider_denied', `${label} refused the sign-in: ${refused}`);
76
+ }
77
+ const code = query.get('code');
78
+ if (code === null || code === '') {
79
+ throw new SigninError('invalid_response', `the ${label} callback carries no code`);
80
+ }
81
+
82
+ const token = await this.#exchange(code, redirectUri, pending.codeVerifier);
83
+ const user = await this.#api<GitHubUser>(token, '/user');
84
+ if (typeof user?.id !== 'number') {
85
+ throw new SigninError('invalid_response', `${label} did not say who signed in`);
86
+ }
87
+
88
+ const organization = this.config.organization;
89
+ if (organization !== null && !(await this.#isMember(token, organization))) {
90
+ throw new SigninError(
91
+ 'not_in_organization',
92
+ `only members of the ${organization} organization may sign in with ${label}`,
93
+ );
94
+ }
95
+
96
+ const listed = await this.#api<GitHubEmail[]>(token, '/user/emails');
97
+ const emails = (Array.isArray(listed) ? listed : [])
98
+ .filter((entry) => entry.verified && typeof entry.email === 'string')
99
+ .sort((a, b) => Number(b.primary) - Number(a.primary))
100
+ .map((entry) => entry.email.trim().toLowerCase());
101
+
102
+ return {
103
+ subject: String(user.id),
104
+ emails: [...new Set(emails)],
105
+ name: user.name ?? user.login,
106
+ };
107
+ }
108
+
109
+ async #exchange(code: string, redirectUri: string, codeVerifier: string): Promise<string> {
110
+ const label = this.config.label;
111
+ const response = await this.#send(`${this.config.webUrl}/login/oauth/access_token`, {
112
+ method: 'POST',
113
+ headers: {
114
+ accept: 'application/json',
115
+ 'content-type': 'application/x-www-form-urlencoded',
116
+ },
117
+ body: new URLSearchParams({
118
+ client_id: this.config.clientId,
119
+ client_secret: this.config.clientSecret,
120
+ code,
121
+ redirect_uri: redirectUri,
122
+ code_verifier: codeVerifier,
123
+ }),
124
+ });
125
+ // GitHub reports a bad code with a 200 and an `error` field.
126
+ const body = (await readJson(response)) as { access_token?: unknown; error?: unknown } | null;
127
+ if (!response.ok || body === null) {
128
+ throw new SigninError('invalid_response', `${label} answered the code exchange with ${response.status}`);
129
+ }
130
+ if (typeof body.error === 'string') {
131
+ throw new SigninError('provider_denied', `${label} refused the code: ${body.error}`);
132
+ }
133
+ if (typeof body.access_token !== 'string' || body.access_token === '') {
134
+ throw new SigninError('invalid_response', `${label} returned no access token`);
135
+ }
136
+ return body.access_token;
137
+ }
138
+
139
+ async #isMember(token: string, organization: string): Promise<boolean> {
140
+ const response = await this.#send(
141
+ `${this.config.apiUrl}/user/memberships/orgs/${encodeURIComponent(organization)}`,
142
+ { headers: apiHeaders(token) },
143
+ );
144
+ if (response.status === 404 || response.status === 403) return false;
145
+ const body = (await readJson(response)) as { state?: unknown } | null;
146
+ if (!response.ok || body === null) {
147
+ throw new SigninError('invalid_response', `${this.config.label} answered the membership check with ${response.status}`);
148
+ }
149
+ return body.state === 'active';
150
+ }
151
+
152
+ async #api<T>(token: string, path: string): Promise<T | null> {
153
+ const response = await this.#send(`${this.config.apiUrl}${path}`, {
154
+ headers: apiHeaders(token),
155
+ });
156
+ if (!response.ok) {
157
+ throw new SigninError('invalid_response', `${this.config.label} answered ${path} with ${response.status}`);
158
+ }
159
+ return (await readJson(response)) as T | null;
160
+ }
161
+
162
+ // A redirect is never followed: callers treat any answer other than a 2xx,
163
+ // a 3xx included, as one coffre cannot trust. Workers only offer 'follow'
164
+ // and 'manual'; 'error' throws there before any request is made.
165
+ async #send(url: string, init: RequestInit): Promise<Response> {
166
+ try {
167
+ return await this.#fetch(url, { ...init, redirect: 'manual', signal: AbortSignal.timeout(10_000) });
168
+ } catch (cause) {
169
+ const error = new SigninError('provider_unavailable', `${this.config.label} could not be reached`);
170
+ (error as Error & { cause?: unknown }).cause = cause;
171
+ throw error;
172
+ }
173
+ }
174
+ }
175
+
176
+ function apiHeaders(token: string): Record<string, string> {
177
+ return {
178
+ accept: 'application/vnd.github+json',
179
+ authorization: `Bearer ${token}`,
180
+ 'user-agent': 'coffre',
181
+ 'x-github-api-version': '2022-11-28',
182
+ };
183
+ }
184
+
185
+ async function readJson(response: Response): Promise<unknown> {
186
+ try {
187
+ return await response.json();
188
+ } catch {
189
+ return null;
190
+ }
191
+ }
@@ -0,0 +1,5 @@
1
+ export * from './config.ts';
2
+ export * from './types.ts';
3
+ export { GitHubSigninProvider } from './github.ts';
4
+ export { OidcSigninProvider } from './oidc.ts';
5
+ export { deriveKey, seal, unseal } from './sealed.ts';
@@ -0,0 +1,199 @@
1
+ import * as oauth from 'oauth4webapi';
2
+
3
+ import type { OidcProviderConfig } from './config.ts';
4
+ import {
5
+ SigninError,
6
+ type PendingSignin,
7
+ type ProviderOptions,
8
+ type SigninBrand,
9
+ type SigninProfile,
10
+ type SigninProvider,
11
+ } from './types.ts';
12
+
13
+ /** Discovery documents change rarely; an hour keeps one fetch off every sign-in. */
14
+ const DISCOVERY_TTL_MS = 60 * 60 * 1000;
15
+
16
+ type Discovered = { server: oauth.AuthorizationServer; fetchedAt: number };
17
+
18
+ /**
19
+ * The authorization code flow with PKCE, against any OpenID Connect issuer.
20
+ *
21
+ * Protocol work is delegated to oauth4webapi: discovery with issuer
22
+ * validation, the RFC 9207 `iss` check on the callback, the token request and
23
+ * ID token claim validation (issuer, audience, expiry, nonce). The ID token is
24
+ * received straight from the token endpoint over TLS with client
25
+ * authentication, which OpenID Connect Core 3.1.3.7 accepts in place of
26
+ * checking its signature.
27
+ */
28
+ export class OidcSigninProvider implements SigninProvider {
29
+ readonly id: string;
30
+ readonly issuer: string;
31
+ readonly label: string;
32
+ readonly brand: SigninBrand;
33
+ /** What it was made from, as checked. */
34
+ readonly config: OidcProviderConfig;
35
+ readonly #fetch: typeof fetch | undefined;
36
+ readonly #insecure: boolean;
37
+ #discovered: Discovered | null = null;
38
+
39
+ constructor(config: OidcProviderConfig, options: ProviderOptions = {}) {
40
+ this.id = config.id;
41
+ this.issuer = config.issuer;
42
+ this.label = config.label;
43
+ this.brand = config.brand;
44
+ this.config = config;
45
+ this.#fetch = options.fetch;
46
+ // Only ever true for the dev IdP: config rejects plain HTTP off loopback.
47
+ this.#insecure = new URL(config.issuer).protocol === 'http:';
48
+ }
49
+
50
+ #http<Method, Body = undefined>(): oauth.HttpRequestOptions<Method, Body> {
51
+ const options: oauth.HttpRequestOptions<Method, Body> = {
52
+ signal: () => AbortSignal.timeout(10_000),
53
+ };
54
+ if (this.#fetch !== undefined) {
55
+ const fetchImpl = this.#fetch;
56
+ options[oauth.customFetch] = (url, init) => fetchImpl(url, init as RequestInit);
57
+ }
58
+ if (this.#insecure) options[oauth.allowInsecureRequests] = true;
59
+ return options;
60
+ }
61
+
62
+ get #client(): oauth.Client {
63
+ return { client_id: this.config.clientId };
64
+ }
65
+
66
+ async #server(): Promise<oauth.AuthorizationServer> {
67
+ if (this.#discovered !== null && Date.now() - this.#discovered.fetchedAt < DISCOVERY_TTL_MS) {
68
+ return this.#discovered.server;
69
+ }
70
+ const issuer = new URL(this.config.issuer);
71
+ let server: oauth.AuthorizationServer;
72
+ try {
73
+ const response = await oauth.discoveryRequest(issuer, { ...this.#http<'GET'>(), algorithm: 'oidc' });
74
+ server = await oauth.processDiscoveryResponse(issuer, response);
75
+ } catch (error) {
76
+ throw unavailable(this.config.label, error);
77
+ }
78
+ this.#discovered = { server, fetchedAt: Date.now() };
79
+ return server;
80
+ }
81
+
82
+ async start(redirectUri: string, options: { loginHint?: string } = {}) {
83
+ const server = await this.#server();
84
+ if (server.authorization_endpoint === undefined) {
85
+ throw new SigninError(
86
+ 'invalid_response',
87
+ `${this.config.label} publishes no authorization endpoint`,
88
+ );
89
+ }
90
+
91
+ const pending: PendingSignin = {
92
+ state: oauth.generateRandomState(),
93
+ codeVerifier: oauth.generateRandomCodeVerifier(),
94
+ nonce: oauth.generateRandomNonce(),
95
+ };
96
+
97
+ const url = new URL(server.authorization_endpoint);
98
+ const params = url.searchParams;
99
+ params.set('client_id', this.config.clientId);
100
+ params.set('redirect_uri', redirectUri);
101
+ params.set('response_type', 'code');
102
+ params.set('scope', this.config.scopes.join(' '));
103
+ params.set('state', pending.state);
104
+ params.set('nonce', pending.nonce as string);
105
+ params.set('code_challenge', await oauth.calculatePKCECodeChallenge(pending.codeVerifier));
106
+ params.set('code_challenge_method', 'S256');
107
+ for (const [name, value] of Object.entries(this.config.authorizationParams)) {
108
+ params.set(name, value);
109
+ }
110
+ if (options.loginHint !== undefined) params.set('login_hint', options.loginHint);
111
+
112
+ return { url, pending };
113
+ }
114
+
115
+ async finish(callbackUrl: URL, redirectUri: string, pending: PendingSignin): Promise<SigninProfile> {
116
+ const server = await this.#server();
117
+ const label = this.config.label;
118
+
119
+ let params: URLSearchParams;
120
+ try {
121
+ params = oauth.validateAuthResponse(server, this.#client, callbackUrl, pending.state);
122
+ } catch (error) {
123
+ if (error instanceof oauth.AuthorizationResponseError) {
124
+ throw new SigninError('provider_denied', `${label} refused the sign-in: ${error.error}`);
125
+ }
126
+ throw new SigninError('state_mismatch', `the ${label} callback does not match this sign-in`);
127
+ }
128
+
129
+ let claims: oauth.IDToken;
130
+ try {
131
+ const response = await oauth.authorizationCodeGrantRequest(
132
+ server,
133
+ this.#client,
134
+ oauth.ClientSecretPost(this.config.clientSecret),
135
+ params,
136
+ redirectUri,
137
+ pending.codeVerifier,
138
+ this.#http<'POST', URLSearchParams>(),
139
+ );
140
+ const result = await oauth.processAuthorizationCodeResponse(server, this.#client, response, {
141
+ expectedNonce: pending.nonce ?? oauth.expectNoNonce,
142
+ requireIdToken: true,
143
+ });
144
+ claims = oauth.getValidatedIdTokenClaims(result) as oauth.IDToken;
145
+ } catch (error) {
146
+ if (error instanceof SigninError) throw error;
147
+ if (error instanceof oauth.ResponseBodyError) {
148
+ throw new SigninError('provider_denied', `${label} refused the code: ${error.error}`);
149
+ }
150
+ if (isNetworkError(error)) throw unavailable(label, error);
151
+ throw new SigninError('invalid_response', `${label} returned a token coffre cannot trust`);
152
+ }
153
+
154
+ return this.#profile(claims);
155
+ }
156
+
157
+ #profile(claims: oauth.IDToken): SigninProfile {
158
+ const label = this.config.label;
159
+
160
+ if (this.config.hostedDomain !== null && claims.hd !== this.config.hostedDomain) {
161
+ throw new SigninError(
162
+ 'wrong_domain',
163
+ `only ${this.config.hostedDomain} accounts may sign in with ${label}`,
164
+ );
165
+ }
166
+
167
+ const email = typeof claims.email === 'string' ? claims.email.trim().toLowerCase() : '';
168
+ // Generic OIDC must prove address ownership. Google is authoritative for
169
+ // Gmail and Workspace, but a third-party address can since have changed hands.
170
+ // https://developers.google.com/identity/sign-in/web/backend-auth
171
+ const authoritative = this.config.brand !== 'google'
172
+ || email.endsWith('@gmail.com')
173
+ || (typeof claims.hd === 'string' && claims.hd !== '');
174
+ // Entra says email and preferred_username must not authorize access.
175
+ // Link its subject while signed in through another provider instead.
176
+ // https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference
177
+ const emails = email !== '' && claims.email_verified === true
178
+ && authoritative && this.config.brand !== 'microsoft' ? [email] : [];
179
+
180
+ return {
181
+ subject: claims.sub,
182
+ emails,
183
+ name: typeof claims.name === 'string' ? claims.name : null,
184
+ };
185
+ }
186
+ }
187
+
188
+ function isNetworkError(error: unknown): boolean {
189
+ return (
190
+ error instanceof TypeError ||
191
+ (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError'))
192
+ );
193
+ }
194
+
195
+ function unavailable(label: string, cause: unknown): SigninError {
196
+ const error = new SigninError('provider_unavailable', `${label} could not be reached`);
197
+ (error as Error & { cause?: unknown }).cause = cause;
198
+ return error;
199
+ }
@@ -0,0 +1,55 @@
1
+ import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:crypto';
2
+
3
+ const IV_BYTES = 12;
4
+ const TAG_BYTES = 16;
5
+ const VERSION = 'v1';
6
+
7
+ /**
8
+ * Derive a purpose-bound key from one of coffre's existing 32-byte secrets.
9
+ *
10
+ * HKDF with a distinct `info` string gives a key that is independent of the
11
+ * source for every practical purpose, so the sign-in cookie needs no new
12
+ * secret to provision and a leak of one derived key says nothing about the
13
+ * others.
14
+ */
15
+ export function deriveKey(secret: Uint8Array, purpose: string): Buffer {
16
+ return Buffer.from(hkdfSync('sha256', secret, Buffer.alloc(0), `coffre/${purpose}`, 32));
17
+ }
18
+
19
+ /**
20
+ * Encrypt a small JSON value for a round trip through the browser.
21
+ *
22
+ * AES-256-GCM authenticates as well as hides, so a tampered or forged value
23
+ * fails to open rather than decoding into something else. The expiry sits
24
+ * inside the ciphertext, which keeps an old cookie from being replayed past
25
+ * it even by a client that ignores Max-Age.
26
+ */
27
+ export function seal(key: Uint8Array, value: unknown, ttlSeconds: number, now = Date.now()): string {
28
+ const iv = randomBytes(IV_BYTES);
29
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
30
+ cipher.setAAD(Buffer.from(VERSION));
31
+ const plaintext = Buffer.from(JSON.stringify({ exp: now + ttlSeconds * 1000, value }));
32
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
33
+ return `${VERSION}.${Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString('base64url')}`;
34
+ }
35
+
36
+ /** The sealed value, or null when it is malformed, forged, tampered with or expired. */
37
+ export function unseal<T>(key: Uint8Array, sealed: string | undefined | null, now = Date.now()): T | null {
38
+ if (!sealed?.startsWith(`${VERSION}.`)) return null;
39
+ const raw = Buffer.from(sealed.slice(VERSION.length + 1), 'base64url');
40
+ if (raw.length <= IV_BYTES + TAG_BYTES) return null;
41
+ try {
42
+ const decipher = createDecipheriv('aes-256-gcm', key, raw.subarray(0, IV_BYTES));
43
+ decipher.setAAD(Buffer.from(VERSION));
44
+ decipher.setAuthTag(raw.subarray(IV_BYTES, IV_BYTES + TAG_BYTES));
45
+ const plaintext = Buffer.concat([
46
+ decipher.update(raw.subarray(IV_BYTES + TAG_BYTES)),
47
+ decipher.final(),
48
+ ]);
49
+ const parsed = JSON.parse(plaintext.toString('utf8')) as { exp?: unknown; value?: unknown };
50
+ if (typeof parsed.exp !== 'number' || parsed.exp <= now) return null;
51
+ return parsed.value as T;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * What a provider reports about the person who just signed in.
3
+ *
4
+ * `subject` is the provider's permanent id for the account: OIDC's `sub`,
5
+ * GitHub's numeric user id. coffre binds to it and to nothing else, since a
6
+ * login name can be renamed and an email address can be handed to someone new.
7
+ */
8
+ export type SigninProfile = {
9
+ subject: string;
10
+ /** Verified addresses only, lowercased, the provider's primary first. */
11
+ emails: string[];
12
+ name: string | null;
13
+ };
14
+
15
+ /**
16
+ * What must survive the round trip through the provider: the anti-CSRF
17
+ * state, the PKCE verifier and, for OIDC, the nonce. coffre keeps it in an
18
+ * encrypted, short-lived cookie rather than in the database, so an
19
+ * unauthenticated visitor cannot make coffre write anything.
20
+ */
21
+ export type PendingSignin = {
22
+ state: string;
23
+ codeVerifier: string;
24
+ nonce: string | null;
25
+ };
26
+
27
+ export type SigninErrorCode =
28
+ /** The person cancelled, or the provider refused them. */
29
+ | 'provider_denied'
30
+ /** The callback does not belong to the sign-in this browser started. */
31
+ | 'state_mismatch'
32
+ /** The provider answered, but not in a way that can be trusted. */
33
+ | 'invalid_response'
34
+ /** The provider could not be reached. */
35
+ | 'provider_unavailable'
36
+ /** The account has no verified email address to match an invitation with. */
37
+ | 'no_verified_email'
38
+ /** A Google account outside the configured Workspace domain. */
39
+ | 'wrong_domain'
40
+ /** A GitHub account outside the configured organization. */
41
+ | 'not_in_organization';
42
+
43
+ export class SigninError extends Error {
44
+ readonly code: SigninErrorCode;
45
+ constructor(code: SigninErrorCode, message: string) {
46
+ super(message);
47
+ this.code = code;
48
+ }
49
+ }
50
+
51
+ export type ProviderOptions = {
52
+ /** Injected by tests; defaults to globalThis.fetch. */
53
+ fetch?: typeof fetch;
54
+ };
55
+
56
+ /** Which mark the sign-in button carries: GitHub's, Google's, Microsoft's, or a plain key. */
57
+ export type SigninBrand = 'github' | 'google' | 'microsoft' | 'oidc';
58
+
59
+ /**
60
+ * A way to sign in: send the browser to whoever knows the person, and take
61
+ * back who they are. `github()`, `google()`, `microsoft()` and `oidc()` make
62
+ * one; so can a deployment, for a provider that is none of those. coffre
63
+ * does everything around it: the pending state, sealed in a cookie between
64
+ * the two calls, binding the profile to a member, sessions, CLI logins and
65
+ * service tokens.
66
+ *
67
+ * const acme: SigninProvider = {
68
+ * id: 'acme',
69
+ * issuer: 'https://sso.acme.example',
70
+ * label: 'Acme SSO',
71
+ * brand: 'oidc',
72
+ * async start(redirectUri) { … return { url, pending: { state, codeVerifier, nonce: null } }; },
73
+ * async finish(callbackUrl, redirectUri, pending) { … return { subject, emails, name }; },
74
+ * };
75
+ */
76
+ export interface SigninProvider {
77
+ /** Stable: part of the callback URL, `/auth/callback/{id}`, and of every account bound through it. */
78
+ readonly id: string;
79
+ /** The authority whose subjects these are; changing it requires linking accounts again. */
80
+ readonly issuer: string;
81
+ /** Button text: "Continue with {label}". */
82
+ readonly label: string;
83
+ readonly brand: SigninBrand;
84
+ /** Where to send the browser, and what to remember until it comes back. */
85
+ start(redirectUri: string, options?: { loginHint?: string }): Promise<{
86
+ url: URL;
87
+ pending: PendingSignin;
88
+ }>;
89
+ /**
90
+ * The person back from the provider, verified: check the callback against
91
+ * `pending`, and throw a SigninError for anything that does not hold.
92
+ */
93
+ finish(callbackUrl: URL, redirectUri: string, pending: PendingSignin): Promise<SigninProfile>;
94
+ }
@@ -0,0 +1,45 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+
3
+ /**
4
+ * Bearer credentials coffre issues itself: browser sessions, CLI sessions and
5
+ * service tokens.
6
+ *
7
+ * A token is a kind prefix and 32 random bytes, e.g.
8
+ * `coffre_cli_Qm9Y...`. The prefix tells a person (or a secret scanner) what a
9
+ * leaked string is; the database stores only its SHA-256, which is enough
10
+ * for lookups and useless to anyone who reads the table. A plain hash is the
11
+ * right tool here, unlike for passwords: 256 bits of randomness leave nothing
12
+ * to brute-force.
13
+ */
14
+ export type CredentialKind = 'browser' | 'cli' | 'service';
15
+
16
+ const PREFIX: Record<CredentialKind, string> = {
17
+ browser: 'coffre_web_',
18
+ cli: 'coffre_cli_',
19
+ service: 'coffre_svc_',
20
+ };
21
+
22
+ const TOKEN = /^coffre_(web|cli|svc)_[A-Za-z0-9_-]{43}$/;
23
+
24
+ export function generateToken(kind: CredentialKind): string {
25
+ return PREFIX[kind] + randomBytes(32).toString('base64url');
26
+ }
27
+
28
+ /** Whether a string has the shape of a coffre token, before any lookup. */
29
+ export function isCoffreToken(value: string): boolean {
30
+ return TOKEN.test(value);
31
+ }
32
+
33
+ export function hashToken(token: string): Buffer {
34
+ return createHash('sha256').update(token, 'utf8').digest();
35
+ }
36
+
37
+ /**
38
+ * Enough of a token to recognize it in a list, never enough to use it: the
39
+ * kind prefix and the last four characters. The prefix is matched rather than
40
+ * cut at the last underscore, because base64url uses `_` too.
41
+ */
42
+ export function tokenHint(token: string): string {
43
+ const prefix = Object.values(PREFIX).find((candidate) => token.startsWith(candidate)) ?? '';
44
+ return `${prefix}…${token.slice(-4)}`;
45
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * An authenticated caller.
3
+ *
4
+ * Modelled as a tagged union rather than "a user with an optional email",
5
+ * because Cloudflare Access issues two structurally different tokens:
6
+ *
7
+ * identity token -> sub = user uuid, email = "admin@acme.example"
8
+ * service token -> sub = "" , common_name = "<client-id>.access", NO email
9
+ *
10
+ * Machine callers (external-secrets, CI) are most of the real traffic, so the
11
+ * shape that cannot represent them is the wrong shape.
12
+ */
13
+ export type Principal =
14
+ | {
15
+ type: 'user';
16
+ /** Stable identifier used in grants and in the audit log. */
17
+ id: string;
18
+ email: string;
19
+ subject: string;
20
+ }
21
+ | {
22
+ type: 'service';
23
+ /** Stable identifier used in grants and in the audit log. */
24
+ id: string;
25
+ commonName: string;
26
+ };
27
+
28
+ /**
29
+ * Verifies a bearer token and returns the caller it identifies.
30
+ *
31
+ * Cloudflare mode verifies Access JWTs against the team domain; signin mode
32
+ * looks up coffre's own credentials.
33
+ */
34
+ export interface IdentityVerifier {
35
+ verify(token: string, request?: { sourceIp: string | null }): Promise<Principal>;
36
+ }
37
+
38
+ /** Access delivers the JWT to the origin in this header. */
39
+ export const ACCESS_JWT_HEADER = 'cf-access-jwt-assertion';
40
+
41
+ /** Where Access JWTs are checked: the team's keys, and the application's AUD tag. */
42
+ export type AccessVerifierConfig = {
43
+ /** Cloudflare Access team domain, e.g. https://acme.cloudflareaccess.com */
44
+ issuer: string;
45
+ /** Usually `${issuer}/cdn-cgi/access/certs`. */
46
+ jwksUrl: string;
47
+ /** The AUD tag of the specific Access application. */
48
+ audience: string;
49
+ /** Seconds of clock skew tolerated. Kept at 0 by default deliberately. */
50
+ clockToleranceSeconds?: number;
51
+ };