@payez/next-mvp 4.5.7 → 4.7.9

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 (59) hide show
  1. package/dist/api-handlers/auth/credential-login-email-code.d.ts +24 -0
  2. package/dist/api-handlers/auth/credential-login-email-code.js +83 -0
  3. package/dist/api-handlers/auth/credential-login.d.ts +41 -0
  4. package/dist/api-handlers/auth/credential-login.js +858 -0
  5. package/dist/api-handlers/oauth/resume.d.ts +16 -0
  6. package/dist/api-handlers/oauth/resume.js +207 -0
  7. package/dist/auth/better-auth.d.ts +14 -2
  8. package/dist/auth/better-auth.js +59 -34
  9. package/dist/auth/idp-oauth.d.ts +106 -0
  10. package/dist/auth/idp-oauth.js +280 -0
  11. package/dist/auth/safe-callback.js +10 -2
  12. package/dist/components/auth/CredentialSignInForm.d.ts +33 -0
  13. package/dist/components/auth/CredentialSignInForm.js +134 -0
  14. package/dist/lib/authorize-resume.d.ts +87 -0
  15. package/dist/lib/authorize-resume.js +190 -0
  16. package/dist/lib/bearer-fetch.d.ts +17 -0
  17. package/dist/lib/bearer-fetch.js +32 -0
  18. package/dist/lib/code-step-copy.d.ts +53 -0
  19. package/dist/lib/code-step-copy.js +73 -0
  20. package/dist/lib/credential-sign-in-client.d.ts +59 -0
  21. package/dist/lib/credential-sign-in-client.js +89 -0
  22. package/dist/lib/device-id.d.ts +37 -0
  23. package/dist/lib/device-id.js +70 -0
  24. package/dist/lib/lock-window.d.ts +29 -0
  25. package/dist/lib/lock-window.js +53 -0
  26. package/dist/lib/login-error-message.d.ts +18 -0
  27. package/dist/lib/login-error-message.js +47 -0
  28. package/dist/lib/twofa-rejection.d.ts +20 -0
  29. package/dist/lib/twofa-rejection.js +73 -0
  30. package/dist/middleware/create-middleware.d.ts +12 -1
  31. package/dist/middleware/create-middleware.js +19 -5
  32. package/dist/pages/login/page.js +11 -37
  33. package/dist/routes/auth/credential-login-email-code.d.ts +6 -0
  34. package/dist/routes/auth/credential-login-email-code.js +10 -0
  35. package/dist/routes/auth/credential-login.d.ts +6 -0
  36. package/dist/routes/auth/credential-login.js +10 -0
  37. package/dist/routes/oauth/resume.d.ts +11 -0
  38. package/dist/routes/oauth/resume.js +16 -0
  39. package/package.json +1102 -1047
  40. package/src/api-handlers/auth/credential-login-email-code.ts +92 -0
  41. package/src/api-handlers/auth/credential-login.ts +948 -0
  42. package/src/api-handlers/oauth/resume.ts +182 -0
  43. package/src/auth/better-auth.ts +81 -5
  44. package/src/auth/idp-oauth.ts +310 -0
  45. package/src/auth/safe-callback.ts +11 -2
  46. package/src/components/auth/CredentialSignInForm.tsx +240 -0
  47. package/src/lib/authorize-resume.ts +187 -0
  48. package/src/lib/bearer-fetch.ts +31 -0
  49. package/src/lib/code-step-copy.ts +83 -0
  50. package/src/lib/credential-sign-in-client.ts +101 -0
  51. package/src/lib/device-id.ts +67 -0
  52. package/src/lib/lock-window.ts +49 -0
  53. package/src/lib/login-error-message.ts +41 -0
  54. package/src/lib/twofa-rejection.ts +91 -0
  55. package/src/middleware/create-middleware.ts +35 -4
  56. package/src/pages/login/page.tsx +20 -34
  57. package/src/routes/auth/credential-login-email-code.ts +6 -0
  58. package/src/routes/auth/credential-login.ts +6 -0
  59. package/src/routes/oauth/resume.ts +11 -0
