@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,3 +1,41 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
+ /**
3
+ * Account Profile API Handler
4
+ *
5
+ * GET /api/account/profile - Get user profile
6
+ * PUT /api/account/profile - Update user profile
7
+ *
8
+ * WHY THIS WAS REWRITTEN (2026-08-02, QAPert defect #7)
9
+ * Reported as "/api/account/profile 401s on the same cookie masked-info
10
+ * accepts (200)". Two endpoints disagreeing about one cookie meant two auth
11
+ * implementations, and they were:
12
+ *
13
+ * masked-info.ts idpFetchJSON(req, ...) resolves the caller's token
14
+ * from the server-side session
15
+ * profile.ts req.headers.get('authorization') required an INBOUND bearer
16
+ *
17
+ * A browser sends a session COOKIE and no Authorization header, so the old
18
+ * profile.ts 401'd EVERY browser request by construction. Nothing was revoked
19
+ * and no token was stale — the handler demanded a credential the app does not
20
+ * use.
21
+ *
22
+ * THE OLD AUTH CHECK WAS A VENEER. It tested that the header was PRESENT, never
23
+ * that it was valid, then forwarded it upstream unexamined. Any string at all
24
+ * got past it and the IDP did the real work. It looked like enforcement and
25
+ * enforced nothing.
26
+ *
27
+ * WHAT THE WORKAROUND COST US: consumers could not call this with a cookie, so
28
+ * the graft's useProfile hook fetched the raw IDP access token from
29
+ * /api/session/idp-token INTO BROWSER JAVASCRIPT and re-sent it as a bearer.
30
+ * That defeats the reason idpFetchJSON exists — it keeps the token server-side
31
+ * precisely so client-side script can never hold it. The bearer-only handler
32
+ * did not merely fail; it pushed its callers into exposing the token.
33
+ *
34
+ * Switching to idpFetchJSON makes the cookie work, so the hook's token round
35
+ * trip can be deleted and /api/session/idp-token drops to a single deliberate
36
+ * caller: the jwt-inspect debug page.
37
+ *
38
+ * @package @payez/next-mvp
39
+ */
2
40
  export declare function GET(req: NextRequest): Promise<NextResponse<any>>;
3
41
  export declare function PUT(req: NextRequest): Promise<NextResponse<any>>;
@@ -3,61 +3,89 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.GET = GET;
4
4
  exports.PUT = PUT;
5
5
  const server_1 = require("next/server");
6
+ const idp_fetch_1 = require("../../lib/idp-fetch");
7
+ const env_1 = require("../../config/env");
6
8
  /**
7
9
  * Account Profile API Handler
8
- * Simple proxy to IDP profile endpoint
9
10
  *
10
- * GET /api/account/profile - Get user profile
11
- * PUT /api/account/profile - Update user profile
11
+ * GET /api/account/profile - Get user profile
12
+ * PUT /api/account/profile - Update user profile
13
+ *
14
+ * WHY THIS WAS REWRITTEN (2026-08-02, QAPert defect #7)
15
+ * Reported as "/api/account/profile 401s on the same cookie masked-info
16
+ * accepts (200)". Two endpoints disagreeing about one cookie meant two auth
17
+ * implementations, and they were:
18
+ *
19
+ * masked-info.ts idpFetchJSON(req, ...) resolves the caller's token
20
+ * from the server-side session
21
+ * profile.ts req.headers.get('authorization') required an INBOUND bearer
22
+ *
23
+ * A browser sends a session COOKIE and no Authorization header, so the old
24
+ * profile.ts 401'd EVERY browser request by construction. Nothing was revoked
25
+ * and no token was stale — the handler demanded a credential the app does not
26
+ * use.
27
+ *
28
+ * THE OLD AUTH CHECK WAS A VENEER. It tested that the header was PRESENT, never
29
+ * that it was valid, then forwarded it upstream unexamined. Any string at all
30
+ * got past it and the IDP did the real work. It looked like enforcement and
31
+ * enforced nothing.
32
+ *
33
+ * WHAT THE WORKAROUND COST US: consumers could not call this with a cookie, so
34
+ * the graft's useProfile hook fetched the raw IDP access token from
35
+ * /api/session/idp-token INTO BROWSER JAVASCRIPT and re-sent it as a bearer.
36
+ * That defeats the reason idpFetchJSON exists — it keeps the token server-side
37
+ * precisely so client-side script can never hold it. The bearer-only handler
38
+ * did not merely fail; it pushed its callers into exposing the token.
39
+ *
40
+ * Switching to idpFetchJSON makes the cookie work, so the hook's token round
41
+ * trip can be deleted and /api/session/idp-token drops to a single deliberate
42
+ * caller: the jwt-inspect debug page.
43
+ *
44
+ * @package @payez/next-mvp
12
45
  */
