@payez/next-mvp 4.4.2 → 4.4.4
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/auth/better-auth.js +130 -1
- package/dist/auth/safe-callback.d.ts +30 -1
- package/dist/auth/safe-callback.js +80 -9
- package/package.json +3 -2
- package/scripts/module-reach.mjs +286 -0
- package/src/auth/better-auth.ts +149 -3
- package/src/auth/safe-callback.ts +80 -8
package/dist/auth/better-auth.js
CHANGED
|
@@ -147,6 +147,20 @@ function isPlainObject(value) {
|
|
|
147
147
|
* globalThis is per-process and per-runtime, which is exactly the lifetime a request-scoped
|
|
148
148
|
* carrier needs to be reachable from every copy that might run inside the request.
|
|
149
149
|
*/
|
|
150
|
+
/**
|
|
151
|
+
* THE KEY STAYS '__payezIdTokenScope' EVEN THOUGH THE SCOPE NOW CARRIES TWO
|
|
152
|
+
* THINGS. Renaming it would give a copy on the old name and a copy on the new
|
|
153
|
+
* name two DIFFERENT stores in the same process — which is precisely the
|
|
154
|
+
* double-load failure documented above, reintroduced by a cosmetic rename. The
|
|
155
|
+
* name is a wire contract between copies, not a description.
|
|
156
|
+
*
|
|
157
|
+
* accessToken is here for providers that do NOT issue an id_token. GitHub is
|
|
158
|
+
* not an OIDC provider: it has no id_token, no JWKS and no discovery document,
|
|
159
|
+
* so `id_token` can never be the field that proves a GitHub identity. The IDP
|
|
160
|
+
* re-derives that identity by calling GitHub itself with this access token
|
|
161
|
+
* (GitHubIdentityVerifier), which keeps the same rule google follows — the IDP
|
|
162
|
+
* verifies for itself and never trusts an asserted email.
|
|
163
|
+
*/
|
|
150
164
|
const ID_TOKEN_SCOPE_KEY = '__payezIdTokenScope';
|
|
151
165
|
const gScope = globalThis;
|
|
152
166
|
gScope[ID_TOKEN_SCOPE_KEY] ??= new node_async_hooks_1.AsyncLocalStorage();
|
|
@@ -248,9 +262,21 @@ function buildBetterAuthProviders(config) {
|
|
|
248
262
|
* remains, so the provider emits exactly its default set.
|
|
249
263
|
*/
|
|
250
264
|
const GOOGLE_DEFAULT_SCOPES = ['email', 'profile', 'openid'];
|
|
265
|
+
/**
|
|
266
|
+
* Same defect, same fix, different provider: github's createAuthorizationURL
|
|
267
|
+
* also seeds ['read:user','user:email'] unconditionally and then pushes ours
|
|
268
|
+
* on top (@better-auth/core social-providers/github.ts), so the stored row
|
|
269
|
+
* "read:user user:email" reached GitHub as
|
|
270
|
+
* "read:user user:email read:user user:email". Measured on :4799, not
|
|
271
|
+
* assumed. GitHub dedupes, so this is legibility rather than function — but
|
|
272
|
+
* a scope list is the first thing a security reviewer reads off that URL.
|
|
273
|
+
*/
|
|
274
|
+
const GITHUB_DEFAULT_SCOPES = ['read:user', 'user:email'];
|
|
251
275
|
const wireScopes = name === 'google'
|
|
252
276
|
? scopes.filter((s) => !GOOGLE_DEFAULT_SCOPES.includes(s))
|
|
253
|
-
:
|
|
277
|
+
: name === 'github'
|
|
278
|
+
? scopes.filter((s) => !GITHUB_DEFAULT_SCOPES.includes(s))
|
|
279
|
+
: scopes;
|
|
254
280
|
providers[name] = {
|
|
255
281
|
clientId: oauth.clientId,
|
|
256
282
|
clientSecret: oauth.clientSecret,
|
|
@@ -351,6 +377,82 @@ function buildBetterAuthProviders(config) {
|
|
|
351
377
|
};
|
|
352
378
|
};
|
|
353
379
|
}
|
|
380
|
+
/**
|
|
381
|
+
* GITHUB: THE SAME CAPTURE, A DIFFERENT TOKEN.
|
|
382
|
+
*
|
|
383
|
+
* GitHub is not an OIDC provider — it issues no id_token — so the google
|
|
384
|
+
* branch above can never fire for it and the exchange would post an
|
|
385
|
+
* assertion with nothing that proves identity. That is exactly the
|
|
386
|
+
* 401 UNVERIFIED_ASSERTION measured on :4799, whose diagnostics read
|
|
387
|
+
* `id_token_forwarded: false, id_token_length: 0` with `response_time_ms: 0`:
|
|
388
|
+
* the IDP rejected it instantly, without ever calling GitHub.
|
|
389
|
+
*
|
|
390
|
+
* getUserInfo is the ONLY provider hook that receives the token set (see the
|
|
391
|
+
* google note above), and @better-auth/core's github provider calls
|
|
392
|
+
* `options.getUserInfo(token)` BEFORE its own body — so installing this
|
|
393
|
+
* REPLACES the default rather than wrapping it, and the default's work has
|
|
394
|
+
* to be reproduced here or the sign-in loses its profile entirely.
|
|
395
|
+
*
|
|
396
|
+
* Mirrored verbatim from @better-auth/core/src/social-providers/github.ts at
|
|
397
|
+
* the version the container actually runs (better-auth 1.5.6 — the graft's
|
|
398
|
+
* node_modules says 1.6.29 and is NOT what executes). Kept deliberately
|
|
399
|
+
* dumb: it must not "improve" on upstream, because every divergence here is
|
|
400
|
+
* a behaviour change disguised as a token capture.
|
|
401
|
+
*
|
|
402
|
+
* This is NOT the identity check. The IDP re-derives the GitHub identity
|
|
403
|
+
* itself from this access token and enforces primary && verified server-side
|
|
404
|
+
* (GitHubIdentityVerifier); what we return here only shapes the local
|
|
405
|
+
* better-auth user record.
|
|
406
|
+
*/
|
|
407
|
+
if (name === 'github') {
|
|
408
|
+
providers[name].getUserInfo = async (token) => {
|
|
409
|
+
const store = idTokenScope.getStore();
|
|
410
|
+
if (store) {
|
|
411
|
+
store.accessToken = token?.accessToken;
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
console.warn('[BETTER_AUTH] github access_token captured but NO REQUEST SCOPE to put it in — ' +
|
|
415
|
+
'it will NOT be forwarded and the IDP exchange will fail UNVERIFIED_ASSERTION. ' +
|
|
416
|
+
'Check that GET is createAuthGetHandler(), not routes/auth/nextauth.');
|
|
417
|
+
}
|
|
418
|
+
const ghHeaders = {
|
|
419
|
+
'User-Agent': 'better-auth',
|
|
420
|
+
Authorization: `Bearer ${token?.accessToken}`,
|
|
421
|
+
};
|
|
422
|
+
const profileRes = await fetch('https://api.github.com/user', {
|
|
423
|
+
headers: ghHeaders,
|
|
424
|
+
});
|
|
425
|
+
// Upstream returns null on profile error, which fails the sign-in
|
|
426
|
+
// closed. Preserved rather than softened.
|
|
427
|
+
if (!profileRes.ok) {
|
|
428
|
+
console.warn(`[BETTER_AUTH] github getUserInfo: /user returned ${profileRes.status}`);
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
const profile = (await profileRes.json());
|
|
432
|
+
const emailsRes = await fetch('https://api.github.com/user/emails', {
|
|
433
|
+
headers: ghHeaders,
|
|
434
|
+
});
|
|
435
|
+
const emails = emailsRes.ok
|
|
436
|
+
? (await emailsRes.json())
|
|
437
|
+
: undefined;
|
|
438
|
+
// A GitHub account with a private email returns profile.email = null;
|
|
439
|
+
// upstream falls back to the primary address here.
|
|
440
|
+
if (!profile.email && emails) {
|
|
441
|
+
profile.email = (emails.find((e) => e.primary) ?? emails[0])?.email;
|
|
442
|
+
}
|
|
443
|
+
const emailVerified = emails?.find((e) => e.email === profile.email)?.verified ?? false;
|
|
444
|
+
return {
|
|
445
|
+
user: {
|
|
446
|
+
id: profile.id,
|
|
447
|
+
name: profile.name || profile.login || '',
|
|
448
|
+
email: profile.email,
|
|
449
|
+
image: profile.avatar_url,
|
|
450
|
+
emailVerified,
|
|
451
|
+
},
|
|
452
|
+
data: profile,
|
|
453
|
+
};
|
|
454
|
+
};
|
|
455
|
+
}
|
|
354
456
|
}
|
|
355
457
|
return providers;
|
|
356
458
|
}
|
|
@@ -802,6 +904,21 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
|
|
|
802
904
|
* distinguishable from "the provider issued none".
|
|
803
905
|
*/
|
|
804
906
|
...(idTokenScope.getStore()?.idToken ? { id_token: idTokenScope.getStore().idToken } : {}),
|
|
907
|
+
/**
|
|
908
|
+
* The same field for providers that have no id_token to give.
|
|
909
|
+
*
|
|
910
|
+
* Identical doctrine to the id_token above: RAW, forwarded verbatim,
|
|
911
|
+
* NOTHING DERIVED FROM IT, and omitted entirely rather than sent null so
|
|
912
|
+
* "never captured" stays distinguishable from "provider issued none".
|
|
913
|
+
* The IDP verifies for itself by calling GitHub with this token and
|
|
914
|
+
* enforcing primary && verified; we assert nothing about the identity.
|
|
915
|
+
*
|
|
916
|
+
* Scoped to the provider access token captured during THIS callback, so
|
|
917
|
+
* a google login continues to send id_token only and is unaffected.
|
|
918
|
+
*/
|
|
919
|
+
...(idTokenScope.getStore()?.accessToken
|
|
920
|
+
? { access_token: idTokenScope.getStore().accessToken }
|
|
921
|
+
: {}),
|
|
805
922
|
}),
|
|
806
923
|
});
|
|
807
924
|
const oauthResText = await oauthRes.text();
|
|
@@ -833,9 +950,21 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
|
|
|
833
950
|
claims = { decode: 'FAILED — not a well-formed JWT' };
|
|
834
951
|
}
|
|
835
952
|
}
|
|
953
|
+
const forwardedAccess = idTokenScope.getStore()?.accessToken;
|
|
836
954
|
console.error('[BETTER_AUTH] exchange diagnostics:', {
|
|
837
955
|
id_token_forwarded: !!forwarded,
|
|
838
956
|
id_token_length: forwarded ? forwarded.length : 0,
|
|
957
|
+
/**
|
|
958
|
+
* Read these two for github, and IGNORE the id_token pair above: github
|
|
959
|
+
* issues no id_token, so `id_token_forwarded: false` is its NORMAL state
|
|
960
|
+
* and not the fault. Presence and length only — an access token is
|
|
961
|
+
* credential material and this log is persistent. A github failure with
|
|
962
|
+
* access_token_forwarded:true means the IDP called GitHub and refused
|
|
963
|
+
* the answer (unverified or non-primary email, or app not installed);
|
|
964
|
+
* false means the capture hook never ran and the IDP never called out.
|
|
965
|
+
*/
|
|
966
|
+
access_token_forwarded: !!forwardedAccess,
|
|
967
|
+
access_token_length: forwardedAccess ? forwardedAccess.length : 0,
|
|
839
968
|
// Absent id_token => the IDP had nothing to verify. Present => compare these
|
|
840
969
|
// against the client's registered Google credentials on the IDP.
|
|
841
970
|
aud: claims.aud ?? null,
|
|
@@ -21,6 +21,27 @@
|
|
|
21
21
|
* v1 `startsWith('/') && !startsWith('//')` -> bypassed by /BACKSLASH host
|
|
22
22
|
* v2 + `!startsWith('/' + BACKSLASH)` -> bypassed by /TAB/host
|
|
23
23
|
* ... and /LF/host, /CR/host
|
|
24
|
+
* v3 resolve once + compare ORIGIN -> bypassed by /..//host
|
|
25
|
+
* and /.//host, /%2e%2e//host
|
|
26
|
+
* v4 + require the RESULT to be a FIXED POINT <- current
|
|
27
|
+
*
|
|
28
|
+
* *** DRIFT, 2026-09-11 (325543). THIS FILE WAS v3 WHILE THE PORTAL'S COPY WAS
|
|
29
|
+
* *** v4, AND THE PACKAGE COPY IS THE ONE THAT SHIPPED. The portal fixed its own
|
|
30
|
+
* *** src/lib/safe-callback.ts (e1f08fea) and deployed; the served bundle STILL
|
|
31
|
+
* *** escaped 5/5 in a real browser, because `src/contexts/ThemeContext.tsx`
|
|
32
|
+
* *** imports the BARE BARREL `@payez/next-mvp`, which pulls dist/index.js ->
|
|
33
|
+
* *** auth/auth-decision -> THIS FILE into the client graph, and because the
|
|
34
|
+
* *** portal's `src/middleware.ts` imports `@payez/next-mvp/edge`, which requires
|
|
35
|
+
* *** THIS FILE server-side on every request. It was read as a stale build
|
|
36
|
+
* *** artifact of the portal's file for most of a day. It was not. It was a
|
|
37
|
+
* *** SECOND DEFINITION, and no rebuild of the portal could have removed it.
|
|
38
|
+
* *** The header below says "ONE definition ... for the whole package" and that
|
|
39
|
+
* *** was true INSIDE this package boundary the entire time. The defect lived in
|
|
40
|
+
* *** the gap BETWEEN two boundaries that each enforced one-definition correctly.
|
|
41
|
+
* *** A guard that exists in N copies is only as strong as its weakest copy, and
|
|
42
|
+
* *** a per-repo grep gate cannot see N. IF YOU CHANGE THE PREDICATE HERE, CHANGE
|
|
43
|
+
* *** IT IN THE PORTAL'S src/lib/safe-callback.ts IN THE SAME HOUR, OR DELETE ONE
|
|
44
|
+
* *** OF THEM.
|
|
24
45
|
*
|
|
25
46
|
* Both bypasses work the same way: the browser NORMALISES the string AFTER the
|
|
26
47
|
* guard has finished looking at it. The WHATWG URL parser converts backslashes
|
|
@@ -45,7 +66,15 @@ export declare const DEFAULT_CALLBACK = "/";
|
|
|
45
66
|
* the auth surface. Never throws.
|
|
46
67
|
*/
|
|
47
68
|
export declare function safeCallbackPath(raw: unknown, fallback?: string): string;
|
|
48
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* True when `raw` is a callback target we are willing to honour.
|
|
71
|
+
*
|
|
72
|
+
* DEFINED IN TERMS OF `safeCallbackPath` ON PURPOSE - it used to carry its own
|
|
73
|
+
* copy of the predicate, and that copy was missing the second resolution above
|
|
74
|
+
* while the two were maintained separately. This file's whole history is
|
|
75
|
+
* duplicated predicates drifting apart (see the header). One definition, one
|
|
76
|
+
* place to fix.
|
|
77
|
+
*/
|
|
49
78
|
export declare function isSafeCallbackPath(raw: unknown): raw is string;
|
|
50
79
|
/**
|
|
51
80
|
* Pick the post-login destination: an explicit `?callbackUrl=` when it is one we
|
|
@@ -22,6 +22,27 @@
|
|
|
22
22
|
* v1 `startsWith('/') && !startsWith('//')` -> bypassed by /BACKSLASH host
|
|
23
23
|
* v2 + `!startsWith('/' + BACKSLASH)` -> bypassed by /TAB/host
|
|
24
24
|
* ... and /LF/host, /CR/host
|
|
25
|
+
* v3 resolve once + compare ORIGIN -> bypassed by /..//host
|
|
26
|
+
* and /.//host, /%2e%2e//host
|
|
27
|
+
* v4 + require the RESULT to be a FIXED POINT <- current
|
|
28
|
+
*
|
|
29
|
+
* *** DRIFT, 2026-09-11 (325543). THIS FILE WAS v3 WHILE THE PORTAL'S COPY WAS
|
|
30
|
+
* *** v4, AND THE PACKAGE COPY IS THE ONE THAT SHIPPED. The portal fixed its own
|
|
31
|
+
* *** src/lib/safe-callback.ts (e1f08fea) and deployed; the served bundle STILL
|
|
32
|
+
* *** escaped 5/5 in a real browser, because `src/contexts/ThemeContext.tsx`
|
|
33
|
+
* *** imports the BARE BARREL `@payez/next-mvp`, which pulls dist/index.js ->
|
|
34
|
+
* *** auth/auth-decision -> THIS FILE into the client graph, and because the
|
|
35
|
+
* *** portal's `src/middleware.ts` imports `@payez/next-mvp/edge`, which requires
|
|
36
|
+
* *** THIS FILE server-side on every request. It was read as a stale build
|
|
37
|
+
* *** artifact of the portal's file for most of a day. It was not. It was a
|
|
38
|
+
* *** SECOND DEFINITION, and no rebuild of the portal could have removed it.
|
|
39
|
+
* *** The header below says "ONE definition ... for the whole package" and that
|
|
40
|
+
* *** was true INSIDE this package boundary the entire time. The defect lived in
|
|
41
|
+
* *** the gap BETWEEN two boundaries that each enforced one-definition correctly.
|
|
42
|
+
* *** A guard that exists in N copies is only as strong as its weakest copy, and
|
|
43
|
+
* *** a per-repo grep gate cannot see N. IF YOU CHANGE THE PREDICATE HERE, CHANGE
|
|
44
|
+
* *** IT IN THE PORTAL'S src/lib/safe-callback.ts IN THE SAME HOUR, OR DELETE ONE
|
|
45
|
+
* *** OF THEM.
|
|
25
46
|
*
|
|
26
47
|
* Both bypasses work the same way: the browser NORMALISES the string AFTER the
|
|
27
48
|
* guard has finished looking at it. The WHATWG URL parser converts backslashes
|
|
@@ -77,19 +98,69 @@ function safeCallbackPath(raw, fallback = exports.DEFAULT_CALLBACK) {
|
|
|
77
98
|
if (url.pathname.startsWith(AUTH_PREFIX))
|
|
78
99
|
return fallback;
|
|
79
100
|
// The parser's normalised output - control characters already stripped.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
101
|
+
const candidate = url.pathname + url.search + url.hash;
|
|
102
|
+
// *** SECOND RESOLUTION. DO NOT DELETE THIS AS REDUNDANT - IT IS NOT. ***
|
|
103
|
+
//
|
|
104
|
+
// v3 (the origin comparison above) was bypassed too, and differently from v1/v2.
|
|
105
|
+
// The origin check constrains where `raw` RESOLVED TO. It does not constrain how
|
|
106
|
+
// the RESULT IS SPELLED - and the result is resolved a SECOND time, by whoever
|
|
107
|
+
// navigates to it. A returned path that begins with `//` is a PROTOCOL-RELATIVE
|
|
108
|
+
// URL at that second resolution, so it lands off-origin having passed every
|
|
109
|
+
// check here:
|
|
110
|
+
//
|
|
111
|
+
// '/..//evil.example.com' -> pathname '//evil.example.com' -> evil.example.com
|
|
112
|
+
// '/.//evil.example.com' -> pathname '//evil.example.com' -> evil.example.com
|
|
113
|
+
// '/%2e%2e//evil.example.com' -> pathname '//evil.example.com' -> evil.example.com
|
|
114
|
+
//
|
|
115
|
+
// The parser resolves `..` against the synthetic base and, at the root, leaves
|
|
116
|
+
// behind the EMPTY SEGMENT rather than collapsing it. Origin stayed BASE the
|
|
117
|
+
// whole time, so nothing upstream fired.
|
|
118
|
+
//
|
|
119
|
+
// MEASURED, not reasoned: in a real headless Chromium on the live origin
|
|
120
|
+
// https://portal.keelauth.com, 5 spellings resolved to https://evil.example.com
|
|
121
|
+
// (QAPert-NightHawk, 2026-09-11, card 325543), and on 2026-09-11 the SERVED
|
|
122
|
+
// BUNDLE was measured escaping 5/5 through THIS PACKAGE'S copy while the
|
|
123
|
+
// portal's own src/lib/safe-callback.ts was already fixed - the two copies had
|
|
124
|
+
// drifted. See the DRIFT note in this file's header.
|
|
125
|
+
//
|
|
126
|
+
// THE FIX FOLLOWS THIS FILE'S OWN DOCTRINE: do not pattern-match the spellings
|
|
127
|
+
// (that is the startsWith() trap that failed four times, and this would be the
|
|
128
|
+
// fifth). Ask the parser AGAIN about the exact value we are about to hand out,
|
|
129
|
+
// and require it to be a FIXED POINT. Anything whose meaning changes when it is
|
|
130
|
+
// resolved a second time is by definition not a stable same-origin path, however
|
|
131
|
+
// it is spelled - including spellings nobody has found yet.
|
|
132
|
+
let echo;
|
|
86
133
|
try {
|
|
87
|
-
|
|
88
|
-
return url.origin === BASE && !url.pathname.startsWith(AUTH_PREFIX);
|
|
134
|
+
echo = new URL(candidate, BASE);
|
|
89
135
|
}
|
|
90
136
|
catch {
|
|
91
|
-
return
|
|
137
|
+
return fallback;
|
|
92
138
|
}
|
|
139
|
+
if (echo.origin !== BASE)
|
|
140
|
+
return fallback;
|
|
141
|
+
if (echo.pathname.startsWith(AUTH_PREFIX))
|
|
142
|
+
return fallback;
|
|
143
|
+
if (echo.pathname + echo.search + echo.hash !== candidate)
|
|
144
|
+
return fallback;
|
|
145
|
+
return candidate;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Sentinel that `safeCallbackPath` can never legitimately return: it is not a
|
|
149
|
+
* fixed point of the URL parser (the parser would percent-encode the space), so
|
|
150
|
+
* it cannot collide with a genuine accepted value.
|
|
151
|
+
*/
|
|
152
|
+
const REJECTED = '\u0000rejected';
|
|
153
|
+
/**
|
|
154
|
+
* True when `raw` is a callback target we are willing to honour.
|
|
155
|
+
*
|
|
156
|
+
* DEFINED IN TERMS OF `safeCallbackPath` ON PURPOSE - it used to carry its own
|
|
157
|
+
* copy of the predicate, and that copy was missing the second resolution above
|
|
158
|
+
* while the two were maintained separately. This file's whole history is
|
|
159
|
+
* duplicated predicates drifting apart (see the header). One definition, one
|
|
160
|
+
* place to fix.
|
|
161
|
+
*/
|
|
162
|
+
function isSafeCallbackPath(raw) {
|
|
163
|
+
return typeof raw === 'string' && raw.length > 0 && safeCallbackPath(raw, REJECTED) !== REJECTED;
|
|
93
164
|
}
|
|
94
165
|
/**
|
|
95
166
|
* Pick the post-login destination: an explicit `?callbackUrl=` when it is one we
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@payez/next-mvp",
|
|
3
|
-
"version": "4.4.
|
|
3
|
+
"version": "4.4.4",
|
|
4
4
|
"sideEffects": false,
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"dev:local": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/dev-local.ps1",
|
|
25
25
|
"dev:broker": "pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/dev-broker.ps1",
|
|
26
26
|
"check:publish-health": "node scripts/check-publish-health.mjs",
|
|
27
|
+
"census:reach": "node scripts/module-reach.mjs",
|
|
27
28
|
"build:incremental": "npx tsc --project tsconfig.json"
|
|
28
29
|
},
|
|
29
30
|
"exports": {
|
|
@@ -966,4 +967,4 @@
|
|
|
966
967
|
"typescript": "^5.4.5",
|
|
967
968
|
"zustand": "^4.5.5"
|
|
968
969
|
}
|
|
969
|
-
}
|
|
970
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* module-reach.mjs - module-graph reachability census over the BUILT dist/.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS
|
|
6
|
+
* ---------------
|
|
7
|
+
* Cards 372358 / 373665 rest on numbers like "40 consumer-mountable entry points can
|
|
8
|
+
* reach server/auth". Those numbers were originally produced by a script that was run
|
|
9
|
+
* inline and never committed, so nobody could reproduce them. A number nobody can
|
|
10
|
+
* re-derive is a rumour. This file is that script, made durable and self-testing.
|
|
11
|
+
*
|
|
12
|
+
* WHAT IT MEASURES
|
|
13
|
+
* ----------------
|
|
14
|
+
* Over dist/**\/*.js (the artifact a consumer actually installs, NOT src/):
|
|
15
|
+
* - the module graph (nodes + edges)
|
|
16
|
+
* - which modules transitively REACH a target module (reverse reachability)
|
|
17
|
+
* - which of those are MOUNTABLE - i.e. named in package.json "exports", so a
|
|
18
|
+
* consumer can import them directly and thereby pull the target in
|
|
19
|
+
*
|
|
20
|
+
* HOW EDGES ARE FOUND - and why this shape was chosen
|
|
21
|
+
* ---------------------------------------------------
|
|
22
|
+
* We do NOT pattern-match `require(...)`. Three real defects came from doing that:
|
|
23
|
+
* 1. QUOTE STYLE - a pattern written with " misses ' (tsc emits either).
|
|
24
|
+
* 2. SIBLING PATHS - a pattern anchored on `./lib/` misses a plain `./auth`.
|
|
25
|
+
* 3. DYNAMIC IMPORT - `await import('x')`, and tsc's downlevel
|
|
26
|
+
* `Promise.resolve().then(() => __importStar(require('x')))`, are not `require(` calls
|
|
27
|
+
* in the shape a naive pattern expects.
|
|
28
|
+
* Instead: extract EVERY string literal (', ", and substitution-free `), and keep the ones
|
|
29
|
+
* that RESOLVE to a real file on disk. Keyword-agnostic and quote-agnostic by construction,
|
|
30
|
+
* so all three modes above are caught without enumerating them.
|
|
31
|
+
*
|
|
32
|
+
* THE HONESTY SIGNAL
|
|
33
|
+
* ------------------
|
|
34
|
+
* A relative specifier that does NOT resolve is counted as UNRESOLVED. Unresolved > 0 means
|
|
35
|
+
* the graph is incomplete and every count derived from it is a lower bound. The tool says so
|
|
36
|
+
* loudly rather than quietly under-reporting. Counts are reported over CALL SITES where
|
|
37
|
+
* relevant, never deduplicated to "files that mention it" - that conflation is its own defect.
|
|
38
|
+
*
|
|
39
|
+
* BOUND: this is a STATIC graph over emitted JS. It proves a module is REACHABLE, not that it
|
|
40
|
+
* is EXECUTED at runtime, and not that reaching it has any security consequence. Reachability
|
|
41
|
+
* is necessary for consequence, never sufficient. Do not quote this tool as proof of impact.
|
|
42
|
+
*
|
|
43
|
+
* USAGE
|
|
44
|
+
* node scripts/module-reach.mjs --to server/auth
|
|
45
|
+
* node scripts/module-reach.mjs --to server/auth --json
|
|
46
|
+
* node scripts/module-reach.mjs --graph-only
|
|
47
|
+
* node scripts/module-reach.mjs --to server/auth --pkg <dir> (default: package root)
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import fs from 'node:fs';
|
|
51
|
+
import path from 'node:path';
|
|
52
|
+
|
|
53
|
+
const CANDIDATE_EXTS = ['', '.js', '.mjs', '.cjs', '.json'];
|
|
54
|
+
const INDEX_FILES = ['index.js', 'index.mjs', 'index.cjs'];
|
|
55
|
+
|
|
56
|
+
/** Extract every string literal. Handles ' " and substitution-free `. Escape-aware. */
|
|
57
|
+
export function extractStringLiterals(src) {
|
|
58
|
+
const out = [];
|
|
59
|
+
const n = src.length;
|
|
60
|
+
let i = 0;
|
|
61
|
+
while (i < n) {
|
|
62
|
+
const ch = src[i];
|
|
63
|
+
// skip line comments
|
|
64
|
+
if (ch === '/' && src[i + 1] === '/') {
|
|
65
|
+
while (i < n && src[i] !== '\n') i++;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
// skip block comments
|
|
69
|
+
if (ch === '/' && src[i + 1] === '*') {
|
|
70
|
+
i += 2;
|
|
71
|
+
while (i < n && !(src[i] === '*' && src[i + 1] === '/')) i++;
|
|
72
|
+
i += 2;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (ch === '"' || ch === "'" || ch === '`') {
|
|
76
|
+
const quote = ch;
|
|
77
|
+
let j = i + 1;
|
|
78
|
+
let buf = '';
|
|
79
|
+
let sawSubstitution = false;
|
|
80
|
+
while (j < n) {
|
|
81
|
+
const c = src[j];
|
|
82
|
+
if (c === '\\') { buf += src[j + 1] ?? ''; j += 2; continue; }
|
|
83
|
+
if (c === quote) break;
|
|
84
|
+
if (quote === '`' && c === '$' && src[j + 1] === '{') sawSubstitution = true;
|
|
85
|
+
if (quote !== '`' && (c === '\n' || c === '\r')) break; // unterminated
|
|
86
|
+
buf += c;
|
|
87
|
+
j++;
|
|
88
|
+
}
|
|
89
|
+
// A template literal WITH ${...} is not a static specifier - we cannot resolve it,
|
|
90
|
+
// and pretending otherwise would invent an edge. Dropped, and counted by the caller.
|
|
91
|
+
if (!sawSubstitution) out.push(buf);
|
|
92
|
+
i = j + 1;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
i++;
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Resolve a relative specifier from `fromFile` to a real file, or null. */
|
|
101
|
+
export function resolveSpecifier(fromFile, spec, exists) {
|
|
102
|
+
const base = path.resolve(path.dirname(fromFile), spec);
|
|
103
|
+
for (const ext of CANDIDATE_EXTS) {
|
|
104
|
+
const cand = base + ext;
|
|
105
|
+
if (exists(cand)) return cand;
|
|
106
|
+
}
|
|
107
|
+
for (const idx of INDEX_FILES) {
|
|
108
|
+
const cand = path.join(base, idx);
|
|
109
|
+
if (exists(cand)) return cand;
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const isRelative = (s) => s === '.' || s === '..' || s.startsWith('./') || s.startsWith('../');
|
|
115
|
+
|
|
116
|
+
function walkJs(dir, acc = []) {
|
|
117
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
118
|
+
const p = path.join(dir, e.name);
|
|
119
|
+
if (e.isDirectory()) walkJs(p, acc);
|
|
120
|
+
// `__`-prefixed files in dist are TEST MUTANTS, never tsc output (measured:
|
|
121
|
+
// 0 of 245 in a clean dist). Several suites write one next to the real
|
|
122
|
+
// artifact — so its relative requires resolve — and delete it straight
|
|
123
|
+
// after, while `node --test` runs other files CONCURRENTLY. Counting one
|
|
124
|
+
// would make this census non-reproducible (the property the LIVE test
|
|
125
|
+
// asserts) and reading one after its delete lands is an ENOENT crash.
|
|
126
|
+
else if (e.isFile() && !e.name.startsWith('__') && /\.(js|mjs|cjs)$/.test(e.name)) acc.push(p);
|
|
127
|
+
}
|
|
128
|
+
return acc;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function buildGraph(distDir, opts = {}) {
|
|
132
|
+
const readFile = opts.readFile || ((f) => fs.readFileSync(f, 'utf8'));
|
|
133
|
+
const exists = opts.exists || ((f) => { try { return fs.statSync(f).isFile(); } catch { return false; } });
|
|
134
|
+
const files = (opts.files || walkJs(distDir)).map((f) => path.resolve(f));
|
|
135
|
+
|
|
136
|
+
const rel = (f) => path.relative(distDir, f).split(path.sep).join('/');
|
|
137
|
+
const forward = new Map(); // module -> Set(module)
|
|
138
|
+
const reverse = new Map(); // module -> Set(module)
|
|
139
|
+
const unresolved = []; // {from, spec} - CALL SITES, not deduped
|
|
140
|
+
let edgeSites = 0;
|
|
141
|
+
let externalSites = 0;
|
|
142
|
+
let dotValueSites = 0;
|
|
143
|
+
|
|
144
|
+
for (const f of files) { forward.set(rel(f), new Set()); reverse.set(rel(f), new Set()); }
|
|
145
|
+
|
|
146
|
+
for (const f of files) {
|
|
147
|
+
const from = rel(f);
|
|
148
|
+
for (const spec of extractStringLiterals(readFile(f))) {
|
|
149
|
+
if (!isRelative(spec)) {
|
|
150
|
+
// bare specifier: 'next/server', 'jose'. External to this package, not a defect.
|
|
151
|
+
if (/^[@a-zA-Z]/.test(spec) && !spec.includes(' ') && spec.length < 100) externalSites++;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const target = resolveSpecifier(f, spec, exists);
|
|
155
|
+
if (!target) {
|
|
156
|
+
// A LONE "." or ".." that does not resolve is the CHARACTER, not a specifier:
|
|
157
|
+
// verified in this dist - every occurrence is `token.split('.')`. `require('.')`
|
|
158
|
+
// is still legal, so we only discount it AFTER resolution has actually failed.
|
|
159
|
+
if (spec === '.' || spec === '..') { dotValueSites++; continue; }
|
|
160
|
+
unresolved.push({ from, spec });
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const to = rel(target);
|
|
164
|
+
if (!forward.has(to)) continue; // resolved outside the scanned set
|
|
165
|
+
edgeSites++;
|
|
166
|
+
forward.get(from).add(to);
|
|
167
|
+
reverse.get(to).add(from);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let edgeCount = 0;
|
|
172
|
+
for (const s of forward.values()) edgeCount += s.size;
|
|
173
|
+
|
|
174
|
+
return { distDir, modules: [...forward.keys()].sort(), forward, reverse, unresolved, edgeCount, edgeSites, externalSites, dotValueSites };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Reverse-transitive closure: every module that can reach `target`. */
|
|
178
|
+
export function reachers(graph, target) {
|
|
179
|
+
if (!graph.reverse.has(target)) return null;
|
|
180
|
+
const depth = new Map([[target, 0]]);
|
|
181
|
+
const queue = [target];
|
|
182
|
+
while (queue.length) {
|
|
183
|
+
const cur = queue.shift();
|
|
184
|
+
for (const prev of graph.reverse.get(cur) || []) {
|
|
185
|
+
if (!depth.has(prev)) { depth.set(prev, depth.get(cur) + 1); queue.push(prev); }
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
depth.delete(target);
|
|
189
|
+
return depth;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Every dist/*.js named as a target in package.json "exports". */
|
|
193
|
+
export function exportTargets(pkgJson) {
|
|
194
|
+
const out = new Map(); // distRelPath -> Set(subpath)
|
|
195
|
+
const visit = (subpath, node) => {
|
|
196
|
+
if (typeof node === 'string') {
|
|
197
|
+
if (!node.endsWith('.js')) return;
|
|
198
|
+
// normalise './dist/lib/x.js' and './lib/x.js' (both appear) to 'lib/x.js'
|
|
199
|
+
let p = node.replace(/^\.\//, '');
|
|
200
|
+
p = p.replace(/^dist\//, '');
|
|
201
|
+
if (!out.has(p)) out.set(p, new Set());
|
|
202
|
+
out.get(p).add(subpath);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (node && typeof node === 'object') for (const v of Object.values(node)) visit(subpath, v);
|
|
206
|
+
};
|
|
207
|
+
for (const [subpath, node] of Object.entries(pkgJson.exports || {})) visit(subpath, node);
|
|
208
|
+
if (typeof pkgJson.main === 'string') visit('. (main)', './' + pkgJson.main);
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function main(argv) {
|
|
213
|
+
const arg = (k) => { const i = argv.indexOf(k); return i >= 0 ? argv[i + 1] : undefined; };
|
|
214
|
+
const pkgDir = path.resolve(arg('--pkg') || path.join(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')), '..'));
|
|
215
|
+
const distDir = path.join(pkgDir, 'dist');
|
|
216
|
+
const json = argv.includes('--json');
|
|
217
|
+
const to = arg('--to');
|
|
218
|
+
|
|
219
|
+
if (!fs.existsSync(distDir)) {
|
|
220
|
+
console.error(`[module-reach] FATAL: no dist at ${distDir}. Run \`npm run build\` first.`);
|
|
221
|
+
console.error('[module-reach] Refusing to report a census over a tree that does not exist.');
|
|
222
|
+
process.exit(2);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const pkgJson = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8'));
|
|
226
|
+
const graph = buildGraph(distDir);
|
|
227
|
+
const exports_ = exportTargets(pkgJson);
|
|
228
|
+
|
|
229
|
+
const report = {
|
|
230
|
+
package: pkgJson.name, version: pkgJson.version, distDir,
|
|
231
|
+
modules: graph.modules.length, edges: graph.edgeCount, edgeSites: graph.edgeSites,
|
|
232
|
+
externalSites: graph.externalSites,
|
|
233
|
+
unresolvedSites: graph.unresolved.length,
|
|
234
|
+
exportSubpaths: Object.keys(pkgJson.exports || {}).length,
|
|
235
|
+
exportTargetFiles: exports_.size,
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
if (to) {
|
|
239
|
+
const candidates = [to, `${to}.js`, `${to}/index.js`].map((t) => t.replace(/^\.\//, ''));
|
|
240
|
+
const target = candidates.find((c) => graph.reverse.has(c));
|
|
241
|
+
if (!target) {
|
|
242
|
+
console.error(`[module-reach] FATAL: target not in dist graph: ${to}`);
|
|
243
|
+
console.error(`[module-reach] tried: ${candidates.join(', ')}`);
|
|
244
|
+
process.exit(2);
|
|
245
|
+
}
|
|
246
|
+
const depth = reachers(graph, target);
|
|
247
|
+
const all = [...depth.keys()].sort();
|
|
248
|
+
const direct = all.filter((m) => depth.get(m) === 1);
|
|
249
|
+
const mountable = all.filter((m) => exports_.has(m));
|
|
250
|
+
report.target = target;
|
|
251
|
+
report.directRequirers = direct.length;
|
|
252
|
+
report.reaching = all.length;
|
|
253
|
+
report.mountable = mountable.length;
|
|
254
|
+
report.targetIsItselfMountable = exports_.has(target);
|
|
255
|
+
report.directRequirerList = direct;
|
|
256
|
+
report.mountableList = mountable.map((m) => ({ module: m, subpaths: [...exports_.get(m)] }));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (json) { console.log(JSON.stringify(report, null, 2)); return; }
|
|
260
|
+
|
|
261
|
+
console.log(`[module-reach] ${report.package}@${report.version}`);
|
|
262
|
+
console.log(` dist modules ......... ${report.modules}`);
|
|
263
|
+
console.log(` edges (unique) ....... ${report.edges}`);
|
|
264
|
+
console.log(` edge call sites ...... ${report.edgeSites}`);
|
|
265
|
+
console.log(` external specifiers .. ${report.externalSites} (bare, outside this package)`);
|
|
266
|
+
console.log(` export subpaths ...... ${report.exportSubpaths} -> ${report.exportTargetFiles} distinct dist files`);
|
|
267
|
+
if (report.unresolvedSites === 0) {
|
|
268
|
+
console.log(` unresolved ........... 0 <- graph is complete; counts below are exact`);
|
|
269
|
+
} else {
|
|
270
|
+
console.log(` unresolved ........... ${report.unresolvedSites} *** GRAPH INCOMPLETE - ALL COUNTS BELOW ARE LOWER BOUNDS ***`);
|
|
271
|
+
for (const u of graph.unresolved.slice(0, 20)) console.log(` ${u.from} -> ${u.spec}`);
|
|
272
|
+
}
|
|
273
|
+
if (report.target) {
|
|
274
|
+
console.log('');
|
|
275
|
+
console.log(` TARGET: ${report.target}${report.targetIsItselfMountable ? ' (itself a public export)' : ''}`);
|
|
276
|
+
console.log(` direct requirers ..... ${report.directRequirers}`);
|
|
277
|
+
console.log(` transitively reaching ${report.reaching}`);
|
|
278
|
+
console.log(` ...of which MOUNTABLE ${report.mountable} <- consumer-reachable entry points`);
|
|
279
|
+
console.log('');
|
|
280
|
+
console.log(' BOUND: static reachability over emitted JS. Proves reachable, NOT executed,');
|
|
281
|
+
console.log(' and NOT that reaching it has any consequence. Do not quote as proof of impact.');
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const invokedDirectly = process.argv[1] && import.meta.url.endsWith(path.basename(process.argv[1]));
|
|
286
|
+
if (invokedDirectly) main(process.argv.slice(2));
|
package/src/auth/better-auth.ts
CHANGED
|
@@ -125,12 +125,27 @@ export interface BetterAuthSocialProvider {
|
|
|
125
125
|
* globalThis is per-process and per-runtime, which is exactly the lifetime a request-scoped
|
|
126
126
|
* carrier needs to be reachable from every copy that might run inside the request.
|
|
127
127
|
*/
|
|
128
|
+
/**
|
|
129
|
+
* THE KEY STAYS '__payezIdTokenScope' EVEN THOUGH THE SCOPE NOW CARRIES TWO
|
|
130
|
+
* THINGS. Renaming it would give a copy on the old name and a copy on the new
|
|
131
|
+
* name two DIFFERENT stores in the same process — which is precisely the
|
|
132
|
+
* double-load failure documented above, reintroduced by a cosmetic rename. The
|
|
133
|
+
* name is a wire contract between copies, not a description.
|
|
134
|
+
*
|
|
135
|
+
* accessToken is here for providers that do NOT issue an id_token. GitHub is
|
|
136
|
+
* not an OIDC provider: it has no id_token, no JWKS and no discovery document,
|
|
137
|
+
* so `id_token` can never be the field that proves a GitHub identity. The IDP
|
|
138
|
+
* re-derives that identity by calling GitHub itself with this access token
|
|
139
|
+
* (GitHubIdentityVerifier), which keeps the same rule google follows — the IDP
|
|
140
|
+
* verifies for itself and never trusts an asserted email.
|
|
141
|
+
*/
|
|
128
142
|
const ID_TOKEN_SCOPE_KEY = '__payezIdTokenScope';
|
|
143
|
+
type ProviderTokenScope = { idToken?: string; accessToken?: string };
|
|
129
144
|
const gScope = globalThis as unknown as Record<
|
|
130
145
|
string,
|
|
131
|
-
AsyncLocalStorage<
|
|
146
|
+
AsyncLocalStorage<ProviderTokenScope> | undefined
|
|
132
147
|
>;
|
|
133
|
-
gScope[ID_TOKEN_SCOPE_KEY] ??= new AsyncLocalStorage<
|
|
148
|
+
gScope[ID_TOKEN_SCOPE_KEY] ??= new AsyncLocalStorage<ProviderTokenScope>();
|
|
134
149
|
const idTokenScope = gScope[ID_TOKEN_SCOPE_KEY]!;
|
|
135
150
|
|
|
136
151
|
/**
|
|
@@ -242,10 +257,22 @@ export function buildBetterAuthProviders(
|
|
|
242
257
|
* remains, so the provider emits exactly its default set.
|
|
243
258
|
*/
|
|
244
259
|
const GOOGLE_DEFAULT_SCOPES = ['email', 'profile', 'openid'];
|
|
260
|
+
/**
|
|
261
|
+
* Same defect, same fix, different provider: github's createAuthorizationURL
|
|
262
|
+
* also seeds ['read:user','user:email'] unconditionally and then pushes ours
|
|
263
|
+
* on top (@better-auth/core social-providers/github.ts), so the stored row
|
|
264
|
+
* "read:user user:email" reached GitHub as
|
|
265
|
+
* "read:user user:email read:user user:email". Measured on :4799, not
|
|
266
|
+
* assumed. GitHub dedupes, so this is legibility rather than function — but
|
|
267
|
+
* a scope list is the first thing a security reviewer reads off that URL.
|
|
268
|
+
*/
|
|
269
|
+
const GITHUB_DEFAULT_SCOPES = ['read:user', 'user:email'];
|
|
245
270
|
const wireScopes =
|
|
246
271
|
name === 'google'
|
|
247
272
|
? scopes.filter((s) => !GOOGLE_DEFAULT_SCOPES.includes(s))
|
|
248
|
-
:
|
|
273
|
+
: name === 'github'
|
|
274
|
+
? scopes.filter((s) => !GITHUB_DEFAULT_SCOPES.includes(s))
|
|
275
|
+
: scopes;
|
|
249
276
|
|
|
250
277
|
providers[name] = {
|
|
251
278
|
clientId: oauth.clientId,
|
|
@@ -350,6 +377,98 @@ export function buildBetterAuthProviders(
|
|
|
350
377
|
};
|
|
351
378
|
};
|
|
352
379
|
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* GITHUB: THE SAME CAPTURE, A DIFFERENT TOKEN.
|
|
383
|
+
*
|
|
384
|
+
* GitHub is not an OIDC provider — it issues no id_token — so the google
|
|
385
|
+
* branch above can never fire for it and the exchange would post an
|
|
386
|
+
* assertion with nothing that proves identity. That is exactly the
|
|
387
|
+
* 401 UNVERIFIED_ASSERTION measured on :4799, whose diagnostics read
|
|
388
|
+
* `id_token_forwarded: false, id_token_length: 0` with `response_time_ms: 0`:
|
|
389
|
+
* the IDP rejected it instantly, without ever calling GitHub.
|
|
390
|
+
*
|
|
391
|
+
* getUserInfo is the ONLY provider hook that receives the token set (see the
|
|
392
|
+
* google note above), and @better-auth/core's github provider calls
|
|
393
|
+
* `options.getUserInfo(token)` BEFORE its own body — so installing this
|
|
394
|
+
* REPLACES the default rather than wrapping it, and the default's work has
|
|
395
|
+
* to be reproduced here or the sign-in loses its profile entirely.
|
|
396
|
+
*
|
|
397
|
+
* Mirrored verbatim from @better-auth/core/src/social-providers/github.ts at
|
|
398
|
+
* the version the container actually runs (better-auth 1.5.6 — the graft's
|
|
399
|
+
* node_modules says 1.6.29 and is NOT what executes). Kept deliberately
|
|
400
|
+
* dumb: it must not "improve" on upstream, because every divergence here is
|
|
401
|
+
* a behaviour change disguised as a token capture.
|
|
402
|
+
*
|
|
403
|
+
* This is NOT the identity check. The IDP re-derives the GitHub identity
|
|
404
|
+
* itself from this access token and enforces primary && verified server-side
|
|
405
|
+
* (GitHubIdentityVerifier); what we return here only shapes the local
|
|
406
|
+
* better-auth user record.
|
|
407
|
+
*/
|
|
408
|
+
if (name === 'github') {
|
|
409
|
+
(providers[name] as unknown as Record<string, unknown>).getUserInfo = async (token: {
|
|
410
|
+
accessToken?: string;
|
|
411
|
+
}) => {
|
|
412
|
+
const store = idTokenScope.getStore();
|
|
413
|
+
if (store) {
|
|
414
|
+
store.accessToken = token?.accessToken;
|
|
415
|
+
} else {
|
|
416
|
+
console.warn(
|
|
417
|
+
'[BETTER_AUTH] github access_token captured but NO REQUEST SCOPE to put it in — ' +
|
|
418
|
+
'it will NOT be forwarded and the IDP exchange will fail UNVERIFIED_ASSERTION. ' +
|
|
419
|
+
'Check that GET is createAuthGetHandler(), not routes/auth/nextauth.',
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const ghHeaders = {
|
|
424
|
+
'User-Agent': 'better-auth',
|
|
425
|
+
Authorization: `Bearer ${token?.accessToken}`,
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
const profileRes = await fetch('https://api.github.com/user', {
|
|
429
|
+
headers: ghHeaders,
|
|
430
|
+
});
|
|
431
|
+
// Upstream returns null on profile error, which fails the sign-in
|
|
432
|
+
// closed. Preserved rather than softened.
|
|
433
|
+
if (!profileRes.ok) {
|
|
434
|
+
console.warn(
|
|
435
|
+
`[BETTER_AUTH] github getUserInfo: /user returned ${profileRes.status}`,
|
|
436
|
+
);
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
const profile = (await profileRes.json()) as Record<string, any>;
|
|
440
|
+
|
|
441
|
+
const emailsRes = await fetch('https://api.github.com/user/emails', {
|
|
442
|
+
headers: ghHeaders,
|
|
443
|
+
});
|
|
444
|
+
const emails = emailsRes.ok
|
|
445
|
+
? ((await emailsRes.json()) as Array<{
|
|
446
|
+
email: string;
|
|
447
|
+
primary: boolean;
|
|
448
|
+
verified: boolean;
|
|
449
|
+
}>)
|
|
450
|
+
: undefined;
|
|
451
|
+
|
|
452
|
+
// A GitHub account with a private email returns profile.email = null;
|
|
453
|
+
// upstream falls back to the primary address here.
|
|
454
|
+
if (!profile.email && emails) {
|
|
455
|
+
profile.email = (emails.find((e) => e.primary) ?? emails[0])?.email;
|
|
456
|
+
}
|
|
457
|
+
const emailVerified =
|
|
458
|
+
emails?.find((e) => e.email === profile.email)?.verified ?? false;
|
|
459
|
+
|
|
460
|
+
return {
|
|
461
|
+
user: {
|
|
462
|
+
id: profile.id,
|
|
463
|
+
name: profile.name || profile.login || '',
|
|
464
|
+
email: profile.email,
|
|
465
|
+
image: profile.avatar_url,
|
|
466
|
+
emailVerified,
|
|
467
|
+
},
|
|
468
|
+
data: profile,
|
|
469
|
+
};
|
|
470
|
+
};
|
|
471
|
+
}
|
|
353
472
|
}
|
|
354
473
|
|
|
355
474
|
return providers;
|
|
@@ -900,6 +1019,21 @@ export async function exchangeOAuthForIdpTokens(
|
|
|
900
1019
|
* distinguishable from "the provider issued none".
|
|
901
1020
|
*/
|
|
902
1021
|
...(idTokenScope.getStore()?.idToken ? { id_token: idTokenScope.getStore()!.idToken } : {}),
|
|
1022
|
+
/**
|
|
1023
|
+
* The same field for providers that have no id_token to give.
|
|
1024
|
+
*
|
|
1025
|
+
* Identical doctrine to the id_token above: RAW, forwarded verbatim,
|
|
1026
|
+
* NOTHING DERIVED FROM IT, and omitted entirely rather than sent null so
|
|
1027
|
+
* "never captured" stays distinguishable from "provider issued none".
|
|
1028
|
+
* The IDP verifies for itself by calling GitHub with this token and
|
|
1029
|
+
* enforcing primary && verified; we assert nothing about the identity.
|
|
1030
|
+
*
|
|
1031
|
+
* Scoped to the provider access token captured during THIS callback, so
|
|
1032
|
+
* a google login continues to send id_token only and is unaffected.
|
|
1033
|
+
*/
|
|
1034
|
+
...(idTokenScope.getStore()?.accessToken
|
|
1035
|
+
? { access_token: idTokenScope.getStore()!.accessToken }
|
|
1036
|
+
: {}),
|
|
903
1037
|
}),
|
|
904
1038
|
});
|
|
905
1039
|
|
|
@@ -933,9 +1067,21 @@ export async function exchangeOAuthForIdpTokens(
|
|
|
933
1067
|
claims = { decode: 'FAILED — not a well-formed JWT' };
|
|
934
1068
|
}
|
|
935
1069
|
}
|
|
1070
|
+
const forwardedAccess = idTokenScope.getStore()?.accessToken;
|
|
936
1071
|
console.error('[BETTER_AUTH] exchange diagnostics:', {
|
|
937
1072
|
id_token_forwarded: !!forwarded,
|
|
938
1073
|
id_token_length: forwarded ? forwarded.length : 0,
|
|
1074
|
+
/**
|
|
1075
|
+
* Read these two for github, and IGNORE the id_token pair above: github
|
|
1076
|
+
* issues no id_token, so `id_token_forwarded: false` is its NORMAL state
|
|
1077
|
+
* and not the fault. Presence and length only — an access token is
|
|
1078
|
+
* credential material and this log is persistent. A github failure with
|
|
1079
|
+
* access_token_forwarded:true means the IDP called GitHub and refused
|
|
1080
|
+
* the answer (unverified or non-primary email, or app not installed);
|
|
1081
|
+
* false means the capture hook never ran and the IDP never called out.
|
|
1082
|
+
*/
|
|
1083
|
+
access_token_forwarded: !!forwardedAccess,
|
|
1084
|
+
access_token_length: forwardedAccess ? forwardedAccess.length : 0,
|
|
939
1085
|
// Absent id_token => the IDP had nothing to verify. Present => compare these
|
|
940
1086
|
// against the client's registered Google credentials on the IDP.
|
|
941
1087
|
aud: claims.aud ?? null,
|
|
@@ -21,6 +21,27 @@
|
|
|
21
21
|
* v1 `startsWith('/') && !startsWith('//')` -> bypassed by /BACKSLASH host
|
|
22
22
|
* v2 + `!startsWith('/' + BACKSLASH)` -> bypassed by /TAB/host
|
|
23
23
|
* ... and /LF/host, /CR/host
|
|
24
|
+
* v3 resolve once + compare ORIGIN -> bypassed by /..//host
|
|
25
|
+
* and /.//host, /%2e%2e//host
|
|
26
|
+
* v4 + require the RESULT to be a FIXED POINT <- current
|
|
27
|
+
*
|
|
28
|
+
* *** DRIFT, 2026-09-11 (325543). THIS FILE WAS v3 WHILE THE PORTAL'S COPY WAS
|
|
29
|
+
* *** v4, AND THE PACKAGE COPY IS THE ONE THAT SHIPPED. The portal fixed its own
|
|
30
|
+
* *** src/lib/safe-callback.ts (e1f08fea) and deployed; the served bundle STILL
|
|
31
|
+
* *** escaped 5/5 in a real browser, because `src/contexts/ThemeContext.tsx`
|
|
32
|
+
* *** imports the BARE BARREL `@payez/next-mvp`, which pulls dist/index.js ->
|
|
33
|
+
* *** auth/auth-decision -> THIS FILE into the client graph, and because the
|
|
34
|
+
* *** portal's `src/middleware.ts` imports `@payez/next-mvp/edge`, which requires
|
|
35
|
+
* *** THIS FILE server-side on every request. It was read as a stale build
|
|
36
|
+
* *** artifact of the portal's file for most of a day. It was not. It was a
|
|
37
|
+
* *** SECOND DEFINITION, and no rebuild of the portal could have removed it.
|
|
38
|
+
* *** The header below says "ONE definition ... for the whole package" and that
|
|
39
|
+
* *** was true INSIDE this package boundary the entire time. The defect lived in
|
|
40
|
+
* *** the gap BETWEEN two boundaries that each enforced one-definition correctly.
|
|
41
|
+
* *** A guard that exists in N copies is only as strong as its weakest copy, and
|
|
42
|
+
* *** a per-repo grep gate cannot see N. IF YOU CHANGE THE PREDICATE HERE, CHANGE
|
|
43
|
+
* *** IT IN THE PORTAL'S src/lib/safe-callback.ts IN THE SAME HOUR, OR DELETE ONE
|
|
44
|
+
* *** OF THEM.
|
|
24
45
|
*
|
|
25
46
|
* Both bypasses work the same way: the browser NORMALISES the string AFTER the
|
|
26
47
|
* guard has finished looking at it. The WHATWG URL parser converts backslashes
|
|
@@ -75,18 +96,69 @@ export function safeCallbackPath(raw: unknown, fallback: string = DEFAULT_CALLBA
|
|
|
75
96
|
if (url.pathname.startsWith(AUTH_PREFIX)) return fallback;
|
|
76
97
|
|
|
77
98
|
// The parser's normalised output - control characters already stripped.
|
|
78
|
-
|
|
79
|
-
}
|
|
99
|
+
const candidate = url.pathname + url.search + url.hash;
|
|
80
100
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
101
|
+
// *** SECOND RESOLUTION. DO NOT DELETE THIS AS REDUNDANT - IT IS NOT. ***
|
|
102
|
+
//
|
|
103
|
+
// v3 (the origin comparison above) was bypassed too, and differently from v1/v2.
|
|
104
|
+
// The origin check constrains where `raw` RESOLVED TO. It does not constrain how
|
|
105
|
+
// the RESULT IS SPELLED - and the result is resolved a SECOND time, by whoever
|
|
106
|
+
// navigates to it. A returned path that begins with `//` is a PROTOCOL-RELATIVE
|
|
107
|
+
// URL at that second resolution, so it lands off-origin having passed every
|
|
108
|
+
// check here:
|
|
109
|
+
//
|
|
110
|
+
// '/..//evil.example.com' -> pathname '//evil.example.com' -> evil.example.com
|
|
111
|
+
// '/.//evil.example.com' -> pathname '//evil.example.com' -> evil.example.com
|
|
112
|
+
// '/%2e%2e//evil.example.com' -> pathname '//evil.example.com' -> evil.example.com
|
|
113
|
+
//
|
|
114
|
+
// The parser resolves `..` against the synthetic base and, at the root, leaves
|
|
115
|
+
// behind the EMPTY SEGMENT rather than collapsing it. Origin stayed BASE the
|
|
116
|
+
// whole time, so nothing upstream fired.
|
|
117
|
+
//
|
|
118
|
+
// MEASURED, not reasoned: in a real headless Chromium on the live origin
|
|
119
|
+
// https://portal.keelauth.com, 5 spellings resolved to https://evil.example.com
|
|
120
|
+
// (QAPert-NightHawk, 2026-09-11, card 325543), and on 2026-09-11 the SERVED
|
|
121
|
+
// BUNDLE was measured escaping 5/5 through THIS PACKAGE'S copy while the
|
|
122
|
+
// portal's own src/lib/safe-callback.ts was already fixed - the two copies had
|
|
123
|
+
// drifted. See the DRIFT note in this file's header.
|
|
124
|
+
//
|
|
125
|
+
// THE FIX FOLLOWS THIS FILE'S OWN DOCTRINE: do not pattern-match the spellings
|
|
126
|
+
// (that is the startsWith() trap that failed four times, and this would be the
|
|
127
|
+
// fifth). Ask the parser AGAIN about the exact value we are about to hand out,
|
|
128
|
+
// and require it to be a FIXED POINT. Anything whose meaning changes when it is
|
|
129
|
+
// resolved a second time is by definition not a stable same-origin path, however
|
|
130
|
+
// it is spelled - including spellings nobody has found yet.
|
|
131
|
+
let echo: URL;
|
|
84
132
|
try {
|
|
85
|
-
|
|
86
|
-
return url.origin === BASE && !url.pathname.startsWith(AUTH_PREFIX);
|
|
133
|
+
echo = new URL(candidate, BASE);
|
|
87
134
|
} catch {
|
|
88
|
-
return
|
|
135
|
+
return fallback;
|
|
89
136
|
}
|
|
137
|
+
if (echo.origin !== BASE) return fallback;
|
|
138
|
+
if (echo.pathname.startsWith(AUTH_PREFIX)) return fallback;
|
|
139
|
+
if (echo.pathname + echo.search + echo.hash !== candidate) return fallback;
|
|
140
|
+
|
|
141
|
+
return candidate;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Sentinel that `safeCallbackPath` can never legitimately return: it is not a
|
|
146
|
+
* fixed point of the URL parser (the parser would percent-encode the space), so
|
|
147
|
+
* it cannot collide with a genuine accepted value.
|
|
148
|
+
*/
|
|
149
|
+
const REJECTED = '\u0000rejected';
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* True when `raw` is a callback target we are willing to honour.
|
|
153
|
+
*
|
|
154
|
+
* DEFINED IN TERMS OF `safeCallbackPath` ON PURPOSE - it used to carry its own
|
|
155
|
+
* copy of the predicate, and that copy was missing the second resolution above
|
|
156
|
+
* while the two were maintained separately. This file's whole history is
|
|
157
|
+
* duplicated predicates drifting apart (see the header). One definition, one
|
|
158
|
+
* place to fix.
|
|
159
|
+
*/
|
|
160
|
+
export function isSafeCallbackPath(raw: unknown): raw is string {
|
|
161
|
+
return typeof raw === 'string' && raw.length > 0 && safeCallbackPath(raw, REJECTED) !== REJECTED;
|
|
90
162
|
}
|
|
91
163
|
|
|
92
164
|
/**
|