@@ -0,0 +1,16 @@
1
+ export declare const DEFAULT_REFUSAL_LOCATION = "/account-auth/login?error=AUTHORIZE_RESUME_REFUSED";
2
+ export interface OAuthResumeDeps {
3
+ /** Test seam. Default: global fetch. */
4
+ fetch?: typeof fetch;
5
+ /** Test seam. Default: the signed-in app session's IdP access token (one refresh attempt when near expiry), else null. */
6
+ resolveAccessToken?: (request: Request) => Promise<string | null>;
7
+ /** Test seam. Default: BETTER_AUTH_URL, else the IdP client config's baseClientUrl, else http://localhost:$PORT (the SAME chain the provider config uses). */
8
+ appBaseUrl?: () => Promise<string>;
9
+ }
10
+ export interface OAuthResumeOptions {
11
+ /** Same-origin location a refused request is sent to. Must start with a single "/" (no //host, no backslash, no scheme). */
12
+ refusalLocation?: string;
13
+ deps?: OAuthResumeDeps;
14
+ }
15
+ export declare function createOAuthResumeHandler(options?: OAuthResumeOptions): (request: Request) => Promise<Response>;
16
+ export declare const GET: (request: Request) => Promise<Response>;
@@ -0,0 +1,207 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.GET = exports.DEFAULT_REFUSAL_LOCATION = void 0;
37
+ exports.createOAuthResumeHandler = createOAuthResumeHandler;
38
+ /**
39
+ * GET /api/oauth/resume?return_url=<IdP authorize request> - moved from VibeID-Customer-Portal src/app/api/oauth/resume/route.ts
40
+ * (PAY-1500), PAY-2302 cut 2.
41
+ *
42
+ * The only place an absolute post-2FA destination is followed, and only after checking it is this deployment's IdP
43
+ * authorize endpoint (lib/authorize-resume). Everything else is refused to a same-origin page: this route must never
44
+ * become an open redirect.
45
+ *
46
+ * The validation base is the app's configured IDP_URL, never anything derived from the request. If IDP_URL is unset
47
+ * or not a URL the answer is the refusal, not a throw and not a fallback to the request's own origin (fail closed).
48
+ * INTERNAL_IDP_URL is deliberately NOT used: the authorize URL is the PUBLIC IdP's (it is also what the registered client uses).
49
+ *
50
+ * PAY-2319 (4.7.1): WHERE a refusal goes is configurable and the default is the login page every generated app has
51
+ * (`/account-auth/login?error=AUTHORIZE_RESUME_REFUSED`). 4.7.0 hard-coded the portal's `/sign-in`, which a generated app does not
52
+ * have: the middleware forwarded it to the login page with the error code dropped and, after sign-in, to a 404.
53
+ * An app (the portal has /sign-in) passes its own location through createOAuthResumeHandler.
54
+ *
55
+ * 4.7.5 (PAY-2305): THE AUTHORIZE CALL IS MADE HERE, SERVER-SIDE. Core's /api/oauth/authorize identifies a user only from an
56
+ * `Authorization: Bearer` header (JwtMiddleware reads no cookie), so sending the BROWSER to it (4.7.0-4.7.4) could never produce a
57
+ * code. This route now
58
+ * 1. validates return_url and REBUILDS the authorize URL from constants (IDP_URL origin, fixed path, this app's CLIENT_ID and
59
+ * registered redirect_uri); only code_challenge (S256), state and scope come from return_url, each validated;
60
+ * 2. requires a same-site navigation, a browser that STARTED the flow (better-auth's oauth_state cookie) and the signed-in app session;
61
+ * 3. calls authorize with the user's IdP access token (redirect: 'manual', timeout; the token goes only to the IDP_URL origin and
62
+ * is never logged);
63
+ * 4. accepts ONLY a redirect to the registered redirect_uri carrying a code and the state it sent, and relays it to the browser
64
+ * as a RELATIVE path+query rebuilt from those two values.
65
+ * Anything else (IdP sends the user to login / 2FA enrolment / an error, no session, refresh fails, IdP unreachable) is the
66
+ * refusal. Nothing is retried or followed.
67
+ */
68
+ const authorize_resume_1 = require("../../lib/authorize-resume");
69
+ const idp_oauth_1 = require("../../auth/idp-oauth");
70
+ exports.DEFAULT_REFUSAL_LOCATION = '/account-auth/login?error=AUTHORIZE_RESUME_REFUSED';
71
+ /** A relative, same-origin location only: the refusal must never itself become an open redirect. */
72
+ function assertRelativeLocation(loc) {
73
+ const ok = typeof loc === 'string' && loc.startsWith('/') && !loc.startsWith('//') && !loc.startsWith('/\\') && !/\s/.test(loc);
74
+ if (!ok) {
75
+ throw new TypeError(`createOAuthResumeHandler: refusalLocation must be a same-origin path starting with a single "/", got ${JSON.stringify(loc)}`);
76
+ }
77
+ return loc;
78
+ }
79
+ const AUTHORIZE_TIMEOUT_MS = 8000;
80
+ /** Cookie header -> the `{ get(name) }` shape decodeSession wants (NextRequest.cookies). */
81
+ function cookieJar(header) {
82
+ const map = new Map();
83
+ for (const part of (header ?? '').split(';')) {
84
+ const i = part.indexOf('=');
85
+ if (i < 1)
86
+ continue;
87
+ const name = part.slice(0, i).trim();
88
+ let value = part.slice(i + 1).trim();
89
+ try {
90
+ value = decodeURIComponent(value);
91
+ }
92
+ catch {
93
+ /* keep the raw value */
94
+ }
95
+ if (!map.has(name))
96
+ map.set(name, value);
97
+ }
98
+ return { get: (name) => (map.has(name) ? { value: map.get(name) } : undefined) };
99
+ }
100
+ async function defaultResolveAccessToken(request) {
101
+ const { decodeSession } = await Promise.resolve().then(() => __importStar(require('../../server/decode-session')));
102
+ const decoded = await decodeSession(cookieJar(request.headers.get('cookie')));
103
+ const sessionToken = (decoded?.jwtPayload?.sessionToken || decoded?.jwtPayload?.redisSessionId);
104
+ if (!sessionToken)
105
+ return null;
106
+ const { ensureFreshAccessToken } = await Promise.resolve().then(() => __importStar(require('../../lib/ensure-fresh-access-token')));
107
+ const fresh = await ensureFreshAccessToken(sessionToken, {
108
+ idpBaseUrl: process.env.IDP_URL,
109
+ clientId: process.env.CLIENT_ID,
110
+ });
111
+ return fresh.ok ? fresh.accessToken : null;
112
+ }
113
+ async function defaultAppBaseUrl() {
114
+ if (process.env.BETTER_AUTH_URL)
115
+ return process.env.BETTER_AUTH_URL;
116
+ try {
117
+ const { getIDPClientConfig } = await Promise.resolve().then(() => __importStar(require('../../lib/idp-client-config')));
118
+ const cfg = await getIDPClientConfig();
119
+ if (cfg?.baseClientUrl)
120
+ return cfg.baseClientUrl;
121
+ }
122
+ catch {
123
+ /* fall through to the local default, exactly as createBetterAuthInstance does */
124
+ }
125
+ return `http://localhost:${process.env.PORT || '3000'}`;
126
+ }
127
+ function createOAuthResumeHandler(options = {}) {
128
+ const refusalLocation = assertRelativeLocation(options.refusalLocation ?? exports.DEFAULT_REFUSAL_LOCATION);
129
+ const fetchFn = options.deps?.fetch ?? ((...a) => fetch(...a));
130
+ const resolveAccessToken = options.deps?.resolveAccessToken ?? defaultResolveAccessToken;
131
+ const appBaseUrl = options.deps?.appBaseUrl ?? defaultAppBaseUrl;
132
+ // RELATIVE Location on purpose: in a container request.url is the bind address (http://0.0.0.0:8080), so
133
+ // new URL(path, request.url) would send the browser to a dead host (kb 3463/5571; QAPert 59254 F1).
134
+ const refuse = (why) => {
135
+ console.warn(`[oauth-resume] AUTHORIZE_RESUME outcome=refused reason=${why}`);
136
+ return new Response(null, { status: 302, headers: { location: refusalLocation, 'cache-control': 'no-store' } });
137
+ };
138
+ return async function GET(request) {
139
+ const idpBase = process.env.IDP_URL;
140
+ const clientId = process.env.CLIENT_ID;
141
+ if (!idpBase || !clientId)
142
+ return refuse('not-configured');
143
+ let redirectUri;
144
+ try {
145
+ redirectUri = (0, idp_oauth_1.resolveIdpRedirectUri)(await appBaseUrl());
146
+ }
147
+ catch {
148
+ return refuse('no-app-base');
149
+ }
150
+ const built = (0, authorize_resume_1.buildServerAuthorizeRequest)(new URL(request.url).searchParams.get('return_url'), {
151
+ idpBaseUrl: idpBase,
152
+ clientId,
153
+ redirectUri,
154
+ });
155
+ if (!built)
156
+ return refuse('return_url');
157
+ let appOrigin;
158
+ try {
159
+ appOrigin = new URL(redirectUri).origin;
160
+ }
161
+ catch {
162
+ return refuse('bad-redirect-uri');
163
+ }
164
+ if (!(0, authorize_resume_1.isSameSiteResume)(request.headers, appOrigin))
165
+ return refuse('not-same-site');
166
+ if (!(0, authorize_resume_1.hasOAuthStateCookie)(request.headers.get('cookie')))
167
+ return refuse('no-oauth-state'); // this browser never started the flow
168
+ let accessToken = null;
169
+ try {
170
+ accessToken = await resolveAccessToken(request);
171
+ }
172
+ catch {
173
+ accessToken = null;
174
+ }
175
+ if (!accessToken)
176
+ return refuse('no-session');
177
+ const forwarded = {};
178
+ const ua = request.headers.get('user-agent');
179
+ if (ua)
180
+ forwarded['User-Agent'] = ua;
181
+ const xff = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip');
182
+ if (xff)
183
+ forwarded['X-Forwarded-For'] = xff;
184
+ const device = request.headers.get('x-device-id');
185
+ if (device)
186
+ forwarded['X-Device-ID'] = device;
187
+ let res;
188
+ try {
189
+ res = await fetchFn(built.url, {
190
+ method: 'GET',
191
+ headers: { ...forwarded, Accept: 'application/json', Authorization: `Bearer ${accessToken}` },
192
+ redirect: 'manual', // the Bearer header is never carried to a second URL
193
+ signal: AbortSignal.timeout(AUTHORIZE_TIMEOUT_MS),
194
+ });
195
+ }
196
+ catch {
197
+ return refuse('idp-unreachable');
198
+ }
199
+ const relay = (0, authorize_resume_1.relayRegisteredCallback)(res.status, res.headers.get('location'), redirectUri, built.state);
200
+ // Status only in the log: the Location can carry a code or a return_url.
201
+ if (!relay)
202
+ return refuse(`idp-answered-${res.status}`);
203
+ console.log('[oauth-resume] AUTHORIZE_RESUME outcome=code-issued');
204
+ return new Response(null, { status: 302, headers: { location: relay, 'cache-control': 'no-store' } });
205
+ };
206
+ }
207
+ exports.GET = createOAuthResumeHandler();
@@ -11,6 +11,7 @@
11
11
  import 'server-only';
