cursedbelt-server 4.13.0 → 4.14.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/dist/server/auth/password.js +4 -6
- package/dist/server/google-oauth/googleOAuth.d.ts +303 -0
- package/dist/server/google-oauth/googleOAuth.js +465 -0
- package/dist/server/guard/passwordVerifier.d.ts +1 -1
- package/dist/server/guard/passwordVerifier.js +3 -3
- package/dist/server/sync/tokens.d.ts +1 -1
- package/dist/server/sync/tokens.js +3 -2
- package/package.json +11 -3
- package/src/leafSubpathsImportNothing.spec.ts +10 -0
- package/src/publicSurface.spec.ts +13 -5
- package/src/server/auth/password.ts +4 -5
- package/src/server/google-oauth/googleOAuth.spec.ts +742 -0
- package/src/server/google-oauth/googleOAuth.ts +750 -0
- package/src/server/guard/passwordVerifier.ts +3 -3
- package/src/server/sync/tokens.ts +4 -3
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cursedbelt-server/google-oauth` — the ONE Google OAuth implementation in this generation:
|
|
3
|
+
* consent URL, authorization-code exchange, refresh-token exchange, revocation, credential
|
|
4
|
+
* reading from env + a 0600 env file, a cached access-token minter, a live credential probe
|
|
5
|
+
* and the 403-to-remedy sentence.
|
|
6
|
+
*
|
|
7
|
+
* ── Why it is here ──────────────────────────────────────────────────────────
|
|
8
|
+
* Two apps held byte-for-byte copies of it (task 099, census entry 407), one of which had
|
|
9
|
+
* already forked ahead of the other with the credential-resolution half. Two copies of a
|
|
10
|
+
* credential parser are two answers to "which env keys are canonical", and the drift costs a
|
|
11
|
+
* sign-in. This file is the union of both, with the stricter body wherever they differed.
|
|
12
|
+
*
|
|
13
|
+
* ── Nothing here knows who is calling ───────────────────────────────────────
|
|
14
|
+
* No app name, no hostname, no secrets path, no env-file location and no scope list is
|
|
15
|
+
* baked in. The caller supplies the secrets file, the env-var names it accepts
|
|
16
|
+
* ({@link CredentialKeys}), the label a failure sentence names and the remedy it offers.
|
|
17
|
+
*
|
|
18
|
+
* ── Two ways a refresh token arrives, one exchange ──────────────────────────
|
|
19
|
+
* · {@link readGoogleCredentials} + {@link createGoogleTokenMinter} serve a consumer whose
|
|
20
|
+
* refresh token is a 0600 file on the machine, minted once by hand.
|
|
21
|
+
* · {@link buildConsentUrl} + {@link exchangeAuthCode} serve a consumer that obtains the
|
|
22
|
+
* refresh token AT RUNTIME from a browser consent and stores it itself. Feed it straight
|
|
23
|
+
* back in — `createGoogleTokenMinter({ credentials })` — so a consenting app still has
|
|
24
|
+
* exactly one `grant_type=refresh_token` POST.
|
|
25
|
+
*
|
|
26
|
+
* ── Failure is always LOUD, and never a secret ──────────────────────────────
|
|
27
|
+
* Every failure is a typed result carrying the exact remedy; nothing here throws. A
|
|
28
|
+
* credential that cannot be read, a token Google refuses, or a scope that was never granted
|
|
29
|
+
* must NEVER look like "nothing to report". No credential value is ever logged, returned in
|
|
30
|
+
* an error string, or thrown — Google's `error_description` is echoed because it describes
|
|
31
|
+
* the REQUEST, never the secret.
|
|
32
|
+
*
|
|
33
|
+
* ── A leaf ──────────────────────────────────────────────────────────────────
|
|
34
|
+
* One file, node builtins only, listed in `leafSubpathsImportNothing.spec.ts`. It is one file
|
|
35
|
+
* on purpose: the leaf fixture copies exactly the exported file, so a sibling import would be
|
|
36
|
+
* a red, and a single module cannot have the import cycle the old three-file split existed to
|
|
37
|
+
* avoid.
|
|
38
|
+
*/
|
|
39
|
+
import { readFileSync } from 'node:fs';
|
|
40
|
+
import { homedir } from 'node:os';
|
|
41
|
+
import path from 'node:path';
|
|
42
|
+
// ─── Endpoints ───────────────────────────────────────────────────────────────
|
|
43
|
+
export const GOOGLE_TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
|
|
44
|
+
export const GOOGLE_AUTH_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth';
|
|
45
|
+
export const GOOGLE_REVOKE_ENDPOINT = 'https://oauth2.googleapis.com/revoke';
|
|
46
|
+
/** `process.env` where there is a `process`; an empty env where there is not. */
|
|
47
|
+
const ambientEnv = () => (typeof process !== 'undefined' ? process.env : {});
|
|
48
|
+
// ─── Env-file reading ────────────────────────────────────────────────────────
|
|
49
|
+
/** `~/x` → `<home>/x`. Anything else is returned unchanged. */
|
|
50
|
+
export const expandTilde = (p) => p.startsWith('~/') ? path.join(homedir(), p.slice(2)) : p;
|
|
51
|
+
/**
|
|
52
|
+
* Parse a `KEY=value` env file, tolerating quotes, blanks, `export`, `#` comments and CRLF.
|
|
53
|
+
*
|
|
54
|
+
* 🔴 CRLF is split on, not trimmed afterwards. Both app copies split on `\n` alone, and `.`
|
|
55
|
+
* does not match `\r`, so `(.*)$` failed on EVERY line of a CRLF file: a secrets file saved by
|
|
56
|
+
* a Windows editor parsed to `{}` and read as "no credential on this machine" — `unconfigured`,
|
|
57
|
+
* with the setup remedy, for a file that held all three values. Pinned in the spec.
|
|
58
|
+
*/
|
|
59
|
+
export function parseEnvFile(text) {
|
|
60
|
+
const out = {};
|
|
61
|
+
for (const line of text.split(/\r?\n/)) {
|
|
62
|
+
if (/^\s*#/.test(line))
|
|
63
|
+
continue;
|
|
64
|
+
const m = /^\s*(?:export\s+)?([A-Z0-9_]+)\s*=\s*(.*)$/.exec(line);
|
|
65
|
+
if (m?.[1])
|
|
66
|
+
out[m[1]] = (m[2] ?? '').trim().replace(/^["']|["']$/g, '');
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
/** The parsed secrets file, or `{}` when there is none or it cannot be read. Never throws. */
|
|
71
|
+
const readEnvFile = (secretsFile) => {
|
|
72
|
+
if (!secretsFile)
|
|
73
|
+
return {};
|
|
74
|
+
try {
|
|
75
|
+
return parseEnvFile(readFileSync(expandTilde(secretsFile), 'utf8'));
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return {};
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
/** Read any other key — env first, then the same secrets file (an API key, an address). */
|
|
82
|
+
export function readSecret(name, options = {}) {
|
|
83
|
+
const fromEnv = (options.env ?? ambientEnv())[name]?.trim();
|
|
84
|
+
if (fromEnv)
|
|
85
|
+
return fromEnv;
|
|
86
|
+
return readEnvFile(options.secretsFile)[name]?.trim() ?? '';
|
|
87
|
+
}
|
|
88
|
+
export const CANONICAL_CREDENTIAL_KEYS = {
|
|
89
|
+
clientId: ['GOOGLE_OAUTH_CLIENT_ID'],
|
|
90
|
+
clientSecret: ['GOOGLE_OAUTH_CLIENT_SECRET'],
|
|
91
|
+
refreshToken: ['GOOGLE_OAUTH_REFRESH_TOKEN'],
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* Read a Google OAuth credential AND report which names carried it.
|
|
95
|
+
*
|
|
96
|
+
* Precedence: env overrides the secrets file, and earlier key names override later aliases.
|
|
97
|
+
*/
|
|
98
|
+
export function resolveGoogleCredentials(options = {}) {
|
|
99
|
+
const env = options.env ?? ambientEnv();
|
|
100
|
+
const keys = options.keys ?? CANONICAL_CREDENTIAL_KEYS;
|
|
101
|
+
const fromFile = readEnvFile(options.secretsFile);
|
|
102
|
+
const read = (names) => {
|
|
103
|
+
let used = null;
|
|
104
|
+
let value = '';
|
|
105
|
+
const shadowed = [];
|
|
106
|
+
for (const n of names) {
|
|
107
|
+
const fromEnv = env[n]?.trim();
|
|
108
|
+
const v = fromEnv || fromFile[n]?.trim();
|
|
109
|
+
if (!v)
|
|
110
|
+
continue;
|
|
111
|
+
// The FIRST name that has a value wins. The rest are recorded rather than discarded —
|
|
112
|
+
// that list is the whole diagnosis when Google refuses the winner.
|
|
113
|
+
if (used === null) {
|
|
114
|
+
used = { name: n, from: fromEnv ? 'env' : 'file' };
|
|
115
|
+
value = v;
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
shadowed.push(n);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return { value, used, shadowed };
|
|
122
|
+
};
|
|
123
|
+
const clientId = read(keys.clientId);
|
|
124
|
+
const clientSecret = read(keys.clientSecret);
|
|
125
|
+
const refreshToken = read(keys.refreshToken);
|
|
126
|
+
const complete = Boolean(clientId.value && clientSecret.value && refreshToken.value);
|
|
127
|
+
return {
|
|
128
|
+
credentials: complete
|
|
129
|
+
? { clientId: clientId.value, clientSecret: clientSecret.value, refreshToken: refreshToken.value }
|
|
130
|
+
: null,
|
|
131
|
+
used: { clientId: clientId.used, clientSecret: clientSecret.used, refreshToken: refreshToken.used },
|
|
132
|
+
shadowed: {
|
|
133
|
+
clientId: clientId.shadowed,
|
|
134
|
+
clientSecret: clientSecret.shadowed,
|
|
135
|
+
refreshToken: refreshToken.shadowed,
|
|
136
|
+
},
|
|
137
|
+
missing: [
|
|
138
|
+
...(clientId.used ? [] : keys.clientId),
|
|
139
|
+
...(clientSecret.used ? [] : keys.clientSecret),
|
|
140
|
+
...(refreshToken.used ? [] : keys.refreshToken),
|
|
141
|
+
],
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Read a Google OAuth credential — env overriding the secrets file, and earlier key names
|
|
146
|
+
* overriding later aliases.
|
|
147
|
+
*
|
|
148
|
+
* Returns null when any of the three is missing. A PARTIAL credential is the same as none:
|
|
149
|
+
* two of three cannot mint a token, and treating it as present would turn a setup mistake
|
|
150
|
+
* into an `unavailable` ("Google refused it") instead of the `unconfigured` ("you have not
|
|
151
|
+
* finished setting it up") that names the real fix.
|
|
152
|
+
*/
|
|
153
|
+
export function readGoogleCredentials(options = {}) {
|
|
154
|
+
return resolveGoogleCredentials(options).credentials;
|
|
155
|
+
}
|
|
156
|
+
/** The URL to send the browser to. Pure — no network, so a caller can test its own wiring. */
|
|
157
|
+
export function buildConsentUrl(options) {
|
|
158
|
+
const params = new URLSearchParams({
|
|
159
|
+
client_id: options.clientId,
|
|
160
|
+
redirect_uri: options.redirectUri,
|
|
161
|
+
response_type: 'code',
|
|
162
|
+
scope: options.scopes.join(' '),
|
|
163
|
+
// Without `offline` Google issues no refresh token at all and the grant dies with the
|
|
164
|
+
// first access token.
|
|
165
|
+
access_type: 'offline',
|
|
166
|
+
include_granted_scopes: 'true',
|
|
167
|
+
prompt: options.prompt ?? 'consent',
|
|
168
|
+
state: options.state,
|
|
169
|
+
});
|
|
170
|
+
return `${GOOGLE_AUTH_ENDPOINT}?${params.toString()}`;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* The one POST to Google's token endpoint, whichever grant is being exercised.
|
|
174
|
+
*
|
|
175
|
+
* Both grants send form-encoded parameters to the same URL and get back the same body shape,
|
|
176
|
+
* so they share a reader. What differs is the SENTENCE a failure produces, which is why the
|
|
177
|
+
* remedies are parameters.
|
|
178
|
+
*/
|
|
179
|
+
async function postTokenRequest(params, options) {
|
|
180
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
181
|
+
let res;
|
|
182
|
+
try {
|
|
183
|
+
res = await doFetch(GOOGLE_TOKEN_ENDPOINT, {
|
|
184
|
+
method: 'POST',
|
|
185
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
186
|
+
body: new URLSearchParams(params).toString(),
|
|
187
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 30_000),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
return {
|
|
192
|
+
status: 'unavailable',
|
|
193
|
+
cause: 'transport',
|
|
194
|
+
reason: `could not reach Google to ${options.what}: ${err instanceof Error ? err.message : String(err)}`,
|
|
195
|
+
fix: "check this machine's internet connection and try again",
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* 🔴 A parse failure must NEVER mask a refusal. Google answers a revoked refresh token with
|
|
200
|
+
* a 400 whose body is sometimes not JSON at all, and reporting that as "the response was
|
|
201
|
+
* not JSON" sends the owner looking at a transport problem for what is a re-consent. So the
|
|
202
|
+
* STATUS is read first and the body is only ever a source of detail.
|
|
203
|
+
*/
|
|
204
|
+
const body = (await res.json().catch(() => null));
|
|
205
|
+
if (!res.ok || typeof body?.access_token !== 'string' || body.access_token.length === 0) {
|
|
206
|
+
const detail = (typeof body?.error_description === 'string' && body.error_description) ||
|
|
207
|
+
(typeof body?.error === 'string' && body.error) ||
|
|
208
|
+
`HTTP ${res.status}`;
|
|
209
|
+
return res.ok && body === null
|
|
210
|
+
? {
|
|
211
|
+
status: 'unavailable',
|
|
212
|
+
cause: 'transport',
|
|
213
|
+
reason: "Google's token response was not JSON",
|
|
214
|
+
fix: 'try again; this is almost always transient',
|
|
215
|
+
}
|
|
216
|
+
: {
|
|
217
|
+
status: 'unavailable',
|
|
218
|
+
cause: 'refused',
|
|
219
|
+
reason: `Google refused the request: ${detail}`,
|
|
220
|
+
fix: options.refusalFix,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
status: 'ok',
|
|
225
|
+
accessToken: body.access_token,
|
|
226
|
+
...(typeof body.refresh_token === 'string' && body.refresh_token ? { refreshToken: body.refresh_token } : {}),
|
|
227
|
+
expiresInSeconds: typeof body.expires_in === 'number' ? body.expires_in : 3600,
|
|
228
|
+
grantedScopes: typeof body.scope === 'string' ? body.scope.split(/\s+/).filter(Boolean) : [],
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
/** Trade the `?code=` Google sent to the callback for tokens. */
|
|
232
|
+
export function exchangeAuthCode(options) {
|
|
233
|
+
return postTokenRequest({
|
|
234
|
+
code: options.code,
|
|
235
|
+
client_id: options.clientId,
|
|
236
|
+
client_secret: options.clientSecret,
|
|
237
|
+
redirect_uri: options.redirectUri,
|
|
238
|
+
grant_type: 'authorization_code',
|
|
239
|
+
}, {
|
|
240
|
+
...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
|
|
241
|
+
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
|
|
242
|
+
what: 'exchange the consent code',
|
|
243
|
+
// `invalid_grant` here almost always means the code was already used or the
|
|
244
|
+
// redirect_uri does not match the one registered.
|
|
245
|
+
refusalFix: "confirm this app's redirect URI is listed on the OAuth client, then press Connect again",
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Trade a stored refresh token for a fresh access token, REPORTING ITS LIFETIME.
|
|
250
|
+
*
|
|
251
|
+
* The uncached half of {@link createGoogleTokenMinter}, exported because a consumer that
|
|
252
|
+
* PERSISTS the access token has to know when it dies — a caller that guesses an hour is a
|
|
253
|
+
* caller whose token is occasionally already dead when it is used.
|
|
254
|
+
*/
|
|
255
|
+
export function refreshAccessToken(options) {
|
|
256
|
+
return postTokenRequest({
|
|
257
|
+
client_id: options.clientId,
|
|
258
|
+
client_secret: options.clientSecret,
|
|
259
|
+
refresh_token: options.refreshToken,
|
|
260
|
+
grant_type: 'refresh_token',
|
|
261
|
+
}, {
|
|
262
|
+
...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
|
|
263
|
+
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
|
|
264
|
+
what: 'refresh the access token',
|
|
265
|
+
refusalFix: options.refusalFix ?? 'the refresh token may have been revoked — re-run consent',
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Best-effort revocation on disconnect. NEVER throws and never reports failure as a blocker:
|
|
270
|
+
* the local record is deleted either way, and a disconnect that refuses because Google was
|
|
271
|
+
* unreachable leaves the owner holding a connection they asked to end. Google having already
|
|
272
|
+
* forgotten the token answers 400, which is the same outcome as success from here — `false`
|
|
273
|
+
* says only "Google did not confirm it".
|
|
274
|
+
*/
|
|
275
|
+
export async function revokeGoogleToken(token, options = {}) {
|
|
276
|
+
if (!token)
|
|
277
|
+
return false;
|
|
278
|
+
const doFetch = options.fetchImpl ?? fetch;
|
|
279
|
+
try {
|
|
280
|
+
const res = await doFetch(GOOGLE_REVOKE_ENDPOINT, {
|
|
281
|
+
method: 'POST',
|
|
282
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
283
|
+
body: new URLSearchParams({ token }).toString(),
|
|
284
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 15_000),
|
|
285
|
+
});
|
|
286
|
+
return res.ok;
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const DEFAULT_LABEL = 'this app';
|
|
293
|
+
const DEFAULT_SETUP_FIX = 're-run the one-time Google consent for this app';
|
|
294
|
+
/** A token is never handed out so close to expiry that the call it is used for could land after it dies. */
|
|
295
|
+
const EXPIRY_MARGIN_MS = 60_000;
|
|
296
|
+
/**
|
|
297
|
+
* A short-lived access token, cached in memory until {@link EXPIRY_MARGIN_MS} before it
|
|
298
|
+
* expires.
|
|
299
|
+
*
|
|
300
|
+
* The refresh token is exchanged on demand rather than held as a long-lived bearer, and the
|
|
301
|
+
* cache exists because one caller makes several calls across several APIs — minting per call
|
|
302
|
+
* would make the caller's latency proportional to its call count.
|
|
303
|
+
*/
|
|
304
|
+
export function createGoogleTokenMinter(options = {}) {
|
|
305
|
+
const timeout = options.timeoutMs ?? 30_000;
|
|
306
|
+
const now = options.now ?? Date.now;
|
|
307
|
+
const label = options.label ?? DEFAULT_LABEL;
|
|
308
|
+
const fix = options.setupFix ?? DEFAULT_SETUP_FIX;
|
|
309
|
+
let cached = null;
|
|
310
|
+
const mint = async () => {
|
|
311
|
+
const creds = options.credentials !== undefined
|
|
312
|
+
? options.credentials
|
|
313
|
+
: readGoogleCredentials({ env: options.env, secretsFile: options.secretsFile, keys: options.keys });
|
|
314
|
+
if (!creds) {
|
|
315
|
+
return {
|
|
316
|
+
status: 'unconfigured',
|
|
317
|
+
reason: `no Google OAuth credential for ${label} is on this machine yet`,
|
|
318
|
+
fix,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
const refreshed = await refreshAccessToken({
|
|
322
|
+
clientId: creds.clientId,
|
|
323
|
+
clientSecret: creds.clientSecret,
|
|
324
|
+
refreshToken: creds.refreshToken,
|
|
325
|
+
...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
|
|
326
|
+
timeoutMs: timeout,
|
|
327
|
+
refusalFix: fix,
|
|
328
|
+
});
|
|
329
|
+
if (refreshed.status !== 'ok')
|
|
330
|
+
return refreshed;
|
|
331
|
+
cached = {
|
|
332
|
+
accessToken: refreshed.accessToken,
|
|
333
|
+
scopes: refreshed.grantedScopes,
|
|
334
|
+
expiresAt: now() + Math.max(0, refreshed.expiresInSeconds * 1000 - EXPIRY_MARGIN_MS),
|
|
335
|
+
};
|
|
336
|
+
return { status: 'ok', accessToken: cached.accessToken, grantedScopes: cached.scopes };
|
|
337
|
+
};
|
|
338
|
+
// Closures, not `this`: `const { token } = minter` must keep working.
|
|
339
|
+
const token = async () => {
|
|
340
|
+
if (cached && now() < cached.expiresAt) {
|
|
341
|
+
return { status: 'ok', accessToken: cached.accessToken, grantedScopes: cached.scopes };
|
|
342
|
+
}
|
|
343
|
+
return await mint();
|
|
344
|
+
};
|
|
345
|
+
return {
|
|
346
|
+
token,
|
|
347
|
+
async grantedScopes() {
|
|
348
|
+
const t = await token();
|
|
349
|
+
return t.status === 'ok' ? t.grantedScopes : [];
|
|
350
|
+
},
|
|
351
|
+
invalidate() {
|
|
352
|
+
cached = null;
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* 🔴 EXCHANGE the credential rather than assuming a present value is a working one.
|
|
358
|
+
*
|
|
359
|
+
* `readGoogleCredentials` cannot tell a dead value from a live one, because "present" is all
|
|
360
|
+
* it ever asked. The probe does the one thing that answers the question: a real
|
|
361
|
+
* `grant_type=refresh_token` POST, reported with the NAME it used and the names it shadowed.
|
|
362
|
+
* It is safe to run at boot — it mints a short-lived access token, drops it, and never throws.
|
|
363
|
+
*/
|
|
364
|
+
export async function probeCredentialExchange(options = {}) {
|
|
365
|
+
const label = options.label ?? DEFAULT_LABEL;
|
|
366
|
+
const setupFix = options.setupFix ?? DEFAULT_SETUP_FIX;
|
|
367
|
+
// A caller that supplied the credential directly has no names to report, and must not be
|
|
368
|
+
// told about env vars it never read.
|
|
369
|
+
const supplied = options.credentials !== undefined;
|
|
370
|
+
const resolution = supplied
|
|
371
|
+
? null
|
|
372
|
+
: resolveGoogleCredentials({ env: options.env, secretsFile: options.secretsFile, keys: options.keys });
|
|
373
|
+
const credentials = supplied ? (options.credentials ?? null) : (resolution?.credentials ?? null);
|
|
374
|
+
const refreshTokenName = resolution?.used.refreshToken?.name ?? '';
|
|
375
|
+
const shadowed = resolution?.shadowed.refreshToken ?? [];
|
|
376
|
+
if (!credentials) {
|
|
377
|
+
const missing = resolution?.missing ?? [];
|
|
378
|
+
return {
|
|
379
|
+
status: 'unconfigured',
|
|
380
|
+
refreshTokenName,
|
|
381
|
+
shadowedRefreshTokenNames: shadowed,
|
|
382
|
+
grantedScopes: [],
|
|
383
|
+
summary: `no Google OAuth credential for ${label} is on this machine yet` +
|
|
384
|
+
(missing.length > 0 ? ` — nothing supplies ${missing.join(' or ')}` : ''),
|
|
385
|
+
fix: setupFix,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
const refreshed = await refreshAccessToken({
|
|
389
|
+
clientId: credentials.clientId,
|
|
390
|
+
clientSecret: credentials.clientSecret,
|
|
391
|
+
refreshToken: credentials.refreshToken,
|
|
392
|
+
...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
|
|
393
|
+
timeoutMs: options.timeoutMs ?? 30_000,
|
|
394
|
+
refusalFix: setupFix,
|
|
395
|
+
});
|
|
396
|
+
// The name is on BOTH outcomes on purpose. A green probe that does not say which of two
|
|
397
|
+
// spellings it proved is a green probe that proves nothing about the other one.
|
|
398
|
+
const used = refreshTokenName ? ` using ${refreshTokenName}` : '';
|
|
399
|
+
const one = shadowed.length === 1;
|
|
400
|
+
if (refreshed.status === 'ok') {
|
|
401
|
+
const alsoSet = shadowed.length > 0
|
|
402
|
+
? ` ${shadowed.join(' and ')} also hold${one ? 's' : ''} a value for the same credential and ${one ? 'is' : 'are'} never reached.`
|
|
403
|
+
: '';
|
|
404
|
+
return {
|
|
405
|
+
status: 'ok',
|
|
406
|
+
refreshTokenName,
|
|
407
|
+
shadowedRefreshTokenNames: shadowed,
|
|
408
|
+
grantedScopes: refreshed.grantedScopes,
|
|
409
|
+
summary: `Google exchanged ${label}'s refresh token${used} — ${refreshed.grantedScopes.length} scope(s) granted.${alsoSet}`,
|
|
410
|
+
fix: '',
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
// A machine that could not reach Google has proved nothing about the credential, and must
|
|
414
|
+
// not be reported as though it had.
|
|
415
|
+
if (refreshed.cause === 'transport') {
|
|
416
|
+
return {
|
|
417
|
+
status: 'unreachable',
|
|
418
|
+
refreshTokenName,
|
|
419
|
+
shadowedRefreshTokenNames: shadowed,
|
|
420
|
+
grantedScopes: [],
|
|
421
|
+
summary: `${refreshed.reason} — ${label}'s credential was NOT tested, so this says nothing about whether it works.`,
|
|
422
|
+
fix: refreshed.fix,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
// 🔴 "The grant has been revoked" is the WRONG diagnosis when a second, untried value for
|
|
426
|
+
// the same credential is sitting in the same file.
|
|
427
|
+
const alsoUntried = shadowed.length > 0
|
|
428
|
+
? ` ${shadowed.join(' and ')} also hold${one ? 's' : ''} a value for the same credential and ${one ? 'was' : 'were'} never tried.`
|
|
429
|
+
: '';
|
|
430
|
+
return {
|
|
431
|
+
status: 'refused',
|
|
432
|
+
refreshTokenName,
|
|
433
|
+
shadowedRefreshTokenNames: shadowed,
|
|
434
|
+
grantedScopes: [],
|
|
435
|
+
summary: `${refreshed.reason}${used} — ${label}'s Google calls will all fail until this is fixed.${alsoUntried}`,
|
|
436
|
+
fix: shadowed.length > 0
|
|
437
|
+
? `Before re-consenting, check whether ${shadowed.join(' or ')} holds the live grant: the preferred name ${refreshTokenName} wins by being PRESENT, not by working. Leaving exactly one of those names set resolves it either way. Otherwise: ${setupFix}`
|
|
438
|
+
: setupFix,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
// ─── 403 → remedy ────────────────────────────────────────────────────────────
|
|
442
|
+
/**
|
|
443
|
+
* Turn a Google 403 into something the owner can act on.
|
|
444
|
+
*
|
|
445
|
+
* A missing scope is the single most likely failure after the owner consents once and the app
|
|
446
|
+
* later grows a capability. Naming the MISSING scope and the re-consent command is the
|
|
447
|
+
* difference between a two-minute fix and an evening.
|
|
448
|
+
*
|
|
449
|
+
* 🔴 The two branches say genuinely different things: `ACCESS_TOKEN_SCOPE_INSUFFICIENT` is a
|
|
450
|
+
* consent problem fixed in a browser, while a 403 on a granted scope is an API that is not
|
|
451
|
+
* ENABLED on the project, fixed in a different console page entirely.
|
|
452
|
+
*/
|
|
453
|
+
export function describeScopeFailure(options) {
|
|
454
|
+
const has = options.granted.includes(options.needed);
|
|
455
|
+
if (!has && options.granted.length > 0) {
|
|
456
|
+
return {
|
|
457
|
+
reason: `${options.api} refused the call (HTTP ${options.httpStatus}): the grant on this machine does NOT include ${options.needed}. It has: ${options.granted.join(', ')}`,
|
|
458
|
+
fix: `run ${options.consentCommand} and approve ${options.needed}. The existing token keeps working for everything it already covers, so nothing else breaks while you do it.`,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
return {
|
|
462
|
+
reason: `${options.api} refused the call (HTTP ${options.httpStatus}) even though ${options.needed} appears to be granted — the API may be disabled for this Google Cloud project`,
|
|
463
|
+
fix: `open Google Cloud Console → APIs and Services → Library and confirm the ${options.api} API is ENABLED for the project that owns this OAuth client`,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
@@ -14,7 +14,7 @@ export declare function sealPasswordVerifier(userId: string, password: Uint8Arra
|
|
|
14
14
|
* stored envelope is corruption, not a wrong password, and re-throws.
|
|
15
15
|
*
|
|
16
16
|
* The comparison is constant-time in the sensitive part: argon2id derivation
|
|
17
|
-
* dominates the timing, and GCM tag verification plus the final `
|
|
17
|
+
* dominates the timing, and GCM tag verification plus the final `constantTimeEqualBytes`
|
|
18
18
|
* are constant-time, so a right and a wrong password of equal length are
|
|
19
19
|
* indistinguishable by timing.
|
|
20
20
|
*/
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
* every path including a thrown KDF. Callers hand over ownership and must not
|
|
30
30
|
* touch the buffer afterwards.
|
|
31
31
|
*/
|
|
32
|
-
import {
|
|
32
|
+
import { constantTimeEqualBytes } from 'cwip/constant-time';
|
|
33
33
|
import { createSecretEnvelopeManager, mintEnvelopeSalt, parseSecretEnvelope, serializeSecretEnvelope, zeroize, } from 'cwip/guard';
|
|
34
34
|
import { isAppError } from '../errors.js';
|
|
35
35
|
/**
|
|
@@ -68,7 +68,7 @@ export function sealPasswordVerifier(userId, password, deriver) {
|
|
|
68
68
|
* stored envelope is corruption, not a wrong password, and re-throws.
|
|
69
69
|
*
|
|
70
70
|
* The comparison is constant-time in the sensitive part: argon2id derivation
|
|
71
|
-
* dominates the timing, and GCM tag verification plus the final `
|
|
71
|
+
* dominates the timing, and GCM tag verification plus the final `constantTimeEqualBytes`
|
|
72
72
|
* are constant-time, so a right and a wrong password of equal length are
|
|
73
73
|
* indistinguishable by timing.
|
|
74
74
|
*/
|
|
@@ -90,7 +90,7 @@ export function verifyPasswordEnvelope(userId, envelopeJson, password, deriver)
|
|
|
90
90
|
// openWithPassphrase re-derives a one-shot key from the envelope's own salt +
|
|
91
91
|
// the presented password, opens, and zeroizes `password` on every path.
|
|
92
92
|
opened = manager.openWithPassphrase(envelope, refFor(userId), password);
|
|
93
|
-
return
|
|
93
|
+
return constantTimeEqualBytes(opened, VERIFIER_PLAINTEXT);
|
|
94
94
|
}
|
|
95
95
|
catch (error) {
|
|
96
96
|
// GCM's rejection is the wrong-password verdict — the ONLY error we swallow.
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* per capability, sha256 at rest so a database read never yields a usable credential).
|
|
5
5
|
*
|
|
6
6
|
* The plaintext token is returned exactly once, at mint. Verification compares sha256
|
|
7
|
-
* digests via `
|
|
7
|
+
* digests via cwip's `constantTimeEqualBytes` — no length guard, no early exit.
|
|
8
8
|
*/
|
|
9
9
|
import type { Database } from "bun:sqlite";
|
|
10
10
|
export interface DeviceToken {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { createHash, randomBytes
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { constantTimeEqualBytes } from "cwip/constant-time";
|
|
2
3
|
const sha256 = (value) => createHash("sha256").update(value).digest();
|
|
3
4
|
export function createTokenStore(db) {
|
|
4
5
|
db.exec(`
|
|
@@ -35,7 +36,7 @@ export function createTokenStore(db) {
|
|
|
35
36
|
// the lookup timing depend on the presented value.
|
|
36
37
|
for (const raw of allLive.all()) {
|
|
37
38
|
const want = Buffer.from(raw.token_sha256);
|
|
38
|
-
if (
|
|
39
|
+
if (constantTimeEqualBytes(want, got)) {
|
|
39
40
|
if (raw.scope !== scope)
|
|
40
41
|
return null;
|
|
41
42
|
return toToken(raw);
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-server",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.14.0",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"description": "The app-facing Bun/Hono server tier of the cursedbelt split
|
|
6
|
+
"description": "The app-facing Bun/Hono server tier of the cursedbelt split — storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
|
|
7
7
|
"cursed": {
|
|
8
8
|
"schemaVersion": 1,
|
|
9
9
|
"app": {
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"test": "NODE_ENV=development bun test src",
|
|
22
22
|
"build": "bun run clean && tsc -p tsconfig.build.json",
|
|
23
23
|
"paths": "bun run scripts/paths.ts",
|
|
24
|
+
"surface": "public-surface",
|
|
24
25
|
"verify": "bun run paths && bun run typecheck && bun run build && bun run test",
|
|
25
26
|
"prepublishOnly": "bun run verify",
|
|
26
27
|
"test:pg": "bun run scripts/testPg.ts"
|
|
@@ -114,6 +115,12 @@
|
|
|
114
115
|
"source": "./src/server/errors.ts",
|
|
115
116
|
"import": "./dist/server/errors.js"
|
|
116
117
|
},
|
|
118
|
+
"./google-oauth": {
|
|
119
|
+
"types": "./dist/server/google-oauth/googleOAuth.d.ts",
|
|
120
|
+
"bun": "./src/server/google-oauth/googleOAuth.ts",
|
|
121
|
+
"source": "./src/server/google-oauth/googleOAuth.ts",
|
|
122
|
+
"import": "./dist/server/google-oauth/googleOAuth.js"
|
|
123
|
+
},
|
|
117
124
|
"./guard": {
|
|
118
125
|
"types": "./dist/server/guard/index.d.ts",
|
|
119
126
|
"bun": "./src/server/guard/index.ts",
|
|
@@ -276,7 +283,8 @@
|
|
|
276
283
|
"@node-rs/argon2": "^2.0.2",
|
|
277
284
|
"@types/bun": "^1.3.14",
|
|
278
285
|
"@types/node": "^24",
|
|
279
|
-
"cursedbelt": "^4.
|
|
286
|
+
"cursedbelt": "^4.5.0",
|
|
287
|
+
"cursedops": "^0.2.7",
|
|
280
288
|
"hono": "4.12.28",
|
|
281
289
|
"kysely": "^0.28.17",
|
|
282
290
|
"kysely-bun-sqlite": "^0.4.0",
|
|
@@ -151,6 +151,16 @@ const LEAVES = [
|
|
|
151
151
|
*/
|
|
152
152
|
evaluates: 'readBoundedJson',
|
|
153
153
|
},
|
|
154
|
+
{
|
|
155
|
+
subpath: './google-oauth',
|
|
156
|
+
/**
|
|
157
|
+
* Google OAuth — consent URL, code exchange, refresh, revoke, the cached minter and the
|
|
158
|
+
* credential probe. Arrived in 4.14.0 from two app-side copies (task 099). Its promise is
|
|
159
|
+
* `node:fs`/`node:os`/`node:path` and nothing else: it is ONE file precisely so this
|
|
160
|
+
* fixture, which copies only the exported file, can prove it reaches no sibling either.
|
|
161
|
+
*/
|
|
162
|
+
evaluates: 'createGoogleTokenMinter',
|
|
163
|
+
},
|
|
154
164
|
] as const;
|
|
155
165
|
|
|
156
166
|
/**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'bun:test';
|
|
2
|
-
import {
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { run } from 'cursedops/public-surface';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* The public-surface ratchet, wired into `bun run verify` at its cheapest correct
|
|
@@ -8,10 +9,15 @@ import { run } from '../scripts/publicSurface.js';
|
|
|
8
9
|
*
|
|
9
10
|
* WHY it exists, WHAT a symbol count includes and excludes, why the dependency list
|
|
10
11
|
* and the used-vs-exported ratio are deliberately NOT computed here, and what
|
|
11
|
-
* `--prune` may and may not do are all in the header of `
|
|
12
|
+
* `--prune` may and may not do are all in the header of `cursedops/src/publicSurface.ts` —
|
|
12
13
|
* one check, one baseline (`publicSurface.baseline`), one place that explains itself.
|
|
13
14
|
* This file is only the wiring.
|
|
14
15
|
*
|
|
16
|
+
* Since 2026-09-22 (task 084) the measurement is ONE body, `cursedops/public-surface`, a
|
|
17
|
+
* devDependency: this package's own `scripts/publicSurface.ts` had forked from the other
|
|
18
|
+
* two libraries' copies. `bun run surface [--prune]` is the CLI. The probes below were
|
|
19
|
+
* run against that former copy; the shared body measured identical per-subpath counts.
|
|
20
|
+
*
|
|
15
21
|
* 🔴 Verified failing, 2026-09-19, before it was trusted — every branch, not just the
|
|
16
22
|
* easy one. Each probe was applied, observed, and removed again:
|
|
17
23
|
*
|
|
@@ -48,11 +54,13 @@ import { run } from '../scripts/publicSurface.js';
|
|
|
48
54
|
* → "refuses: … already records 34 subpath(s)", exit 1
|
|
49
55
|
*
|
|
50
56
|
* Every probe was reverted with `git checkout -- <path>`; the tree afterwards held only
|
|
51
|
-
* this file, `scripts/publicSurface.ts` and `publicSurface.baseline`.
|
|
57
|
+
* this file, the then `scripts/publicSurface.ts` and `publicSurface.baseline`.
|
|
52
58
|
*
|
|
53
59
|
* A ratchet nobody has seen red is a ratchet nobody knows is wired up.
|
|
54
60
|
*/
|
|
55
|
-
|
|
61
|
+
// The package this spec lives in — resolved from this file, never from `process.cwd()`,
|
|
62
|
+
// because `bun test` can be started from anywhere.
|
|
63
|
+
const result = await run({ root: dirname(import.meta.dir) });
|
|
56
64
|
|
|
57
65
|
describe('public surface', () => {
|
|
58
66
|
it('has measured a real surface', () => {
|
|
@@ -82,7 +90,7 @@ describe('public surface', () => {
|
|
|
82
90
|
// behind for the next one to grow into.
|
|
83
91
|
expect(
|
|
84
92
|
result.stale,
|
|
85
|
-
`surface came off and the ratchet was not tightened — run \`bun
|
|
93
|
+
`surface came off and the ratchet was not tightened — run \`bun run surface --prune\`:\n ${result.stale.join('\n ')}`,
|
|
86
94
|
).toEqual([]);
|
|
87
95
|
});
|
|
88
96
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { pbkdf2 as pbkdf2Cb
|
|
1
|
+
import { pbkdf2 as pbkdf2Cb } from 'node:crypto';
|
|
2
|
+
import { constantTimeEqual } from 'cwip/constant-time';
|
|
2
3
|
import { promisify } from 'node:util';
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -55,10 +56,8 @@ export async function verifyPassword(plain: string, stored: string): Promise<boo
|
|
|
55
56
|
const derived = (
|
|
56
57
|
await pbkdf2(plain, salt, LEGACY_ITERATIONS, LEGACY_KEYLEN, LEGACY_DIGEST)
|
|
57
58
|
).toString('hex');
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
if (derived.length !== expectedHex.length) return false;
|
|
61
|
-
return timingSafeEqual(Buffer.from(derived), Buffer.from(expectedHex));
|
|
59
|
+
// cwip's fold: no length guard to own, no early exit (task 322).
|
|
60
|
+
return constantTimeEqual(derived, expectedHex);
|
|
62
61
|
}
|
|
63
62
|
|
|
64
63
|
/**
|