@payez/next-mvp 4.1.12 → 4.1.14

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.
@@ -22,9 +22,6 @@ export interface BetterAuthSocialProvider {
22
22
  accessType?: 'offline' | 'online';
23
23
  hd?: string;
24
24
  }
25
- /**
26
- * Build Better Auth social providers from IDP config.
27
- */
28
25
  export declare function buildBetterAuthProviders(config: IDPClientConfig): Record<string, BetterAuthSocialProvider>;
29
26
  /**
30
27
  * Optional configuration for `createBetterAuthInstance`.
@@ -53,6 +53,8 @@ exports.getBetterAuthHandler = getBetterAuthHandler;
53
53
  exports.exchangeOAuthForIdpTokens = exchangeOAuthForIdpTokens;
54
54
  exports.createAuthGetHandler = createAuthGetHandler;
55
55
  require("server-only");
56
+ const node_async_hooks_1 = require("node:async_hooks");
57
+ const jose_1 = require("jose"); // better-auth's own JWT dependency
56
58
  const better_auth_1 = require("better-auth");
57
59
  const next_js_1 = require("better-auth/next-js");
58
60
  const next_js_2 = require("better-auth/next-js");
@@ -63,6 +65,30 @@ const redis_1 = require("../lib/redis");
63
65
  /**
64
66
  * Build Better Auth social providers from IDP config.
65
67
  */
68
+ /**
69
+ * REQUEST-SCOPED carrier for the provider's raw id_token. Card 138635.
70
+ *
71
+ * WHY THIS EXISTS: /api/ExternalAuth/oauth-callback mints an IDP bearer for any
72
+ * ASSERTED email — we send {provider, email} and nothing verifiable. The fix is
73
+ * for the IDP to require a provider-signed id_token and verify it itself. This
74
+ * is the client half: capture the token Better Auth already receives, and
75
+ * forward it. Additive — the IDP ignores it until its gate flips.
76
+ *
77
+ * WHY AsyncLocalStorage AND NOT REDIS (BAPert's ruling: forward, do not persist):
78
+ * an id_token is credential material. Writing it to the session record would put
79
+ * it one allow-list edit away from leaking out of /api/internal/session-data,
80
+ * and it would outlive the request that needed it. ALS scopes it to the single
81
+ * request, and it is gone when the request ends — nothing to expire, nothing to
82
+ * scrub, nothing to leak.
83
+ *
84
+ * SAFE BY CONSTRUCTION: getUserInfo (which fills this) and the post-callback
85
+ * exchange (which reads it) both run inside createAuthGetHandler's own request.
86
+ * VERIFIED, not assumed: better-auth.ts:452 awaits ba.GET(request) — the call
87
+ * that invokes getUserInfo — and the exchange follows at :465 in the SAME
88
+ * function. Outside that scope the store is undefined and the body simply omits
89
+ * id_token, which is the correct additive behaviour.
90
+ */
91
+ const idTokenScope = new node_async_hooks_1.AsyncLocalStorage();
66
92
  function buildBetterAuthProviders(config) {
67
93
  const providers = {};
68
94
  for (const oauth of config.oauthProviders || []) {
@@ -92,6 +118,65 @@ function buildBetterAuthProviders(config) {
92
118
  accessType: rawAccessType === 'online' ? 'online' : rawAccessType === 'offline' ? 'offline' : undefined,
93
119
  hd: typeof rawHostedDomain === 'string' ? rawHostedDomain : undefined,
94
120
  };
121
+ /**
122
+ * CAPTURE THE RAW id_token — card 138635, client half.
123
+ *
124
+ * getUserInfo is the ONLY provider hook that receives the token set.
125
+ * mapProfileToUser cannot do this: it is handed the DECODED claims, and a
126
+ * signed JWT cannot be reconstructed from them. We need the raw token
127
+ * because the IDP must verify the signature ITSELF — a client-decoded
128
+ * claim is just another assertable field, which is the defect restated.
129
+ *
130
+ * ⚠ THIS REPLACES THE DEFAULT google getUserInfo ENTIRELY. Upstream calls
131
+ * `if (options.getUserInfo) return options.getUserInfo(token)` FIRST and
132
+ * returns our result verbatim, so everything the default did is now ours to
133
+ * do — including the `hd` hosted-domain restriction.
134
+ *
135
+ * `hd` IS NOT HYPOTHETICAL: it is wired from additionalParams.hd four lines
136
+ * above, so a client can restrict sign-in to a Google Workspace domain.
137
+ * Silently dropping it would let ANY Google account into a client that had
138
+ * restricted itself — a security regression introduced by a security fix.
139
+ * (Measured on 93: zero clients set it today. That is a reason to be
140
+ * careful, not a reason to skip it — it is configurable and unused, which
141
+ * is exactly the state in which a regression goes unnoticed.)
142
+ *
143
+ * SO WE DO NOT OVERRIDE AT ALL WHEN `hd` IS SET. We take the hook over
144
+ * ONLY where upstream's check is PROVABLY VACUOUS — its own first line is
145
+ * `if (!configuredHostedDomain) return true`. With no hd configured the
146
+ * default would return true unconditionally, so omitting it is equivalent
147
+ * by the library's own definition rather than by our judgement.
148
+ *
149
+ * A client that DOES set hd keeps the stock provider, keeps its domain
150
+ * restriction, and simply does not contribute an id_token yet. Losing a
151
+ * capability is an acceptable cost; silently losing a security control is
152
+ * not. (better-auth does not re-export the predicate through its public
153
+ * types, and reimplementing a security check is how two copies drift — so
154
+ * the honest move is to not need it.)
155
+ */
156
+ if (name === 'google' && !providers[name].hd) {
157
+ providers[name].getUserInfo = async (token) => {
158
+ if (!token?.idToken)
159
+ return null;
160
+ // The only new behaviour: hand the raw token to this request's scope.
161
+ const scope = idTokenScope.getStore();
162
+ if (scope)
163
+ scope.idToken = token.idToken;
164
+ // Mirrors the upstream default (google.mjs) verbatim, minus the hd
165
+ // branch that cannot fire here — so overriding the hook changes WHAT WE
166
+ // KEEP, never what we ACCEPT.
167
+ const claims = (0, jose_1.decodeJwt)(token.idToken);
168
+ return {
169
+ user: {
170
+ id: claims.sub,
171
+ name: claims.name,
172
+ email: claims.email,
173
+ image: claims.picture,
174
+ emailVerified: claims.email_verified,
175
+ },
176
+ data: claims,
177
+ };
178
+ };
179
+ }
95
180
  }
96
181
  return providers;
97
182
  }