12
12
  import { betterAuth } from 'better-auth';
13
13
  import { type MagicLinkOptions } from 'better-auth/plugins/magic-link';
14
+ import { createIdpOAuthHooks } from './idp-oauth';
14
15
  import type { IDPClientConfig } from '../lib/idp-client-config';
15
16
  /**
16
17
  * 185121: short sha256 fingerprint for secrets/tokens in log lines — enough
@@ -67,13 +68,18 @@ export interface BetterAuthSocialProvider {
67
68
  * exchange must verify signature/iss/aud/exp itself and preserve those four, or
68
69
  * they are lost silently.
69
70
  */
70
- export declare function buildIdpOAuthProviderConfig(): {
71
+ export interface IdpOAuthProviderConfig {
71
72
  providerId: string;
72
73
  discoveryUrl: string;
73
74
  clientId: string;
74
75
  pkce: true;
75
76
  scopes: string[];
76
- } | null;
77
+ /** PAY-2305: the REGISTERED {site_url}/api/auth/callback, sent identically at authorize and token time. */
78
+ redirectURI: string;
79
+ getToken: ReturnType<typeof createIdpOAuthHooks>['getToken'];
80
+ getUserInfo: ReturnType<typeof createIdpOAuthHooks>['getUserInfo'];
81
+ }
82
+ export declare function buildIdpOAuthProviderConfig(appBaseUrl?: string): IdpOAuthProviderConfig | null;
77
83
  export declare function buildBetterAuthProviders(config: IDPClientConfig): Record<string, BetterAuthSocialProvider>;
