@payez/next-mvp 4.1.14 → 4.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/dist/api-handlers/account/profile.d.ts +38 -0
  2. package/dist/api-handlers/account/profile.js +73 -45
  3. package/dist/api-handlers/account/recovery/send-code.d.ts +33 -0
  4. package/dist/api-handlers/account/recovery/send-code.js +52 -18
  5. package/dist/api-handlers/account/recovery/verify-code.d.ts +33 -0
  6. package/dist/api-handlers/account/recovery/verify-code.js +52 -18
  7. package/dist/api-handlers/account/verify-email.js +15 -1
  8. package/dist/api-handlers/account/verify-sms.js +15 -1
  9. package/dist/api-handlers/auth/passkey-signin.d.ts +29 -0
  10. package/dist/api-handlers/auth/passkey-signin.js +97 -0
  11. package/dist/api-handlers/auth/refresh.js +38 -15
  12. package/dist/api-handlers/mfa/backup-code-verify.d.ts +73 -0
  13. package/dist/api-handlers/mfa/backup-code-verify.js +153 -0
  14. package/dist/api-handlers/mfa/backup-codes.d.ts +7 -0
  15. package/dist/api-handlers/mfa/backup-codes.js +40 -0
  16. package/dist/api-handlers/mfa/methods.d.ts +3 -0
  17. package/dist/api-handlers/mfa/methods.js +41 -0
  18. package/dist/api-handlers/mfa/totp-confirm.d.ts +8 -0
  19. package/dist/api-handlers/mfa/totp-confirm.js +41 -0
  20. package/dist/api-handlers/mfa/totp-enroll.d.ts +8 -0
  21. package/dist/api-handlers/mfa/totp-enroll.js +41 -0
  22. package/dist/api-handlers/mfa/totp-verify.d.ts +47 -0
  23. package/dist/api-handlers/mfa/totp-verify.js +134 -0
  24. package/dist/api-handlers/passkeys/list.d.ts +7 -0
  25. package/dist/api-handlers/passkeys/list.js +29 -0
  26. package/dist/api-handlers/passkeys/manage.d.ts +3 -0
  27. package/dist/api-handlers/passkeys/manage.js +54 -0
  28. package/dist/api-handlers/passkeys/register-options.d.ts +8 -0
  29. package/dist/api-handlers/passkeys/register-options.js +41 -0
  30. package/dist/api-handlers/passkeys/register-verify.d.ts +7 -0
  31. package/dist/api-handlers/passkeys/register-verify.js +40 -0
  32. package/dist/api-handlers/session/viability.js +19 -0
  33. package/dist/auth/better-auth.d.ts +68 -2
  34. package/dist/auth/better-auth.js +137 -18
  35. package/dist/auth/utils/idp-client.d.ts +13 -22
  36. package/dist/auth/utils/idp-client.js +12 -82
  37. package/dist/client/better-auth-client.d.ts +62 -62
  38. package/dist/lib/ensure-fresh-access-token.js +49 -8
  39. package/dist/lib/session-store.d.ts +1 -0
  40. package/dist/lib/session-store.js +19 -8
  41. package/dist/lib/token-lifecycle.d.ts +2 -0
  42. package/dist/lib/token-lifecycle.js +61 -7
  43. package/dist/models/SessionModel.d.ts +17 -0
  44. package/dist/pages/login/page.d.ts +10 -1
  45. package/dist/pages/login/page.js +69 -80
  46. package/dist/pages/verify-code/page.d.ts +10 -1
  47. package/dist/pages/verify-code/page.js +72 -4
  48. package/dist/routes/auth/passkey-signin.d.ts +13 -0
  49. package/dist/routes/auth/passkey-signin.js +18 -0
  50. package/dist/routes/auth/viability.d.ts +2 -0
  51. package/dist/routes/auth/viability.js +88 -0
  52. package/dist/routes/mfa/backup-code-verify.d.ts +1 -0
  53. package/dist/routes/mfa/backup-code-verify.js +5 -0
  54. package/dist/routes/mfa/backup-codes.d.ts +10 -0
  55. package/dist/routes/mfa/backup-codes.js +14 -0
  56. package/dist/routes/mfa/methods.d.ts +21 -0
  57. package/dist/routes/mfa/methods.js +10 -0
  58. package/dist/routes/mfa/totp-confirm.d.ts +10 -0
  59. package/dist/routes/mfa/totp-confirm.js +14 -0
  60. package/dist/routes/mfa/totp-enroll.d.ts +10 -0
  61. package/dist/routes/mfa/totp-enroll.js +14 -0
  62. package/dist/routes/mfa/totp-verify.d.ts +14 -0
  63. package/dist/routes/mfa/totp-verify.js +18 -0
  64. package/dist/routes/passkeys/list.d.ts +10 -0
  65. package/dist/routes/passkeys/list.js +14 -0
  66. package/dist/routes/passkeys/manage.d.ts +18 -0
  67. package/dist/routes/passkeys/manage.js +13 -0
  68. package/dist/routes/passkeys/register-options.d.ts +10 -0
  69. package/dist/routes/passkeys/register-options.js +14 -0
  70. package/dist/routes/passkeys/register-verify.d.ts +10 -0
  71. package/dist/routes/passkeys/register-verify.js +14 -0
  72. package/dist/server/auth.d.ts +29 -20
  73. package/dist/server/auth.js +8 -0
  74. package/dist/server/decode-session.js +37 -3
  75. package/package.json +958 -894
  76. package/scripts/check-internal-url-usage.sh +73 -73
  77. package/src/api-handlers/account/profile.ts +76 -57
  78. package/src/api-handlers/account/recovery/send-code.ts +65 -17
  79. package/src/api-handlers/account/recovery/verify-code.ts +65 -17
  80. package/src/api-handlers/account/verify-email.ts +15 -1
  81. package/src/api-handlers/account/verify-sms.ts +15 -1
  82. package/src/api-handlers/auth/passkey-signin.ts +98 -0
  83. package/src/api-handlers/auth/refresh.ts +38 -15
  84. package/src/api-handlers/mfa/backup-code-verify.ts +175 -0
  85. package/src/api-handlers/mfa/backup-codes.ts +41 -0
  86. package/src/api-handlers/mfa/methods.ts +46 -0
  87. package/src/api-handlers/mfa/totp-confirm.ts +42 -0
  88. package/src/api-handlers/mfa/totp-enroll.ts +42 -0
  89. package/src/api-handlers/mfa/totp-verify.ts +156 -0
  90. package/src/api-handlers/passkeys/list.ts +31 -0
  91. package/src/api-handlers/passkeys/manage.ts +54 -0
  92. package/src/api-handlers/passkeys/register-options.ts +42 -0
  93. package/src/api-handlers/passkeys/register-verify.ts +41 -0
  94. package/src/api-handlers/session/viability.ts +19 -0
  95. package/src/auth/better-auth.ts +158 -18
  96. package/src/auth/utils/idp-client.ts +13 -95
  97. package/src/lib/ensure-fresh-access-token.ts +49 -8
  98. package/src/lib/session-store.ts +11 -1
  99. package/src/lib/token-lifecycle.ts +70 -7
  100. package/src/models/SessionModel.ts +18 -0
  101. package/src/pages/login/page.tsx +123 -228
  102. package/src/pages/verify-code/page.tsx +71 -5
  103. package/src/routes/auth/passkey-signin.ts +13 -0
  104. package/src/routes/auth/viability.ts +89 -0
  105. package/src/routes/mfa/backup-code-verify.ts +1 -0
  106. package/src/routes/mfa/backup-codes.ts +10 -0
  107. package/src/routes/mfa/methods.ts +22 -0
  108. package/src/routes/mfa/totp-confirm.ts +10 -0
  109. package/src/routes/mfa/totp-enroll.ts +10 -0
  110. package/src/routes/mfa/totp-verify.ts +14 -0
  111. package/src/routes/passkeys/list.ts +10 -0
  112. package/src/routes/passkeys/manage.ts +23 -0
  113. package/src/routes/passkeys/register-options.ts +10 -0
  114. package/src/routes/passkeys/register-verify.ts +10 -0
  115. package/src/server/auth.ts +17 -1
  116. package/src/server/decode-session.ts +36 -3