@@ -297,6 +382,30 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
297
382
  name,
298
383
  image,
299
384
  client_id: config.clientSlug || String(config.clientId),
385
+ /**
386
+ * Card 138635 — the RAW provider id_token, forwarded verbatim.
387
+ *
388
+ * Every other field in this body is an ASSERTION. The endpoint mints an
389
+ * IDP bearer from them today with no cryptographic check of any kind,
390
+ * which is why it is a complete authentication bypass and why it has
391
+ * been publicly reachable for 488 days.
392
+ *
393
+ * This field is the only one the IDP can VERIFY: signature against
394
+ * Google's JWKS, plus iss / exp / aud, and then read email_verified
395
+ * from the verified claims. Additive — the IDP ignores it until its
396
+ * gate flips, so this cannot break a login on its own.
397
+ *
398
+ * RAW, AND NOTHING DERIVED FROM IT (BAPert's ruling). We do NOT send a
399
+ * decoded email_verified. A client-computed verification flag is a body
400
+ * field with a better story: an attacker posts email_verified:true and
401
+ * the defect is rebuilt one layer up. The IDP verifies for itself or
402
+ * the fix is theatre.
403
+ *
404
+ * Absent when this runs outside the OAuth callback scope — the key is
405
+ * simply omitted rather than sent null, so "we never captured one" stays
406
+ * distinguishable from "the provider issued none".
407
+ */
408
+ ...(idTokenScope.getStore()?.idToken ? { id_token: idTokenScope.getStore().idToken } : {}),
300
409
  }),
301
410
  });
302
411
  const oauthResText = await oauthRes.text();
@@ -380,45 +489,52 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
380
489
  */