13
- function getIdpUrl() {
14
- const url = process.env.IDP_URL;
15
- if (!url) {
16
- throw new Error('[IDP_URL] FATAL: IDP_URL environment variable is REQUIRED.');
17
- }
18
- return url;
19
- }
20
46
  async function GET(req) {
21
- const IDP_URL = getIdpUrl();
22
- const authHeader = req.headers.get('authorization');
23
- if (!authHeader) {
24
- return server_1.NextResponse.json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Authentication required' } }, { status: 401 });
25
- }
26
- try {
27
- const upstream = await fetch(`${IDP_URL}/api/Account/profile`, {
28
- method: 'GET',
29
- headers: {
30
- 'Content-Type': 'application/json',
31
- 'Authorization': authHeader,
47
+ const result = await (0, idp_fetch_1.idpFetchJSON)(req, `${env_1.ENV_CONFIG.IDP_URL}/api/Account/profile`, {
48
+ method: 'GET',
49
+ headers: { 'Content-Type': 'application/json' },
50
+ });
51
+ if (!result.ok) {
52
+ return server_1.NextResponse.json({
53
+ success: false,
54
+ error: {
55
+ code: 'UPSTREAM_SERVICE_ERROR',
56
+ message: result.json?.message || 'Failed to fetch profile',
57
+ status: result.status,
32
58
  },
33
- });
34
- const data = await upstream.json().catch(() => ({}));
35
- return server_1.NextResponse.json(data, { status: upstream.status });
36
- }
37
- catch (error) {
38
- return server_1.NextResponse.json({ success: false, error: { code: 'UPSTREAM_ERROR', message: 'Failed to fetch profile' } }, { status: 500 });
59
+ meta: { attemptedRefresh: result.attemptedRefresh },
60
+ }, { status: result.status });
39
61
  }
62
+ return server_1.NextResponse.json(result.json, { status: 200 });
40
63
  }
41
64
  async function PUT(req) {
42
- const IDP_URL = getIdpUrl();
43
- const authHeader = req.headers.get('authorization');
44
- if (!authHeader) {
45
- return server_1.NextResponse.json({ success: false, error: { code: 'UNAUTHORIZED', message: 'Authentication required' } }, { status: 401 });
46
- }
65
+ let body = '{}';
47
66
  try {
48
- const body = await req.text();
49
- const upstream = await fetch(`${IDP_URL}/api/Account/profile`, {
50
- method: 'PUT',
51
- headers: {
52
- 'Content-Type': 'application/json',
53
- 'Authorization': authHeader,
54
- },
55
- body,
56
- });
57
- const data = await upstream.json().catch(() => ({}));
58
- return server_1.NextResponse.json(data, { status: upstream.status });
67
+ const raw = await req.text();
68
+ if (raw && raw.trim().length > 0)
69
+ body = raw;
59
70
  }
60
- catch (error) {
61
- return server_1.NextResponse.json({ success: false, error: { code: 'UPSTREAM_ERROR', message: 'Failed to update profile' } }, { status: 500 });
71
+ catch {
72
+ // Empty body is valid here; the IDP validates the payload.
73
+ }
74
+ const result = await (0, idp_fetch_1.idpFetchJSON)(req, `${env_1.ENV_CONFIG.IDP_URL}/api/Account/profile`, {
75
+ method: 'PUT',
76
+ headers: { 'Content-Type': 'application/json' },
77
+ body,
78
+ });
79
+ if (!result.ok) {
80
+ return server_1.NextResponse.json({
81
+ success: false,
82
+ error: {
83
+ code: 'UPSTREAM_SERVICE_ERROR',
84
+ message: result.json?.message || 'Failed to update profile',
85
+ status: result.status,
86
+ },
87
+ meta: { attemptedRefresh: result.attemptedRefresh },
88
+ }, { status: result.status });
62
89
  }
90
+ return server_1.NextResponse.json(result.json, { status: 200 });
63
91
  }
@@ -1,2 +1,35 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
+ /**
3
+ * Account recovery — send-code
4
+ *
5
+ * WHY THIS WAS REWRITTEN (2026-08-02, swept out of QAPert defect #7)
6
+ * This handler read the caller's bearer straight off the inbound request:
7
+ *
8
+ * const auth = req.headers.get('authorization') || '';
9
+ * const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
10
+ * ... headers: { 'Authorization': `Bearer ${token}` }
11
+ *
12
+ * Browsers send a session COOKIE and no Authorization header, so `token` was
13
+ * ALWAYS the empty string and this always called the IDP with a literal
14
+ * "Bearer " and nothing after it.
15
+ *
16
+ * THAT IS WORSE THAN THE profile.ts VERSION OF THIS BUG, in two ways:
17
+ *
18
+ * 1. profile.ts at least 401'd when the header was missing. This one has no
19
+ * check at all — `|| ''` swallows the absence — so it proceeds to call
20
+ * upstream with an empty credential and returns the IDP's rejection as an
21
+ * opaque failure. The user sees recovery "not working", not "not
22
+ * authenticated", and the log shows an upstream error rather than an auth
23
+ * one. The defect hides itself.
24
+ * 2. It is BOTH HALVES of account recovery — send-code and verify-code — so
25
+ * the entire recovery path is unreachable, not merely degraded. This is
26
+ * the flow a locked-out user reaches for, i.e. it fails exactly when it is
27
+ * the only thing left that could help them.
28
+ *
29
+ * Fixed the same way as profile.ts and masked-info.ts: idpFetchJSON resolves
30
+ * the caller's token from the server-side session, so the cookie works and the
31
+ * token is never handed to browser JavaScript.
32
+ *
33
+ * @package @payez/next-mvp
34
+ */
2
35
  export declare function POST(req: NextRequest): Promise<NextResponse<any>>;
@@ -2,27 +2,61 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.POST = POST;
4
4
  const server_1 = require("next/server");
5
- function getIdpUrl() {
6
- const url = process.env.IDP_URL;
7
- if (!url)
8
- throw new Error('[IDP_URL] FATAL: IDP_URL environment variable is REQUIRED.');
9
- return url;
10
- }
5
+ const idp_fetch_1 = require("../../../lib/idp-fetch");
6
+ const env_1 = require("../../../config/env");
7
+ /**
8
+ * Account recovery — send-code
9
+ *
10
+ * WHY THIS WAS REWRITTEN (2026-08-02, swept out of QAPert defect #7)
11
+ * This handler read the caller's bearer straight off the inbound request:
12
+ *
13
+ * const auth = req.headers.get('authorization') || '';
14
+ * const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
15
+ * ... headers: { 'Authorization': `Bearer ${token}` }
16
+ *
17
+ * Browsers send a session COOKIE and no Authorization header, so `token` was
18
+ * ALWAYS the empty string and this always called the IDP with a literal
19
+ * "Bearer " and nothing after it.
20
+ *
21
+ * THAT IS WORSE THAN THE profile.ts VERSION OF THIS BUG, in two ways:
22
+ *
23
+ * 1. profile.ts at least 401'd when the header was missing. This one has no
24
+ * check at all — `|| ''` swallows the absence — so it proceeds to call
25
+ * upstream with an empty credential and returns the IDP's rejection as an
26
+ * opaque failure. The user sees recovery "not working", not "not
27
+ * authenticated", and the log shows an upstream error rather than an auth
28
+ * one. The defect hides itself.
29
+ * 2. It is BOTH HALVES of account recovery — send-code and verify-code — so
30
+ * the entire recovery path is unreachable, not merely degraded. This is
31
+ * the flow a locked-out user reaches for, i.e. it fails exactly when it is
32
+ * the only thing left that could help them.
33
+ *
34
+ * Fixed the same way as profile.ts and masked-info.ts: idpFetchJSON resolves
35
+ * the caller's token from the server-side session, so the cookie works and the
36
+ * token is never handed to browser JavaScript.
37
+ *
38
+ * @package @payez/next-mvp
39
+ */
11
40
  async function POST(req) {
12
- const IDP_URL = getIdpUrl();
41
+ let payload = {};
13
42
  try {
14
- const auth = req.headers.get('authorization') || '';
15
- const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
16
- const { method } = await req.json();
17
- const upstream = await fetch(`${IDP_URL}/api/Account/recovery/send-code`, {
18
- method: 'POST',
19
- headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
20
- body: JSON.stringify({ method }),
21
- });
22
- const data = await upstream.json().catch(() => ({}));
23
- return server_1.NextResponse.json(data, { status: upstream.status });
43
+ payload = await req.json();
24
44
  }
25
45
  catch {
26
- return server_1.NextResponse.json({ success: false, error: 'send_code_failed' }, { status: 500 });
46
+ return server_1.NextResponse.json({ success: false, error: 'invalid_request', message: 'A JSON body is required' }, { status: 400 });
47
+ }
48
+ const result = await (0, idp_fetch_1.idpFetchJSON)(req, `${env_1.ENV_CONFIG.IDP_URL}/api/Account/recovery/send-code`, {
49
+ method: 'POST',
50
+ headers: { 'Content-Type': 'application/json' },
51
+ body: JSON.stringify(payload),
52
+ });
53
+ if (!result.ok) {
54
+ return server_1.NextResponse.json({
55
+ success: false,
56
+ error: 'send_code_failed',
57
+ message: result.json?.message || 'Upstream error',
58
+ meta: { status: result.status, attemptedRefresh: result.attemptedRefresh },
59
+ }, { status: result.status });
27
60
  }
61
+ return server_1.NextResponse.json(result.json, { status: 200 });
28
62
  }
@@ -1,2 +1,35 @@
1
1
  import { NextRequest, NextResponse } from 'next/server';
2
+ /**
3
+ * Account recovery — verify-code
4
+ *
5
+ * WHY THIS WAS REWRITTEN (2026-08-02, swept out of QAPert defect #7)
6
+ * This handler read the caller's bearer straight off the inbound request:
7
+ *
8
+ * const auth = req.headers.get('authorization') || '';
9
+ * const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
10
+ * ... headers: { 'Authorization': `Bearer ${token}` }
11
+ *
12
+ * Browsers send a session COOKIE and no Authorization header, so `token` was
13
+ * ALWAYS the empty string and this always called the IDP with a literal
14
+ * "Bearer " and nothing after it.
15
+ *
16
+ * THAT IS WORSE THAN THE profile.ts VERSION OF THIS BUG, in two ways:
17
+ *
18
+ * 1. profile.ts at least 401'd when the header was missing. This one has no
19
+ * check at all — `|| ''` swallows the absence — so it proceeds to call
20
+ * upstream with an empty credential and returns the IDP's rejection as an
21
+ * opaque failure. The user sees recovery "not working", not "not
22
+ * authenticated", and the log shows an upstream error rather than an auth
23
+ * one. The defect hides itself.
24
+ * 2. It is BOTH HALVES of account recovery — send-code and verify-code — so
25
+ * the entire recovery path is unreachable, not merely degraded. This is
26
+ * the flow a locked-out user reaches for, i.e. it fails exactly when it is
27
+ * the only thing left that could help them.
28
+ *
29
+ * Fixed the same way as profile.ts and masked-info.ts: idpFetchJSON resolves
30
+ * the caller's token from the server-side session, so the cookie works and the
31
+ * token is never handed to browser JavaScript.
32
+ *
33
+ * @package @payez/next-mvp
34
+ */
2
35
  export declare function POST(req: NextRequest): Promise<NextResponse<any>>;
@@ -2,27 +2,61 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.POST = POST;
4
4
  const server_1 = require("next/server");
5
- function getIdpUrl() {
6
- const url = process.env.IDP_URL;
7
- if (!url)
8
- throw new Error('[IDP_URL] FATAL: IDP_URL environment variable is REQUIRED.');
9
- return url;
10
- }
5
+ const idp_fetch_1 = require("../../../lib/idp-fetch");
6
+ const env_1 = require("../../../config/env");
7
+ /**
8
+ * Account recovery — verify-code
9
+ *
10
+ * WHY THIS WAS REWRITTEN (2026-08-02, swept out of QAPert defect #7)
11
+ * This handler read the caller's bearer straight off the inbound request:
12
+ *
13
+ * const auth = req.headers.get('authorization') || '';
14
+ * const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
15
+ * ... headers: { 'Authorization': `Bearer ${token}` }
16
+ *
17
+ * Browsers send a session COOKIE and no Authorization header, so `token` was
18
+ * ALWAYS the empty string and this always called the IDP with a literal
19
+ * "Bearer " and nothing after it.
20
+ *
21
+ * THAT IS WORSE THAN THE profile.ts VERSION OF THIS BUG, in two ways:
22
+ *
23
+ * 1. profile.ts at least 401'd when the header was missing. This one has no
24
+ * check at all — `|| ''` swallows the absence — so it proceeds to call
25
+ * upstream with an empty credential and returns the IDP's rejection as an
26
+ * opaque failure. The user sees recovery "not working", not "not
27
+ * authenticated", and the log shows an upstream error rather than an auth
28
+ * one. The defect hides itself.
29
+ * 2. It is BOTH HALVES of account recovery — send-code and verify-code — so
30
+ * the entire recovery path is unreachable, not merely degraded. This is
31
+ * the flow a locked-out user reaches for, i.e. it fails exactly when it is
32
+ * the only thing left that could help them.
33
+ *
34
+ * Fixed the same way as profile.ts and masked-info.ts: idpFetchJSON resolves
35
+ * the caller's token from the server-side session, so the cookie works and the
36
+ * token is never handed to browser JavaScript.
37
+ *
38
+ * @package @payez/next-mvp
39
+ */
11
40
  async function POST(req) {
12
- const IDP_URL = getIdpUrl();
41
+ let payload = {};
13
42
  try {
14
- const auth = req.headers.get('authorization') || '';
15
- const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
16
- const { code, method } = await req.json();
17
- const upstream = await fetch(`${IDP_URL}/api/Account/recovery/verify-code`, {
18
- method: 'POST',
19
- headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
20
- body: JSON.stringify({ code, method }),
21
- });
22
- const data = await upstream.json().catch(() => ({}));
23
- return server_1.NextResponse.json(data, { status: upstream.status });
43
+ payload = await req.json();
24
44
  }
25
45
  catch {
26
- return server_1.NextResponse.json({ success: false, error: 'verify_code_failed' }, { status: 500 });
46
+ return server_1.NextResponse.json({ success: false, error: 'invalid_request', message: 'A JSON body is required' }, { status: 400 });
47
+ }
48
+ const result = await (0, idp_fetch_1.idpFetchJSON)(req, `${env_1.ENV_CONFIG.IDP_URL}/api/Account/recovery/verify-code`, {
49
+ method: 'POST',
50
+ headers: { 'Content-Type': 'application/json' },
51
+ body: JSON.stringify(payload),
52
+ });
53
+ if (!result.ok) {
54
+ return server_1.NextResponse.json({
55
+ success: false,
56
+ error: 'verify_code_failed',
57
+ message: result.json?.message || 'Upstream error',
58
+ meta: { status: result.status, attemptedRefresh: result.attemptedRefresh },
59
+ }, { status: result.status });
27
60
  }
61
+ return server_1.NextResponse.json(result.json, { status: 200 });
28
62
  }
@@ -44,6 +44,10 @@ async function POST(req) {
44
44
  // Unwrap IDP envelope
45
45
  const unwrappedData = result.json?.data || result.json;
46
46
  // If we have new tokens, update the session to complete 2FA
47
+ // Reports whether the session ACTUALLY transitioned. Must not be assumed
48
+ // from a 2xx: the IDP can verify the code while we fail to transition
49
+ // (no tokens returned, or no resolvable session token).
50
+ let sessionTransitioned = false;
47
51
  if (unwrappedData.access_token && unwrappedData.refresh_token) {
48
52
  // Get session token from NextAuth
49
53
  // Support both field names: sessionToken (auth.ts JWT) and redisSessionId (legacy)
@@ -81,17 +85,27 @@ async function POST(req) {
81
85
  refreshTokenExpires
82
86
  }, 'email' // Store 2FA method for refresh token flow
83
87
  );
88
+ sessionTransitioned = true;
84
89
  console.log('[VERIFY_EMAIL] Session updated successfully', {
85
90
  accessTokenExpires: new Date(accessTokenExpires).toISOString(),
86
91
  refreshTokenExpires: new Date(refreshTokenExpires).toISOString()
87
92
  });
88
93
  }
94
+ else {
95
+ console.warn('[VERIFY_EMAIL] IDP verified the code but no session token was resolvable — session NOT transitioned');
96
+ }
97
+ }
98
+ else {
99
+ console.warn('[VERIFY_EMAIL] IDP returned 2xx without tokens — session NOT transitioned');
89
100
  }
90
101
  // Return simplified success response (don't expose tokens to client)
91
102
  return server_1.NextResponse.json({
92
103
  success: true,
93
104
  verificationSuccessful: true,
94
- twoFactorSessionVerified: true,
105
+ // NOT a copy of `success` — reports what actually happened. A client that
106
+ // trusted a hardcoded true would show the user as MFA-verified while the
107
+ // session still says otherwise.
108
+ twoFactorSessionVerified: sessionTransitioned,
95
109
  message: unwrappedData.message || 'Email code verified successfully'
96
110
  }, { status: 200 });
97
111
  }
@@ -44,6 +44,10 @@ async function POST(req) {
44
44
  // Unwrap IDP envelope
45
45
  const unwrappedData = result.json?.data || result.json;
46
46
  // If we have new tokens, update the session to complete 2FA
47
+ // Reports whether the session ACTUALLY transitioned. Must not be assumed
48
+ // from a 2xx: the IDP can verify the code while we fail to transition
49
+ // (no tokens returned, or no resolvable session token).
50
+ let sessionTransitioned = false;
47
51
  if (unwrappedData.access_token && unwrappedData.refresh_token) {
48
52
  // Get session token from NextAuth
49
53
  // Support both field names: sessionToken (auth.ts JWT) and redisSessionId (legacy)
@@ -81,17 +85,27 @@ async function POST(req) {
81
85
  refreshTokenExpires
82
86
  }, 'sms' // Store 2FA method for refresh token flow
83
87
  );
88
+ sessionTransitioned = true;
84
89
  console.log('[VERIFY_SMS] Session updated successfully', {
85
90
  accessTokenExpires: new Date(accessTokenExpires).toISOString(),
86
91
  refreshTokenExpires: new Date(refreshTokenExpires).toISOString()
87
92
  });
88
93
  }
94
+ else {
95
+ console.warn('[VERIFY_SMS] IDP verified the code but no session token was resolvable — session NOT transitioned');
96
+ }
97
+ }
98
+ else {
99
+ console.warn('[VERIFY_SMS] IDP returned 2xx without tokens — session NOT transitioned');
89
100
  }