@@ -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
@@ -1,80 +1,99 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
+ import { idpFetchJSON } from '../../lib/idp-fetch';
3
+ import { ENV_CONFIG } from '../../config/env';
2
4
 
3
5
  /**
4
6
  * Account Profile API Handler
5
- * Simple proxy to IDP profile endpoint
6
7
  *
7
- * GET /api/account/profile - Get user profile
8
- * PUT /api/account/profile - Update user profile
8
+ * GET /api/account/profile - Get user profile
9
+ * PUT /api/account/profile - Update user profile
10
+ *
11
+ * WHY THIS WAS REWRITTEN (2026-08-02, QAPert defect #7)
12
+ * Reported as "/api/account/profile 401s on the same cookie masked-info
13
+ * accepts (200)". Two endpoints disagreeing about one cookie meant two auth
14
+ * implementations, and they were:
15
+ *
16
+ * masked-info.ts idpFetchJSON(req, ...) resolves the caller's token
17
+ * from the server-side session
18
+ * profile.ts req.headers.get('authorization') required an INBOUND bearer
19
+ *
20
+ * A browser sends a session COOKIE and no Authorization header, so the old
21
+ * profile.ts 401'd EVERY browser request by construction. Nothing was revoked
22
+ * and no token was stale — the handler demanded a credential the app does not
23
+ * use.
24
+ *
25
+ * THE OLD AUTH CHECK WAS A VENEER. It tested that the header was PRESENT, never
26
+ * that it was valid, then forwarded it upstream unexamined. Any string at all
27
+ * got past it and the IDP did the real work. It looked like enforcement and
28
+ * enforced nothing.
29
+ *
30
+ * WHAT THE WORKAROUND COST US: consumers could not call this with a cookie, so
31
+ * the graft's useProfile hook fetched the raw IDP access token from
32
+ * /api/session/idp-token INTO BROWSER JAVASCRIPT and re-sent it as a bearer.
33
+ * That defeats the reason idpFetchJSON exists — it keeps the token server-side
34
+ * precisely so client-side script can never hold it. The bearer-only handler
35
+ * did not merely fail; it pushed its callers into exposing the token.
36
+ *
37
+ * Switching to idpFetchJSON makes the cookie work, so the hook's token round
38
+ * trip can be deleted and /api/session/idp-token drops to a single deliberate
39
+ * caller: the jwt-inspect debug page.
40
+ *
41
+ * @package @payez/next-mvp
9
42
  */