381
490
  function createAuthGetHandler(loginPath = '/account-auth/login') {
382
491
  return async function GET(request) {
383
- const ba = await getBetterAuthHandler();
384
- if (!ba) {
385
- return new Response('Auth handler not configured', { status: 500 });
386
- }
387
- const response = await ba.GET(request);
388
- // Intercept auth errors (state mismatch, expired cookies) — redirect to login cleanly
389
- if (response.status === 302) {
390
- const location = response.headers.get('location') || '';
391
- if (location.includes('/api/auth/error') || location.includes('please_restart')) {
392
- console.warn('[BETTER_AUTH] OAuth state error, redirecting to login');
393
- return Response.redirect(new URL(loginPath, request.url), 302);
492
+ // Card 138635: establish the request-scoped carrier for the provider's raw
493
+ // id_token. It must wrap BOTH ba.GET (which invokes getUserInfo, the only
494
+ // hook that sees the token) and the exchange below (which forwards it).
495
+ // Wrapping the whole handler is what makes "never persisted" achievable —
496
+ // the token exists only for the life of this request.
497
+ return idTokenScope.run({}, async () => {
498
+ const ba = await getBetterAuthHandler();
499
+ if (!ba) {
500
+ return new Response('Auth handler not configured', { status: 500 });
394
501
  }
395
- }
396
- // After successful OAuth callback: exchange Google identity for IDP tokens
397
- const url = new URL(request.url);
398
- if (url.pathname.includes('/callback/') && response.status === 302) {
399
- try {
400
- const auth = await getBetterAuthInstance();
401
- if (auth?.api?.getSession) {
402
- const setCookies = response.headers.getSetCookie?.() || [];
403
- const cookieHeader = setCookies
404
- .map((c) => c.split(';')[0])
405
- .join('; ');
406
- const headers = new Headers();
407
- headers.set('cookie', cookieHeader);
408
- const session = await auth.api.getSession({ headers });
409
- if (session?.session?.token) {
410
- console.log('[BETTER_AUTH] Got session token from callback:', session.session.token.substring(0, 10), '| email:', session.user?.email);
411
- await exchangeOAuthForIdpTokens(session.session.token);
412
- }
413
- else {
414
- console.warn('[BETTER_AUTH] Could not get session after OAuth callback');
415
- }
502
+ const response = await ba.GET(request);
503
+ // Intercept auth errors (state mismatch, expired cookies) — redirect to login cleanly
504
+ if (response.status === 302) {
505
+ const location = response.headers.get('location') || '';
506
+ if (location.includes('/api/auth/error') || location.includes('please_restart')) {
507
+ console.warn('[BETTER_AUTH] OAuth state error, redirecting to login');
508
+ return Response.redirect(new URL(loginPath, request.url), 302);
416
509
  }
417
510
  }
418
- catch (err) {
419
- console.error('[BETTER_AUTH] IDP token exchange failed:', err.message);
511
+ // After successful OAuth callback: exchange Google identity for IDP tokens
512
+ const url = new URL(request.url);
513
+ if (url.pathname.includes('/callback/') && response.status === 302) {
514
+ try {
515
+ const auth = await getBetterAuthInstance();
516
+ if (auth?.api?.getSession) {
517
+ const setCookies = response.headers.getSetCookie?.() || [];
518
+ const cookieHeader = setCookies
519
+ .map((c) => c.split(';')[0])
520
+ .join('; ');
521
+ const headers = new Headers();
522
+ headers.set('cookie', cookieHeader);
523
+ const session = await auth.api.getSession({ headers });
524
+ if (session?.session?.token) {
525
+ console.log('[BETTER_AUTH] Got session token from callback:', session.session.token.substring(0, 10), '| email:', session.user?.email);
526
+ await exchangeOAuthForIdpTokens(session.session.token);
527
+ }
528
+ else {
529
+ console.warn('[BETTER_AUTH] Could not get session after OAuth callback');
530
+ }
531
+ }
532
+ }
533
+ catch (err) {
534
+ console.error('[BETTER_AUTH] IDP token exchange failed:', err.message);
535
+ }
420
536
  }
421
- }
422
- return response;
537
+ return response;
538
+ });
423
539
  };
424
540
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payez/next-mvp",
3
- "version": "4.1.12",
3
+ "version": "4.1.14",
4
4
  "sideEffects": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -891,4 +891,4 @@
891
891
  "typescript": "^5.4.5",
892
892
  "zustand": "^4.5.5"
893
893
  }
894
- }
894
+ }
@@ -1,73 +1,73 @@
1
- #!/bin/bash
2
- #
3
- # CI Check: Detect incorrect internal API URL patterns
4
- #
5
- # This script fails if code is using req.url or req.nextUrl.origin to construct
6
- # URLs for internal API calls. These patterns cause 403 errors in Kubernetes
7
- # because they use the external HTTPS URL instead of INTERNAL_API_URL.
8
- #
9
- # CORRECT: Use internalFetch() or internalRefresh() from @payez/next-mvp/lib/internal-api
10
- # INCORRECT: new URL('/api/...', req.url) or fetch(`${req.nextUrl.origin}/api/...`)
11
- #
12
- # NOTE: new URL(req.url) alone is FINE - it just parses the incoming request URL.
13
- # The problem is when you construct a NEW path using req.url as the base.
14
- #
15
-
16
- set -e
17
-
18
- SEARCH_PATH="${1:-.}"
19
- ERRORS_FOUND=0
20
-
21
- echo "Checking for incorrect internal URL patterns in: $SEARCH_PATH"
22
- echo "========================================================================"
23
-
24
- # Pattern 1: new URL('/api/...', req.url) - constructing internal API URL from request
25
- # This catches: new URL('/api/...', req.url) and new URL("/api/...", req.url)
26
- # But NOT: new URL(req.url) which is just parsing the request
27
- echo ""
28
- echo "Checking for: new URL('/api/...', req.url) pattern"
29
- if grep -rn --include="*.ts" --include="*.tsx" -E "new URL\(['\"][^'\"]*['\"],\s*req\.url" "$SEARCH_PATH" 2>/dev/null | grep -v "node_modules" | grep -v "\.d\.ts"; then
30
- echo "ERROR: Found 'new URL(path, req.url)' pattern. Use internalFetch() instead."
31
- ERRORS_FOUND=1
32
- fi
33
-
34
- # Pattern 2: new URL('/api/...', req.nextUrl) or new URL(..., req.nextUrl.origin)
35
- echo ""
36
- echo "Checking for: new URL(..., req.nextUrl) pattern"
37
- if grep -rn --include="*.ts" --include="*.tsx" -E "new URL\([^)]+,\s*req\.nextUrl" "$SEARCH_PATH" 2>/dev/null | grep -v "node_modules" | grep -v "\.d\.ts"; then
38
- echo "ERROR: Found 'new URL(..., req.nextUrl)' pattern. Use internalFetch() instead."
39
- ERRORS_FOUND=1
40
- fi
41
-
42
- # Pattern 3: fetch with req.nextUrl.origin and /api/
43
- echo ""
44
- echo "Checking for: fetch with req.nextUrl.origin and /api/"
45
- if grep -rn --include="*.ts" --include="*.tsx" "req\.nextUrl\.origin.*\/api\/" "$SEARCH_PATH" 2>/dev/null | grep -v "node_modules" | grep -v "\.d\.ts"; then
46
- echo "ERROR: Found 'req.nextUrl.origin' used with internal API route. Use internalFetch() instead."
47
- ERRORS_FOUND=1
48
- fi
49
-
50
- # Pattern 4: Direct fetch to /api/auth/refresh without using internalRefresh (warning only)
51
- echo ""
52
- echo "Checking for: Direct fetch to /api/auth/refresh (should use internalRefresh)"
53
- if grep -rn --include="*.ts" --include="*.tsx" "fetch.*\/api\/auth\/refresh" "$SEARCH_PATH" 2>/dev/null | grep -v "node_modules" | grep -v "\.d\.ts" | grep -v "internal-api\.ts"; then
54
- echo "WARNING: Found direct fetch to /api/auth/refresh. Consider using internalRefresh() helper."
55
- # Not failing on this one as it might be intentional in some cases (client-side refresh)
56
- fi
57
-
58
- echo ""
59
- echo "========================================================================"
60
-
61
- if [ $ERRORS_FOUND -eq 1 ]; then
62
- echo "FAILED: Dangerous internal URL patterns detected!"
63
- echo ""
64
- echo "Fix: Import and use helpers from @payez/next-mvp/lib/internal-api:"
65
- echo " import { internalFetch, internalRefresh } from '@payez/next-mvp/lib/internal-api';"
66
- echo ""
67
- echo "Why: Server-to-server calls must use INTERNAL_API_URL (http://service.namespace.svc.cluster.local)"
68
- echo " not the external URL from the request (https://api.example.com) which fails in Kubernetes."
69
- exit 1
70
- else
71
- echo "PASSED: No dangerous internal URL patterns found."
72
- exit 0
73
- fi
1
+ #!/bin/bash
2
+ #
3
+ # CI Check: Detect incorrect internal API URL patterns
4
+ #
5
+ # This script fails if code is using req.url or req.nextUrl.origin to construct
6
+ # URLs for internal API calls. These patterns cause 403 errors in Kubernetes
7
+ # because they use the external HTTPS URL instead of INTERNAL_API_URL.
8
+ #
9
+ # CORRECT: Use internalFetch() or internalRefresh() from @payez/next-mvp/lib/internal-api
10
+ # INCORRECT: new URL('/api/...', req.url) or fetch(`${req.nextUrl.origin}/api/...`)
11
+ #
12
+ # NOTE: new URL(req.url) alone is FINE - it just parses the incoming request URL.
13
+ # The problem is when you construct a NEW path using req.url as the base.
14
+ #
15
+
16
+ set -e
17
+
18
+ SEARCH_PATH="${1:-.}"
19
+ ERRORS_FOUND=0
20
+
21
+ echo "Checking for incorrect internal URL patterns in: $SEARCH_PATH"
22
+ echo "========================================================================"
23
+
24
+ # Pattern 1: new URL('/api/...', req.url) - constructing internal API URL from request
25
+ # This catches: new URL('/api/...', req.url) and new URL("/api/...", req.url)
26
+ # But NOT: new URL(req.url) which is just parsing the request
27
+ echo ""
28
+ echo "Checking for: new URL('/api/...', req.url) pattern"
29
+ if grep -rn --include="*.ts" --include="*.tsx" -E "new URL\(['\"][^'\"]*['\"],\s*req\.url" "$SEARCH_PATH" 2>/dev/null | grep -v "node_modules" | grep -v "\.d\.ts"; then
30
+ echo "ERROR: Found 'new URL(path, req.url)' pattern. Use internalFetch() instead."
31
+ ERRORS_FOUND=1
32
+ fi
33
+
34
+ # Pattern 2: new URL('/api/...', req.nextUrl) or new URL(..., req.nextUrl.origin)
35
+ echo ""
36
+ echo "Checking for: new URL(..., req.nextUrl) pattern"
37
+ if grep -rn --include="*.ts" --include="*.tsx" -E "new URL\([^)]+,\s*req\.nextUrl" "$SEARCH_PATH" 2>/dev/null | grep -v "node_modules" | grep -v "\.d\.ts"; then
38
+ echo "ERROR: Found 'new URL(..., req.nextUrl)' pattern. Use internalFetch() instead."
39
+ ERRORS_FOUND=1
40
+ fi
41
+
42
+ # Pattern 3: fetch with req.nextUrl.origin and /api/
43
+ echo ""
44
+ echo "Checking for: fetch with req.nextUrl.origin and /api/"
45
+ if grep -rn --include="*.ts" --include="*.tsx" "req\.nextUrl\.origin.*\/api\/" "$SEARCH_PATH" 2>/dev/null | grep -v "node_modules" | grep -v "\.d\.ts"; then
46
+ echo "ERROR: Found 'req.nextUrl.origin' used with internal API route. Use internalFetch() instead."
47
+ ERRORS_FOUND=1
48
+ fi
49
+
50
+ # Pattern 4: Direct fetch to /api/auth/refresh without using internalRefresh (warning only)
51
+ echo ""
52
+ echo "Checking for: Direct fetch to /api/auth/refresh (should use internalRefresh)"
53
+ if grep -rn --include="*.ts" --include="*.tsx" "fetch.*\/api\/auth\/refresh" "$SEARCH_PATH" 2>/dev/null | grep -v "node_modules" | grep -v "\.d\.ts" | grep -v "internal-api\.ts"; then
54
+ echo "WARNING: Found direct fetch to /api/auth/refresh. Consider using internalRefresh() helper."
55
+ # Not failing on this one as it might be intentional in some cases (client-side refresh)
56
+ fi
57
+
58
+ echo ""
59
+ echo "========================================================================"
60
+
61
+ if [ $ERRORS_FOUND -eq 1 ]; then
62
+ echo "FAILED: Dangerous internal URL patterns detected!"
63
+ echo ""
64
+ echo "Fix: Import and use helpers from @payez/next-mvp/lib/internal-api:"
65
+ echo " import { internalFetch, internalRefresh } from '@payez/next-mvp/lib/internal-api';"
66
+ echo ""
67
+ echo "Why: Server-to-server calls must use INTERNAL_API_URL (http://service.namespace.svc.cluster.local)"
68
+ echo " not the external URL from the request (https://api.example.com) which fails in Kubernetes."
69
+ exit 1
70
+ else
71
+ echo "PASSED: No dangerous internal URL patterns found."
72
+ exit 0
73
+ fi