78
84
  /**
79
85
  * Optional configuration for `createBetterAuthInstance`.
@@ -594,6 +600,12 @@ export declare function storeIdpTokensInSession(sessionToken: string, idpTokenDa
594
600
  * Try again or use another method." The cause stays in the server log, never on the page.
595
601
  */
596
602
  export declare const OAUTH_SIGN_IN_FAILED_ERROR = "OAUTH_SIGN_IN_FAILED";
603
+ /**
604
+ * PAY-2305: store the tokens the payez-idp provider hooks captured during THIS request. Fail closed: no captured tokens or no
605
+ * verified claims (getUserInfo refused the token) => false, and the caller aborts the sign-in. nextStep is left undefined on
606
+ * purpose (unknown is not "fully authenticated", see 141688); mfaVerified comes only from the verified token's amr.
607
+ */
608
+ export declare function storePayezIdpSession(sessionToken: string): Promise<boolean>;
597
609
  /**
598
610
  * The ONLY IdP refusal codes the package forwards to the login page (PAY-2005 (b), BAPert 67897).
599
611
  *
@@ -55,6 +55,7 @@ exports.getBetterAuthInstance = getBetterAuthInstance;
55
55
  exports.getBetterAuthHandler = getBetterAuthHandler;
56
56
  exports.exchangeOAuthForIdpTokens = exchangeOAuthForIdpTokens;
57
57
  exports.storeIdpTokensInSession = storeIdpTokensInSession;
58
+ exports.storePayezIdpSession = storePayezIdpSession;
58
59
  exports.expireSetCookies = expireSetCookies;
59
60
  exports.createAuthGetHandler = createAuthGetHandler;
60
61
  require("server-only");
@@ -68,6 +69,7 @@ const session_response_sanitizer_1 = require("./session-response-sanitizer");
68
69
  const magic_link_1 = require("better-auth/plugins/magic-link");
69
70
  const client_key_1 = require("../lib/client-key");
70
71
  const generic_oauth_1 = require("better-auth/plugins/generic-oauth");
72
+ const idp_oauth_1 = require("./idp-oauth");
71
73
  const idp_client_config_1 = require("../lib/idp-client-config");
72
74
  const app_slug_1 = require("../lib/app-slug");
73
75
  const redis_1 = require("../lib/redis");
@@ -170,46 +172,38 @@ const ID_TOKEN_SCOPE_KEY = '__payezIdTokenScope';
170
172
  const gScope = globalThis;
171
173
  gScope[ID_TOKEN_SCOPE_KEY] ??= new node_async_hooks_1.AsyncLocalStorage();
172
174
  const idTokenScope = gScope[ID_TOKEN_SCOPE_KEY];
173
- /**
174
- * EXCHANGE-MOVE (175047): route OAuth through OUR IDP instead of holding
175
- * provider credentials in the app.
176
- *
177
- * ARCHITECTURE LOCKED (BAPert 21318): genericOAuth pointed at the IDP - NOT at
178
- * Google - which is simultaneously pkce + no-secret AND route-through-IDP, and
179
- * is uniform with the ratified desktop leg. The IDP holds the provider secret
180
- * and performs the provider exchange server-side.
181
- *
182
- * ── CUSTOM SHAPE, NEVER A PROVIDER PRESET ───────────────────────────────────
183
- * QAPert-NightHawk 21304: better-auth's provider-PRESET shape makes
184
- * clientSecret REQUIRED again. Same plugin, two config shapes, opposite answers
185
- * on whether we need a secret. This builds the generic/custom shape
186
- * deliberately; reaching for a preset because it looks tidier would silently
187
- * kill keyless-on-web and nobody would connect the two changes.
188
- *
189
- * ── OPT-IN, AND NOT A FALLBACK ──────────────────────────────────────────────
190
- * Returns null unless the IDP's OAuth endpoints are explicitly configured. That
191
- * makes this ADDITIVE: the existing socialProviders path keeps working until an
192
- * operator turns this on. It is NOT a runtime failover - nothing silently
193
- * chooses between them, because a silent chooser is how a migration ends up
194
- * half-applied and undetectable.
195
- *
196
- * ⚠ WHAT MUST MOVE TO THE IDP WITH THIS (flagged in 21317, not solvable here):
197
- * the app stops talking to Google, so it can no longer capture the raw
198
- * id_token that oauth-callback verifies today, and the per-provider settings
199
- * below stop being ours to set - profile scope, prompt=select_account,
200
- * accessType, and hd (hosted-domain, a SECURITY control). The IDP's own
201
- * exchange must verify signature/iss/aud/exp itself and preserve those four, or
202
- * they are lost silently.
203
- */
204
- function buildIdpOAuthProviderConfig() {
175
+ function buildIdpOAuthProviderConfig(appBaseUrl) {
205
176
  const discoveryUrl = process.env.IDP_OAUTH_DISCOVERY_URL;
206
177
  const clientId = process.env.CLIENT_ID;
207
178
  if (!discoveryUrl || !clientId)
208
179
  return null;
180
+ const redirectURI = (0, idp_oauth_1.resolveIdpRedirectUri)(appBaseUrl || process.env.BETTER_AUTH_URL || process.env.APP_URL || `http://localhost:${process.env.PORT || '3000'}`);
181
+ const hooks = (0, idp_oauth_1.createIdpOAuthHooks)({
182
+ discoveryUrl,
183
+ clientId,
184
+ redirectURI,
185
+ issuer: process.env.IDP_OAUTH_ISSUER || undefined,
186
+ trustedOrigins: process.env.IDP_OAUTH_TRUSTED_ORIGINS
187
+ ? process.env.IDP_OAUTH_TRUSTED_ORIGINS.split(',').map((x) => x.trim()).filter(Boolean)
188
+ : undefined,
189
+ onTokens: (tokens) => {
190
+ const store = idTokenScope.getStore();
191
+ if (store)
192
+ (store.idp ??= {}).tokens = tokens;
193
+ },
194
+ onClaims: (claims) => {
195
+ const store = idTokenScope.getStore();
196
+ if (store)
197
+ (store.idp ??= {}).claims = claims;
198
+ },
199
+ });
209
200
  return {
210
201
  providerId: 'payez-idp',
211
202
  discoveryUrl,
212
203
  clientId,
204
+ redirectURI,
205
+ getToken: hooks.getToken,
206
+ getUserInfo: hooks.getUserInfo,
213
207
  // NO clientSecret. The whole point: this app is not a credential holder.
214
208
  pkce: true,
215
209
  scopes: ['openid', 'email', 'profile'],
@@ -507,7 +501,7 @@ function createBetterAuthInstance(idpConfig, opts = {}) {
507
501
  || idpConfig.baseClientUrl
508
502
  || `http://localhost:${process.env.PORT || '3000'}`;
509
503
  const baseURL = rawBaseURL.replace(/\/+$/, '') + '/api/auth';
510
- const idpOAuth = buildIdpOAuthProviderConfig();
504
+ const idpOAuth = buildIdpOAuthProviderConfig(rawBaseURL);
511
505
  return (0, better_auth_1.betterAuth)({
512
506
  baseURL,
513
507
  secret: idpConfig.authSecret,
@@ -1286,6 +1280,33 @@ async function storeIdpTokensInSession(sessionToken, idpTokenData, authProvider)
1286
1280
  * Try again or use another method." The cause stays in the server log, never on the page.
1287
1281
  */
1288
1282
  exports.OAUTH_SIGN_IN_FAILED_ERROR = 'OAUTH_SIGN_IN_FAILED';
1283
+ /**
1284
+ * PAY-2305: store the tokens the payez-idp provider hooks captured during THIS request. Fail closed: no captured tokens or no
1285
+ * verified claims (getUserInfo refused the token) => false, and the caller aborts the sign-in. nextStep is left undefined on
1286
+ * purpose (unknown is not "fully authenticated", see 141688); mfaVerified comes only from the verified token's amr.
1287
+ */
1288
+ async function storePayezIdpSession(sessionToken) {
1289
+ const captured = idTokenScope.getStore()?.idp;
1290
+ if (!captured?.tokens?.accessToken || !captured.claims) {
1291
+ console.warn('[BETTER_AUTH] payez-idp callback: no verified IdP tokens captured for this request');
1292
+ return false;
1293
+ }
1294
+ const { tokens, claims } = captured;
1295
+ const stored = await storeIdpTokensInSession(sessionToken, {
1296
+ idpAccessToken: tokens.accessToken,
1297
+ idpRefreshToken: tokens.refreshToken,
1298
+ idpAccessTokenExpires: tokens.accessTokenExpires ?? Date.now() + 15 * 60 * 1000,
1299
+ userId: String(claims.userId ?? claims.sub),
1300
+ email: claims.email,
1301
+ name: claims.name,
1302
+ roles: claims.roles,
1303
+ mfaVerified: claims.mfaVerified,
1304
+ nextStep: undefined,
1305
+ }, 'payez-idp');
1306
+ if (stored)
1307
+ console.log('[BETTER_AUTH] IdP tokens stored in session for', claims.email);
1308
+ return stored;
1309
+ }
1289
1310
  /**
1290
1311
  * The ONLY IdP refusal codes the package forwards to the login page (PAY-2005 (b), BAPert 67897).
1291
1312
  *
@@ -1451,7 +1472,11 @@ function createAuthGetHandler(loginPath = '/account-auth/login') {
1451
1472
  if (session?.session?.token) {
1452
1473
  sessionToken = session.session.token;
1453
1474
  console.log('[BETTER_AUTH] Got session token from callback:', (0, token_log_id_1.tokenLogId)(session.session.token), '| email:', session.user?.email);
1454
- exchange = await exchangeOAuthForIdpTokens(session.session.token);
1475
+ // PAY-2305: the payez-idp callback already holds an IdP-ISSUED bearer (verified in getUserInfo). It must NOT go
1476
+ // through oauth-callback, the asserted-email path (card 138635), and storing it is what the passkey path does.
1477
+ exchange = url.pathname.endsWith('/callback/payez-idp')
1478
+ ? await storePayezIdpSession(session.session.token)
1479
+ : await exchangeOAuthForIdpTokens(session.session.token);
1455
1480
  }
1456
1481
  else {
1457
1482
  console.warn('[BETTER_AUTH] Could not get session after OAuth callback');
@@ -0,0 +1,106 @@
1
+ /**
2
+ * PAY-2305 (next-mvp 4.7.4): the two hooks that let better-auth's genericOAuth talk to THIS IdP.
3
+ *
4
+ * WHY THEY EXIST (source read, PayEz-Core master d53f192af, BAPert 74014):
5
+ * - POST /api/oauth/token takes a JSON body (snake_case), not better-auth's default form body, and answers with the
6
+ * Core response envelope { success, data: { access_token, refresh_token, token_type, expires_in, user } }.
7
+ * better-auth's default exchange reads top-level fields and would find nothing.
8
+ * - There is NO id_token and NO userinfo endpoint (deliberate, PAY-1585). The only proof of who signed in is the
9
+ * access token itself: an RS-family bearer JWT (RS512 on dev-93's JWKS, measured) signed by the IdP bearer key (jwks_uri in discovery) with sub, email,
10
+ * roles, amr, token_type.
11
+ *
12
+ * TRUST RULE: a claim is read ONLY from a token whose signature, issuer, expiry and token_type were verified here.
13
+ * Never base64-decode and believe. Only RSA signatures are accepted (the RS and PS families; the JWKS key's own declared alg is binding), so a token
14
+ * re-signed with HS256 using the public key as the secret, or alg none, is refused. The `aud` of an IdP bearer is the IdP's public constant, not this client, so it is not a
15
+ * binding; the binding to THIS client is the authorization code + PKCE verifier + exact redirect_uri that Core checks.
16
+ *
17
+ * The redirect_uri sent here MUST be byte-identical at the authorize step and the token step (Core compares it
18
+ * Ordinal at the token step) and equal to the registered {site_url}/api/auth/callback (Core compares it exactly,
19
+ * case-insensitively, at the authorize step). Both come from ONE value: the `redirectURI` of the provider config.
20
+ */
21
+ import { type JWTVerifyGetKey } from 'jose';
22
+ export interface IdpTokenGrant {
23
+ accessToken: string;
24
+ refreshToken?: string;
25
+ /** epoch ms */
26
+ accessTokenExpires?: number;
27
+ tokenType?: string;
28
+ /** The `user` object of the IdP response, passed through untouched (shape not contractually fixed). */
29
+ user?: Record<string, unknown>;
30
+ }
31
+ export interface IdpVerifiedClaims {
32
+ sub: string;
33
+ email: string;
34
+ name: string;
35
+ userId?: string;
36
+ roles: string[];
37
+ /**
38
+ * true ONLY when the verified token asserts email_verified === true (or "true"). Never assumed: better-auth links by email
39
+ * from what getUserInfo returns, so an unasserted value must not let an IdP email attach to an existing local account.
40
+ */
41
+ emailVerified: boolean;
42
+ /** true only when the VERIFIED access token's amr contains "mfa" (Core's HasSecondFactor rule). */
43
+ mfaVerified: boolean;
44
+ }
45
+ export interface IdpOAuthHookDeps {
46
+ fetch?: typeof fetch;
47
+ /** Key resolver for a jwks_uri. Default: jose createRemoteJWKSet. Tests inject a local key set. */
48
+ getKeySet?: (jwksUri: string) => JWTVerifyGetKey;
49
+ now?: () => number;
50
+ }
51
+ export interface IdpOAuthHookOptions {
52
+ discoveryUrl: string;
53
+ clientId: string;
54
+ redirectURI: string;
55
+ /** Override for the expected `iss`. Default: the discovery document's `issuer`. */
56
+ issuer?: string;
57
+ /**
58
+ * Extra origins the discovery document may point token_endpoint / jwks_uri at. By default both MUST share the origin of
59
+ * `discoveryUrl`, so a document cannot send the code exchange or the key fetch to another host. Set this only for a
60
+ * split-host deployment (internal discovery URL, public endpoints). Comma-separated env: IDP_OAUTH_TRUSTED_ORIGINS.
61
+ */
62
+ trustedOrigins?: string[];
63
+ onTokens?: (grant: IdpTokenGrant) => void;
64
+ onClaims?: (claims: IdpVerifiedClaims) => void;
65
+ deps?: IdpOAuthHookDeps;
66
+ }
67
+ /** The URI sent at authorize AND token time. Explicit env wins; else the registered {app}/api/auth/callback form. */
68
+ export declare function resolveIdpRedirectUri(appBaseUrl: string, env?: NodeJS.ProcessEnv): string;
69
+ /** amr arrives as an array, a JSON-array string, or a delimited string (Core's own reader accepts all three). */
70
+ export declare function amrIncludesMfa(amr: unknown): boolean;
71
+ /** Max skew accepted on nbf/exp/iat when verifying the IdP access token. */
72
+ export declare const CLOCK_TOLERANCE_SECONDS = 30;
73
+ /**
74
+ * Address shape, a POSITIVE character rule (PAY-2337, Scout 74285): the local part may contain only [A-Za-z0-9] and . _ % + - ' ; the domain only
75
+ * [A-Za-z0-9-] labels joined by dots; one @, <= 254 total, local <= 64, no leading/trailing/double dot in the local part, a dotted domain.
76
+ * Everything else is NOT an address, and in particular a MASK: Core masks data.user.email on purpose (MaskedEmailJsonConverter), today with '*',
77
+ * tomorrow maybe a bullet, an ellipsis or an 'x' run; a blacklist of one glyph would pass the next mask. Not a deliverability check.
78
+ */
79
+ export declare function isAddressShaped(v: unknown): v is string;
80
+ /**
81
+ * The account address (BAPert 74193 / 74196, 4.7.8). Core's code-flow access token has NO email claim; its sub and ws-identity name claim are
82
+ * both user.UserName, which is a LOGIN, not the mailbox: it may be address-shaped and still differ from the account's Email (4.7.7 vetoed on that
83
+ * and refused every such sign-in, NightHawk 74189). Sources: the token RESPONSE's data.user.email (token endpoint, TLS) and/or the signed token's
84
+ * own email claim; if both are present they must be equal. sub / the name claim are NEITHER a source NOR a veto. Not trimmed. Returns '' on any doubt.
85
+ */
86
+ export declare function resolveEmail(payload: Record<string, unknown>, responseEmail?: unknown): string;
87
+ export declare function createIdpOAuthHooks(opts: IdpOAuthHookOptions): {
88
+ getToken: (data: {
89
+ code: string;
90
+ codeVerifier?: string;
91
+ }) => Promise<{
92
+ tokenType: any;
93
+ accessToken: any;
94
+ refreshToken: any;
95
+ accessTokenExpiresAt: Date | undefined;
96
+ scopes: string[];
97
+ }>;
98
+ getUserInfo: (tokens: {
99
+ accessToken?: string;
100
+ }) => Promise<{
101
+ id: string;
102
+ email: string;
103
+ name: string;
104
+ emailVerified: boolean;
105
+ } | null>;
106
+ };