10
43
 
11
- function getIdpUrl(): string {
12
- const url = process.env.IDP_URL;
13
- if (!url) {
14
- throw new Error('[IDP_URL] FATAL: IDP_URL environment variable is REQUIRED.');
15
- }
16
- return url;
17
- }
18
-
19
44
  export async function GET(req: NextRequest) {
20
- const IDP_URL = getIdpUrl();
21
- const authHeader = req.headers.get('authorization');
45
+ const result = await idpFetchJSON(req, `${ENV_CONFIG.IDP_URL}/api/Account/profile`, {
46
+ method: 'GET',
47
+ headers: { 'Content-Type': 'application/json' },
48
+ });
22
49
 
23
- if (!authHeader) {
50
+ if (!result.ok) {
24
51
  return NextResponse.json(
25
- { success: false, error: { code: 'UNAUTHORIZED', message: 'Authentication required' } },
26
- { status: 401 }
27
- );
28
- }
29
-
30
- try {
31
- const upstream = await fetch(`${IDP_URL}/api/Account/profile`, {
32
- method: 'GET',
33
- headers: {
34
- 'Content-Type': 'application/json',
35
- 'Authorization': authHeader,
52
+ {
53
+ success: false,
54
+ error: {
55
+ code: 'UPSTREAM_SERVICE_ERROR',
56
+ message: result.json?.message || 'Failed to fetch profile',
57
+ status: result.status,
58
+ },
59
+ meta: { attemptedRefresh: result.attemptedRefresh },
36
60
  },
37
- });
38
-
39
- const data = await upstream.json().catch(() => ({}));
40
- return NextResponse.json(data, { status: upstream.status });
41
- } catch (error) {
42
- return NextResponse.json(
43
- { success: false, error: { code: 'UPSTREAM_ERROR', message: 'Failed to fetch profile' } },
44
- { status: 500 }
61
+ { status: result.status }
45
62
  );
46
63
  }
64
+
65
+ return NextResponse.json(result.json, { status: 200 });
47
66
  }
48
67
 
49
68
  export async function PUT(req: NextRequest) {
50
- const IDP_URL = getIdpUrl();
51
- const authHeader = req.headers.get('authorization');
52
-
53
- if (!authHeader) {
54
- return NextResponse.json(
55
- { success: false, error: { code: 'UNAUTHORIZED', message: 'Authentication required' } },
56
- { status: 401 }
57
- );
58
- }
59
-
69
+ let body = '{}';
60
70
  try {
61
- const body = await req.text();
71
+ const raw = await req.text();
72
+ if (raw && raw.trim().length > 0) body = raw;
73
+ } catch {
74
+ // Empty body is valid here; the IDP validates the payload.
75
+ }
62
76
 
63
- const upstream = await fetch(`${IDP_URL}/api/Account/profile`, {
64
- method: 'PUT',
65
- headers: {
66
- 'Content-Type': 'application/json',
67
- 'Authorization': authHeader,
68
- },
69
- body,
70
- });
77
+ const result = await idpFetchJSON(req, `${ENV_CONFIG.IDP_URL}/api/Account/profile`, {
78
+ method: 'PUT',
79
+ headers: { 'Content-Type': 'application/json' },
80
+ body,
81
+ });
71
82
 
72
- const data = await upstream.json().catch(() => ({}));
73
- return NextResponse.json(data, { status: upstream.status });
74
- } catch (error) {
83
+ if (!result.ok) {
75
84
  return NextResponse.json(
76
- { success: false, error: { code: 'UPSTREAM_ERROR', message: 'Failed to update profile' } },
77
- { status: 500 }
85
+ {
86
+ success: false,
87
+ error: {
88
+ code: 'UPSTREAM_SERVICE_ERROR',
89
+ message: result.json?.message || 'Failed to update profile',
90
+ status: result.status,
91
+ },
92
+ meta: { attemptedRefresh: result.attemptedRefresh },
93
+ },
94
+ { status: result.status }
78
95
  );
79
96
  }
97
+
98
+ return NextResponse.json(result.json, { status: 200 });
80
99
  }
@@ -1,25 +1,73 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
+ import { idpFetchJSON } from '../../../lib/idp-fetch';
3
+ import { ENV_CONFIG } from '../../../config/env';
2
4
 
3
- function getIdpUrl(): string {
4
- const url = process.env.IDP_URL;
5
- if (!url) throw new Error('[IDP_URL] FATAL: IDP_URL environment variable is REQUIRED.');
6
- return url;
7
- }
5
+ /**
6
+ * Account recovery — send-code
7
+ *
8
+ * WHY THIS WAS REWRITTEN (2026-08-02, swept out of QAPert defect #7)
9
+ * This handler read the caller's bearer straight off the inbound request:
10
+ *
11
+ * const auth = req.headers.get('authorization') || '';
12
+ * const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
13
+ * ... headers: { 'Authorization': `Bearer ${token}` }
14
+ *
15
+ * Browsers send a session COOKIE and no Authorization header, so `token` was
16
+ * ALWAYS the empty string and this always called the IDP with a literal
17
+ * "Bearer " and nothing after it.
18
+ *
19
+ * THAT IS WORSE THAN THE profile.ts VERSION OF THIS BUG, in two ways:
20
+ *
21
+ * 1. profile.ts at least 401'd when the header was missing. This one has no
22
+ * check at all — `|| ''` swallows the absence — so it proceeds to call
23
+ * upstream with an empty credential and returns the IDP's rejection as an
24
+ * opaque failure. The user sees recovery "not working", not "not
25
+ * authenticated", and the log shows an upstream error rather than an auth
26
+ * one. The defect hides itself.
27
+ * 2. It is BOTH HALVES of account recovery — send-code and verify-code — so
28
+ * the entire recovery path is unreachable, not merely degraded. This is
29
+ * the flow a locked-out user reaches for, i.e. it fails exactly when it is
30
+ * the only thing left that could help them.
31
+ *
32
+ * Fixed the same way as profile.ts and masked-info.ts: idpFetchJSON resolves
33
+ * the caller's token from the server-side session, so the cookie works and the
34
+ * token is never handed to browser JavaScript.
35
+ *
36
+ * @package @payez/next-mvp
37
+ */
8
38
 
9
39
  export async function POST(req: NextRequest) {
10
- const IDP_URL = getIdpUrl();
40
+ let payload: unknown = {};
11
41
  try {
12
- const auth = req.headers.get('authorization') || '';
13
- const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
14
- const { method } = await req.json();
15
- const upstream = await fetch(`${IDP_URL}/api/Account/recovery/send-code`, {
16
- method: 'POST',
17
- headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
18
- body: JSON.stringify({ method }),
19
- });
20
- const data = await upstream.json().catch(() => ({}));
21
- return NextResponse.json(data, { status: upstream.status });
42
+ payload = await req.json();
22
43
  } catch {
23
- return NextResponse.json({ success: false, error: 'send_code_failed' }, { status: 500 });
44
+ return NextResponse.json(
45
+ { success: false, error: 'invalid_request', message: 'A JSON body is required' },
46
+ { status: 400 }
47
+ );
24
48
  }
49
+
50
+ const result = await idpFetchJSON(
51
+ req,
52
+ `${ENV_CONFIG.IDP_URL}/api/Account/recovery/send-code`,
53
+ {
54
+ method: 'POST',
55
+ headers: { 'Content-Type': 'application/json' },
56
+ body: JSON.stringify(payload),
57
+ }
58
+ );
59
+
60
+ if (!result.ok) {
61
+ return NextResponse.json(
62
+ {
63
+ success: false,
64
+ error: 'send_code_failed',
65
+ message: result.json?.message || 'Upstream error',
66
+ meta: { status: result.status, attemptedRefresh: result.attemptedRefresh },
67
+ },
68
+ { status: result.status }
69
+ );
70
+ }
71
+
72
+ return NextResponse.json(result.json, { status: 200 });
25
73
  }
@@ -1,25 +1,73 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
+ import { idpFetchJSON } from '../../../lib/idp-fetch';
3
+ import { ENV_CONFIG } from '../../../config/env';
2
4
 
3
- function getIdpUrl(): string {
4
- const url = process.env.IDP_URL;
5
- if (!url) throw new Error('[IDP_URL] FATAL: IDP_URL environment variable is REQUIRED.');
6
- return url;
7
- }
5
+ /**
6
+ * Account recovery — verify-code
7
+ *
8
+ * WHY THIS WAS REWRITTEN (2026-08-02, swept out of QAPert defect #7)
9
+ * This handler read the caller's bearer straight off the inbound request:
10
+ *
11
+ * const auth = req.headers.get('authorization') || '';
12
+ * const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
13
+ * ... headers: { 'Authorization': `Bearer ${token}` }
14
+ *
15
+ * Browsers send a session COOKIE and no Authorization header, so `token` was
16
+ * ALWAYS the empty string and this always called the IDP with a literal
17
+ * "Bearer " and nothing after it.
18
+ *
19
+ * THAT IS WORSE THAN THE profile.ts VERSION OF THIS BUG, in two ways:
20
+ *
21
+ * 1. profile.ts at least 401'd when the header was missing. This one has no
22
+ * check at all — `|| ''` swallows the absence — so it proceeds to call
23
+ * upstream with an empty credential and returns the IDP's rejection as an
24
+ * opaque failure. The user sees recovery "not working", not "not
25
+ * authenticated", and the log shows an upstream error rather than an auth
26
+ * one. The defect hides itself.
27
+ * 2. It is BOTH HALVES of account recovery — send-code and verify-code — so
28
+ * the entire recovery path is unreachable, not merely degraded. This is
29
+ * the flow a locked-out user reaches for, i.e. it fails exactly when it is
30
+ * the only thing left that could help them.
31
+ *
32
+ * Fixed the same way as profile.ts and masked-info.ts: idpFetchJSON resolves
33
+ * the caller's token from the server-side session, so the cookie works and the
34
+ * token is never handed to browser JavaScript.
35
+ *
36
+ * @package @payez/next-mvp
37
+ */
8
38
 
9
39
  export async function POST(req: NextRequest) {
10
- const IDP_URL = getIdpUrl();
40
+ let payload: unknown = {};
11
41
  try {
12
- const auth = req.headers.get('authorization') || '';
13
- const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
14
- const { code, method } = await req.json();
15
- const upstream = await fetch(`${IDP_URL}/api/Account/recovery/verify-code`, {
16
- method: 'POST',
17
- headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
18
- body: JSON.stringify({ code, method }),
19
- });
20
- const data = await upstream.json().catch(() => ({}));
21
- return NextResponse.json(data, { status: upstream.status });
42
+ payload = await req.json();
22
43
  } catch {
23
- return NextResponse.json({ success: false, error: 'verify_code_failed' }, { status: 500 });
44
+ return NextResponse.json(
45
+ { success: false, error: 'invalid_request', message: 'A JSON body is required' },
46
+ { status: 400 }
47
+ );
24
48
  }
49
+
50
+ const result = await idpFetchJSON(
51
+ req,
52
+ `${ENV_CONFIG.IDP_URL}/api/Account/recovery/verify-code`,
53
+ {
54
+ method: 'POST',
55
+ headers: { 'Content-Type': 'application/json' },
56
+ body: JSON.stringify(payload),
57
+ }
58
+ );
59
+
60
+ if (!result.ok) {
61
+ return NextResponse.json(
62
+ {
63
+ success: false,
64
+ error: 'verify_code_failed',
65
+ message: result.json?.message || 'Upstream error',
66
+ meta: { status: result.status, attemptedRefresh: result.attemptedRefresh },
67
+ },
68
+ { status: result.status }
69
+ );
70
+ }
71
+
72
+ return NextResponse.json(result.json, { status: 200 });
25
73
  }
@@ -54,6 +54,11 @@ export async function POST(req: NextRequest) {
54
54
  const unwrappedData = result.json?.data || result.json;
55
55
 
56
56
  // If we have new tokens, update the session to complete 2FA
57
+ // Reports whether the session ACTUALLY transitioned. Must not be assumed
58
+ // from a 2xx: the IDP can verify the code while we fail to transition
59
+ // (no tokens returned, or no resolvable session token).
60
+ let sessionTransitioned = false;
61
+
57
62
  if (unwrappedData.access_token && unwrappedData.refresh_token) {
58
63
  // Get session token from NextAuth
59
64
  // Support both field names: sessionToken (auth.ts JWT) and redisSessionId (legacy)
@@ -97,18 +102,27 @@ export async function POST(req: NextRequest) {
97
102
  'email' // Store 2FA method for refresh token flow
98
103
  );
99
104
 
105
+ sessionTransitioned = true;
106
+
100
107
  console.log('[VERIFY_EMAIL] Session updated successfully', {
101
108
  accessTokenExpires: new Date(accessTokenExpires).toISOString(),
102
109
  refreshTokenExpires: new Date(refreshTokenExpires).toISOString()
103
110
  });
111
+ } else {
112
+ console.warn('[VERIFY_EMAIL] IDP verified the code but no session token was resolvable — session NOT transitioned');
104
113
  }
114
+ } else {
115
+ console.warn('[VERIFY_EMAIL] IDP returned 2xx without tokens — session NOT transitioned');
105
116
  }
106
117
 
107
118
  // Return simplified success response (don't expose tokens to client)
108
119
  return NextResponse.json({
109
120
  success: true,
110
121
  verificationSuccessful: true,
111
- twoFactorSessionVerified: true,
122
+ // NOT a copy of `success` — reports what actually happened. A client that
123
+ // trusted a hardcoded true would show the user as MFA-verified while the
124
+ // session still says otherwise.
125
+ twoFactorSessionVerified: sessionTransitioned,
112
126
  message: unwrappedData.message || 'Email code verified successfully'
113
127
  }, { status: 200 });
114
128
  } catch (error) {
@@ -54,6 +54,11 @@ export async function POST(req: NextRequest) {
54
54
  const unwrappedData = result.json?.data || result.json;
55
55
 
56
56
  // If we have new tokens, update the session to complete 2FA
57
+ // Reports whether the session ACTUALLY transitioned. Must not be assumed
58
+ // from a 2xx: the IDP can verify the code while we fail to transition
59
+ // (no tokens returned, or no resolvable session token).
60
+ let sessionTransitioned = false;
61
+
57
62
  if (unwrappedData.access_token && unwrappedData.refresh_token) {
58
63
  // Get session token from NextAuth
59
64
  // Support both field names: sessionToken (auth.ts JWT) and redisSessionId (legacy)
@@ -97,18 +102,27 @@ export async function POST(req: NextRequest) {
97
102
  'sms' // Store 2FA method for refresh token flow
98
103
  );
99
104
 
105
+ sessionTransitioned = true;
106
+
100
107
  console.log('[VERIFY_SMS] Session updated successfully', {
101
108
  accessTokenExpires: new Date(accessTokenExpires).toISOString(),
102
109
  refreshTokenExpires: new Date(refreshTokenExpires).toISOString()
103
110
  });
111
+ } else {
112
+ console.warn('[VERIFY_SMS] IDP verified the code but no session token was resolvable — session NOT transitioned');
104
113
  }
114
+ } else {
115
+ console.warn('[VERIFY_SMS] IDP returned 2xx without tokens — session NOT transitioned');
105
116
  }
106
117
 
107
118
  // Return simplified success response (don't expose tokens to client)
108
119
  return NextResponse.json({
109
120
  success: true,
110
121
  verificationSuccessful: true,
111
- twoFactorSessionVerified: true,
122
+ // NOT a copy of `success` — reports what actually happened. A client that
123
+ // trusted a hardcoded true would show the user as MFA-verified while the
124
+ // session still says otherwise.
125
+ twoFactorSessionVerified: sessionTransitioned,
112
126
  message: unwrappedData.message || 'SMS code verified successfully'
113
127
  }, { status: 200 });
114
128
  } catch (error) {