90
101
  // Return simplified success response (don't expose tokens to client)
91
102
  return server_1.NextResponse.json({
92
103
  success: true,
93
104
  verificationSuccessful: true,
94
- twoFactorSessionVerified: true,
105
+ // NOT a copy of `success` — reports what actually happened. A client that
106
+ // trusted a hardcoded true would show the user as MFA-verified while the
107
+ // session still says otherwise.
108
+ twoFactorSessionVerified: sessionTransitioned,
95
109
  message: unwrappedData.message || 'SMS code verified successfully'
96
110
  }, { status: 200 });
97
111
  }
@@ -0,0 +1,29 @@
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ export declare function optionsPOST(req: NextRequest): Promise<NextResponse<any>>;
3
+ /**
4
+ * VERIFY DOES NOT USE anonCall — IT MUST RESHAPE, NOT SPREAD.
5
+ *
6
+ * The IDP contract is three fields (PasskeyModels.cs:36 PasskeyAuthVerifyRequest):
7
+ * assertion_id : string
8
+ * response : the WHOLE AuthenticatorAssertionRawResponse from
9
+ * navigator.credentials.get()
10
+ * client_id : string
11
+ *
12
+ * anonCall SPREADS the caller's body (`{ client_id, ...parsed }`). For options
13
+ * that is harmless. For verify it is fatal: spreading an assertion hoists
14
+ * id/rawId/type to the top level and leaves `response` holding only the INNER
15
+ * {clientDataJSON, authenticatorData, signature, userHandle}. The IDP's guard
16
+ * then answers "response is required" in 4-12ms WITHOUT REACHING THE CRYPTO —
17
+ * a timing signature DotNetPert used to prove the caller was still spreading
18
+ * long after the graft-side plugin had been fixed.
19
+ *
20
+ * This route and the Better Auth plugin endpoint are TWO PATHS to the same IDP
21
+ * endpoint. Fixing only the plugin left this one live, spreading, and reachable
22
+ * on a URL one segment away from the correct one — which is what kept the
23
+ * sign-in leg red across several runs.
24
+ *
25
+ * Accepts the assertion either already nested (`{ assertion_id, response }`) or
26
+ * flat (`{ assertion_id, id, rawId, type, response }`) and normalises both, so a
27
+ * caller written against either shape works.
28
+ */
29
+ export declare function verifyPOST(req: NextRequest): Promise<NextResponse<any>>;
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.optionsPOST = optionsPOST;
4
+ exports.verifyPOST = verifyPOST;
5
+ const server_1 = require("next/server");
6
+ const env_1 = require("../../config/env");
7
+ const idp_client_config_1 = require("../../lib/idp-client-config");
8
+ /**
9
+ * Passkey SIGN-IN ceremony (ANONYMOUS — no session exists yet):
10
+ * optionsPOST -> POST /api/ExternalAuth/passwordless/passkey/options
11
+ * verifyPOST -> POST /api/ExternalAuth/passwordless/passkey/verify
12
+ *
13
+ * Anon idiom copied from auth/better-auth.ts (exchangeOAuthForIdpTokens):
14
+ * direct fetch to the IDP, client identified by client_id from the resolved
15
+ * IDP client config (slug preferred, numeric id fallback) — sent BOTH as the
16
+ * X-Client-Id header (the probe-verified routing key) and merged into the
17
+ * body when the caller did not supply one.
18
+ *
19
+ * The verify response is passed through WITHOUT unwrapping: this is a
20
+ * sign-in, its payload carries the grant the caller's session layer consumes.
21
+ */
22
+ async function clientKey() {
23
+ const config = await (0, idp_client_config_1.getIDPClientConfig)();
24
+ return config.clientSlug || String(config.clientId);
25
+ }
26
+ async function anonCall(req, path) {
27
+ let parsed = {};
28
+ try {
29
+ const raw = await req.text();
30
+ if (raw && raw.trim().length > 0)
31
+ parsed = JSON.parse(raw);
32
+ }
33
+ catch { }
34
+ const key = await clientKey();
35
+ const res = await fetch(`${env_1.ENV_CONFIG.IDP_URL}${path}`, {
36
+ method: 'POST',
37
+ headers: { 'Content-Type': 'application/json', 'X-Client-Id': key },
38
+ body: JSON.stringify({ client_id: key, ...parsed }),
39
+ cache: 'no-store',
40
+ });
41
+ const json = await res.json().catch(() => null);
42
+ return { res, json };
43
+ }
44
+ async function optionsPOST(req) {
45
+ const { res, json } = await anonCall(req, '/api/ExternalAuth/passwordless/passkey/options');
46
+ return server_1.NextResponse.json(json ?? { success: false, message: 'Empty upstream response' }, { status: res.status });
47
+ }
48
+ /**
49
+ * VERIFY DOES NOT USE anonCall — IT MUST RESHAPE, NOT SPREAD.
50
+ *
51
+ * The IDP contract is three fields (PasskeyModels.cs:36 PasskeyAuthVerifyRequest):
52
+ * assertion_id : string
53
+ * response : the WHOLE AuthenticatorAssertionRawResponse from
54
+ * navigator.credentials.get()
55
+ * client_id : string
56
+ *
57
+ * anonCall SPREADS the caller's body (`{ client_id, ...parsed }`). For options
58
+ * that is harmless. For verify it is fatal: spreading an assertion hoists
59
+ * id/rawId/type to the top level and leaves `response` holding only the INNER
60
+ * {clientDataJSON, authenticatorData, signature, userHandle}. The IDP's guard
61
+ * then answers "response is required" in 4-12ms WITHOUT REACHING THE CRYPTO —
62
+ * a timing signature DotNetPert used to prove the caller was still spreading
63
+ * long after the graft-side plugin had been fixed.
64
+ *
65
+ * This route and the Better Auth plugin endpoint are TWO PATHS to the same IDP
66
+ * endpoint. Fixing only the plugin left this one live, spreading, and reachable
67
+ * on a URL one segment away from the correct one — which is what kept the
68
+ * sign-in leg red across several runs.
69
+ *
70
+ * Accepts the assertion either already nested (`{ assertion_id, response }`) or
71
+ * flat (`{ assertion_id, id, rawId, type, response }`) and normalises both, so a
72
+ * caller written against either shape works.
73
+ */
74
+ async function verifyPOST(req) {
75
+ let parsed = {};
76
+ try {
77
+ const raw = await req.text();
78
+ if (raw && raw.trim().length > 0)
79
+ parsed = JSON.parse(raw);
80
+ }
81
+ catch { /* empty body -> the IDP's own validation answers */ }
82
+ const { assertion_id: assertionId, response, ...rest } = parsed;
83
+ // Already-nested callers send `response` holding a full assertion (it has an
84
+ // `id`). Flat callers send the assertion's own fields alongside assertion_id,
85
+ // and their `response` is the inner attestation half — not a whole assertion.
86
+ const nested = response && typeof response === 'object' && 'id' in response;
87
+ const assertion = nested ? response : { ...rest, response };
88
+ const key = await clientKey();
89
+ const res = await fetch(`${env_1.ENV_CONFIG.IDP_URL}/api/ExternalAuth/passwordless/passkey/verify`, {
90
+ method: 'POST',
91
+ headers: { 'Content-Type': 'application/json', 'X-Client-Id': key },
92
+ body: JSON.stringify({ client_id: key, assertion_id: assertionId, response: assertion }),
93
+ cache: 'no-store',
94
+ });
95
+ const json = await res.json().catch(() => null);
96
+ return server_1.NextResponse.json(json ?? { success: false, message: 'Empty upstream response' }, { status: res.status });
97
+ }