@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,182 @@
1
+ /**
2
+ * GET /api/oauth/resume?return_url=<IdP authorize request> - moved from VibeID-Customer-Portal src/app/api/oauth/resume/route.ts
3
+ * (PAY-1500), PAY-2302 cut 2.
4
+ *
5
+ * The only place an absolute post-2FA destination is followed, and only after checking it is this deployment's IdP
6
+ * authorize endpoint (lib/authorize-resume). Everything else is refused to a same-origin page: this route must never
7
+ * become an open redirect.
8
+ *
9
+ * The validation base is the app's configured IDP_URL, never anything derived from the request. If IDP_URL is unset
10
+ * or not a URL the answer is the refusal, not a throw and not a fallback to the request's own origin (fail closed).
11
+ * INTERNAL_IDP_URL is deliberately NOT used: the authorize URL is the PUBLIC IdP's (it is also what the registered client uses).
12
+ *
13
+ * PAY-2319 (4.7.1): WHERE a refusal goes is configurable and the default is the login page every generated app has
14
+ * (`/account-auth/login?error=AUTHORIZE_RESUME_REFUSED`). 4.7.0 hard-coded the portal's `/sign-in`, which a generated app does not
15
+ * have: the middleware forwarded it to the login page with the error code dropped and, after sign-in, to a 404.
16
+ * An app (the portal has /sign-in) passes its own location through createOAuthResumeHandler.
17
+ *
18
+ * 4.7.5 (PAY-2305): THE AUTHORIZE CALL IS MADE HERE, SERVER-SIDE. Core's /api/oauth/authorize identifies a user only from an
19
+ * `Authorization: Bearer` header (JwtMiddleware reads no cookie), so sending the BROWSER to it (4.7.0-4.7.4) could never produce a
20
+ * code. This route now
21
+ * 1. validates return_url and REBUILDS the authorize URL from constants (IDP_URL origin, fixed path, this app's CLIENT_ID and
22
+ * registered redirect_uri); only code_challenge (S256), state and scope come from return_url, each validated;
23
+ * 2. requires a same-site navigation, a browser that STARTED the flow (better-auth's oauth_state cookie) and the signed-in app session;
24
+ * 3. calls authorize with the user's IdP access token (redirect: 'manual', timeout; the token goes only to the IDP_URL origin and
25
+ * is never logged);
26
+ * 4. accepts ONLY a redirect to the registered redirect_uri carrying a code and the state it sent, and relays it to the browser
27
+ * as a RELATIVE path+query rebuilt from those two values.
28
+ * Anything else (IdP sends the user to login / 2FA enrolment / an error, no session, refresh fails, IdP unreachable) is the
29
+ * refusal. Nothing is retried or followed.
30
+ */
31
+ import { buildServerAuthorizeRequest, hasOAuthStateCookie, isSameSiteResume, relayRegisteredCallback } from '../../lib/authorize-resume';
32
+ import { resolveIdpRedirectUri } from '../../auth/idp-oauth';
33
+
34
+ export const DEFAULT_REFUSAL_LOCATION = '/account-auth/login?error=AUTHORIZE_RESUME_REFUSED';
35
+
36
+ export interface OAuthResumeDeps {
37
+ /** Test seam. Default: global fetch. */
38
+ fetch?: typeof fetch;
39
+ /** Test seam. Default: the signed-in app session's IdP access token (one refresh attempt when near expiry), else null. */
40
+ resolveAccessToken?: (request: Request) => Promise<string | null>;
41
+ /** Test seam. Default: BETTER_AUTH_URL, else the IdP client config's baseClientUrl, else http://localhost:$PORT (the SAME chain the provider config uses). */
42
+ appBaseUrl?: () => Promise<string>;
43
+ }
44
+
45
+ export interface OAuthResumeOptions {
46
+ /** Same-origin location a refused request is sent to. Must start with a single "/" (no //host, no backslash, no scheme). */
47
+ refusalLocation?: string;
48
+ deps?: OAuthResumeDeps;
49
+ }
50
+
51
+ /** A relative, same-origin location only: the refusal must never itself become an open redirect. */
52
+ function assertRelativeLocation(loc: string): string {
53
+ const ok = typeof loc === 'string' && loc.startsWith('/') && !loc.startsWith('//') && !loc.startsWith('/\\') && !/\s/.test(loc);
54
+ if (!ok) {
55
+ throw new TypeError(`createOAuthResumeHandler: refusalLocation must be a same-origin path starting with a single "/", got ${JSON.stringify(loc)}`);
56
+ }
57
+ return loc;
58
+ }
59
+
60
+ const AUTHORIZE_TIMEOUT_MS = 8000;
61
+
62
+ /** Cookie header -> the `{ get(name) }` shape decodeSession wants (NextRequest.cookies). */
63
+ function cookieJar(header: string | null): { get: (name: string) => { value: string } | undefined } {
64
+ const map = new Map<string, string>();
65
+ for (const part of (header ?? '').split(';')) {
66
+ const i = part.indexOf('=');
67
+ if (i < 1) continue;
68
+ const name = part.slice(0, i).trim();
69
+ let value = part.slice(i + 1).trim();
70
+ try {
71
+ value = decodeURIComponent(value);
72
+ } catch {
73
+ /* keep the raw value */
74
+ }
75
+ if (!map.has(name)) map.set(name, value);
76
+ }
77
+ return { get: (name) => (map.has(name) ? { value: map.get(name)! } : undefined) };
78
+ }
79
+
80
+ async function defaultResolveAccessToken(request: Request): Promise<string | null> {
81
+ const { decodeSession } = await import('../../server/decode-session');
82
+ const decoded = await decodeSession(cookieJar(request.headers.get('cookie')));
83
+ const sessionToken = (decoded?.jwtPayload?.sessionToken || decoded?.jwtPayload?.redisSessionId) as string | undefined;
84
+ if (!sessionToken) return null;
85
+ const { ensureFreshAccessToken } = await import('../../lib/ensure-fresh-access-token');
86
+ const fresh = await ensureFreshAccessToken(sessionToken, {
87
+ idpBaseUrl: process.env.IDP_URL as string,
88
+ clientId: process.env.CLIENT_ID as string,
89
+ });
90
+ return fresh.ok ? fresh.accessToken : null;
91
+ }
92
+
93
+ async function defaultAppBaseUrl(): Promise<string> {
94
+ if (process.env.BETTER_AUTH_URL) return process.env.BETTER_AUTH_URL;
95
+ try {
96
+ const { getIDPClientConfig } = await import('../../lib/idp-client-config');
97
+ const cfg = await getIDPClientConfig();
98
+ if (cfg?.baseClientUrl) return cfg.baseClientUrl;
99
+ } catch {
100
+ /* fall through to the local default, exactly as createBetterAuthInstance does */
101
+ }
102
+ return `http://localhost:${process.env.PORT || '3000'}`;
103
+ }
104
+
105
+ export function createOAuthResumeHandler(options: OAuthResumeOptions = {}): (request: Request) => Promise<Response> {
106
+ const refusalLocation = assertRelativeLocation(options.refusalLocation ?? DEFAULT_REFUSAL_LOCATION);
107
+ const fetchFn = options.deps?.fetch ?? ((...a: Parameters<typeof fetch>) => fetch(...a));
108
+ const resolveAccessToken = options.deps?.resolveAccessToken ?? defaultResolveAccessToken;
109
+ const appBaseUrl = options.deps?.appBaseUrl ?? defaultAppBaseUrl;
110
+
111
+ // RELATIVE Location on purpose: in a container request.url is the bind address (http://0.0.0.0:8080), so
112
+ // new URL(path, request.url) would send the browser to a dead host (kb 3463/5571; QAPert 59254 F1).
113
+ const refuse = (why: string): Response => {
114
+ console.warn(`[oauth-resume] AUTHORIZE_RESUME outcome=refused reason=${why}`);
115
+ return new Response(null, { status: 302, headers: { location: refusalLocation, 'cache-control': 'no-store' } });
116
+ };
117
+
118
+ return async function GET(request: Request): Promise<Response> {
119
+ const idpBase = process.env.IDP_URL;
120
+ const clientId = process.env.CLIENT_ID;
121
+ if (!idpBase || !clientId) return refuse('not-configured');
122
+
123
+ let redirectUri: string;
124
+ try {
125
+ redirectUri = resolveIdpRedirectUri(await appBaseUrl());
126
+ } catch {
127
+ return refuse('no-app-base');
128
+ }
129
+
130
+ const built = buildServerAuthorizeRequest(new URL(request.url).searchParams.get('return_url'), {
131
+ idpBaseUrl: idpBase,
132
+ clientId,
133
+ redirectUri,
134
+ });
135
+ if (!built) return refuse('return_url');
136
+
137
+ let appOrigin: string;
138
+ try {
139
+ appOrigin = new URL(redirectUri).origin;
140
+ } catch {
141
+ return refuse('bad-redirect-uri');
142
+ }
143
+ if (!isSameSiteResume(request.headers, appOrigin)) return refuse('not-same-site');
144
+ if (!hasOAuthStateCookie(request.headers.get('cookie'))) return refuse('no-oauth-state'); // this browser never started the flow
145
+
146
+ let accessToken: string | null = null;
147
+ try {
148
+ accessToken = await resolveAccessToken(request);
149
+ } catch {
150
+ accessToken = null;
151
+ }
152
+ if (!accessToken) return refuse('no-session');
153
+
154
+ const forwarded: Record<string, string> = {};
155
+ const ua = request.headers.get('user-agent');
156
+ if (ua) forwarded['User-Agent'] = ua;
157
+ const xff = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip');
158
+ if (xff) forwarded['X-Forwarded-For'] = xff;
159
+ const device = request.headers.get('x-device-id');
160
+ if (device) forwarded['X-Device-ID'] = device;
161
+
162
+ let res: Response;
163
+ try {
164
+ res = await fetchFn(built.url, {
165
+ method: 'GET',
166
+ headers: { ...forwarded, Accept: 'application/json', Authorization: `Bearer ${accessToken}` },
167
+ redirect: 'manual', // the Bearer header is never carried to a second URL
168
+ signal: AbortSignal.timeout(AUTHORIZE_TIMEOUT_MS),
169
+ });
170
+ } catch {
171
+ return refuse('idp-unreachable');
172
+ }
173
+
174
+ const relay = relayRegisteredCallback(res.status, res.headers.get('location'), redirectUri, built.state);
175
+ // Status only in the log: the Location can carry a code or a return_url.
176
+ if (!relay) return refuse(`idp-answered-${res.status}`);
177
+ console.log('[oauth-resume] AUTHORIZE_RESUME outcome=code-issued');
178
+ return new Response(null, { status: 302, headers: { location: relay, 'cache-control': 'no-store' } });
179
+ };
180
+ }
181
+
182
+ export const GET = createOAuthResumeHandler();
@@ -20,6 +20,12 @@ import { wrapBetterAuthHandler } from './session-response-sanitizer';
20
20
  import { magicLink, type MagicLinkOptions } from 'better-auth/plugins/magic-link';
21
21
  import { resolveClientKey } from '../lib/client-key';
22
22
  import { genericOAuth } from 'better-auth/plugins/generic-oauth';
23
+ import {
24
+ createIdpOAuthHooks,
25
+ resolveIdpRedirectUri,
26
+ type IdpTokenGrant,
27
+ type IdpVerifiedClaims,
28
+ } from './idp-oauth';
23
29
  import type { IDPClientConfig } from '../lib/idp-client-config';
24
30
  import { getIDPClientConfig } from '../lib/idp-client-config';
25
31
  import { getAppSlug, assertAppSlugMatchesIdp } from '../lib/app-slug';
@@ -143,7 +149,12 @@ export interface BetterAuthSocialProvider {
143
149
  * verifies for itself and never trusts an asserted email.
144
150
  */
145
151
  const ID_TOKEN_SCOPE_KEY = '__payezIdTokenScope';
146
- type ProviderTokenScope = { idToken?: string; accessToken?: string };
152
+ type ProviderTokenScope = {
153
+ idToken?: string;
154
+ accessToken?: string;
155
+ /** PAY-2305: what the payez-idp getToken / getUserInfo hooks captured for THIS request (never persisted here). */
156
+ idp?: { tokens?: IdpTokenGrant; claims?: IdpVerifiedClaims };
157
+ };
147
158
  const gScope = globalThis as unknown as Record<
148
159
  string,
149
160
  AsyncLocalStorage<ProviderTokenScope> | undefined
@@ -182,21 +193,51 @@ const idTokenScope = gScope[ID_TOKEN_SCOPE_KEY]!;
182
193
  * exchange must verify signature/iss/aud/exp itself and preserve those four, or
183
194
  * they are lost silently.
184
195
  */
185
- export function buildIdpOAuthProviderConfig(): {
196
+ export interface IdpOAuthProviderConfig {
186
197
  providerId: string;
187
198
  discoveryUrl: string;
188
199
  clientId: string;
189
200
  pkce: true;
190
201
  scopes: string[];
191
- } | null {
202
+ /** PAY-2305: the REGISTERED {site_url}/api/auth/callback, sent identically at authorize and token time. */
203
+ redirectURI: string;
204
+ getToken: ReturnType<typeof createIdpOAuthHooks>['getToken'];
205
+ getUserInfo: ReturnType<typeof createIdpOAuthHooks>['getUserInfo'];
206
+ }
207
+
208
+ export function buildIdpOAuthProviderConfig(appBaseUrl?: string): IdpOAuthProviderConfig | null {
192
209
  const discoveryUrl = process.env.IDP_OAUTH_DISCOVERY_URL;
193
210
  const clientId = process.env.CLIENT_ID;
194
211
  if (!discoveryUrl || !clientId) return null;
195
212
 
213
+ const redirectURI = resolveIdpRedirectUri(
214
+ appBaseUrl || process.env.BETTER_AUTH_URL || process.env.APP_URL || `http://localhost:${process.env.PORT || '3000'}`,
215
+ );
216
+ const hooks = createIdpOAuthHooks({
217
+ discoveryUrl,
218
+ clientId,
219
+ redirectURI,
220
+ issuer: process.env.IDP_OAUTH_ISSUER || undefined,
221
+ trustedOrigins: process.env.IDP_OAUTH_TRUSTED_ORIGINS
222
+ ? process.env.IDP_OAUTH_TRUSTED_ORIGINS.split(',').map((x) => x.trim()).filter(Boolean)
223
+ : undefined,
224
+ onTokens: (tokens) => {
225
+ const store = idTokenScope.getStore();
226
+ if (store) (store.idp ??= {}).tokens = tokens;
227
+ },
228
+ onClaims: (claims) => {
229
+ const store = idTokenScope.getStore();
230
+ if (store) (store.idp ??= {}).claims = claims;
231
+ },
232
+ });
233
+
196
234
  return {
197
235
  providerId: 'payez-idp',
198
236
  discoveryUrl,
199
237
  clientId,
238
+ redirectURI,
239
+ getToken: hooks.getToken,
240
+ getUserInfo: hooks.getUserInfo,
200
241
  // NO clientSecret. The whole point: this app is not a credential holder.
201
242
  pkce: true,
202
243
  scopes: ['openid', 'email', 'profile'],
@@ -565,7 +606,7 @@ export function createBetterAuthInstance(
565
606
  || `http://localhost:${process.env.PORT || '3000'}`;
566
607
  const baseURL = rawBaseURL.replace(/\/+$/, '') + '/api/auth';
567
608
 
568
- const idpOAuth = buildIdpOAuthProviderConfig();
609
+ const idpOAuth = buildIdpOAuthProviderConfig(rawBaseURL);
569
610
 
570
611
  return betterAuth({
571
612
  baseURL,
@@ -1417,6 +1458,37 @@ export async function storeIdpTokensInSession(
1417
1458
  */
1418
1459
  export const OAUTH_SIGN_IN_FAILED_ERROR = 'OAUTH_SIGN_IN_FAILED';
1419
1460
 
1461
+ /**
1462
+ * PAY-2305: store the tokens the payez-idp provider hooks captured during THIS request. Fail closed: no captured tokens or no
1463
+ * verified claims (getUserInfo refused the token) => false, and the caller aborts the sign-in. nextStep is left undefined on
1464
+ * purpose (unknown is not "fully authenticated", see 141688); mfaVerified comes only from the verified token's amr.
1465
+ */
1466
+ export async function storePayezIdpSession(sessionToken: string): Promise<boolean> {
1467
+ const captured = idTokenScope.getStore()?.idp;
1468
+ if (!captured?.tokens?.accessToken || !captured.claims) {
1469
+ console.warn('[BETTER_AUTH] payez-idp callback: no verified IdP tokens captured for this request');
1470
+ return false;
1471
+ }
1472
+ const { tokens, claims } = captured;
1473
+ const stored = await storeIdpTokensInSession(
1474
+ sessionToken,
1475
+ {
1476
+ idpAccessToken: tokens.accessToken,
1477
+ idpRefreshToken: tokens.refreshToken,
1478
+ idpAccessTokenExpires: tokens.accessTokenExpires ?? Date.now() + 15 * 60 * 1000,
1479
+ userId: String(claims.userId ?? claims.sub),
1480
+ email: claims.email,
1481
+ name: claims.name,
1482
+ roles: claims.roles,
1483
+ mfaVerified: claims.mfaVerified,
1484
+ nextStep: undefined,
1485
+ },
1486
+ 'payez-idp',
1487
+ );
1488
+ if (stored) console.log('[BETTER_AUTH] IdP tokens stored in session for', claims.email);
1489
+ return stored;
1490
+ }
1491
+
1420
1492
  /**
1421
1493
  * The ONLY IdP refusal codes the package forwards to the login page (PAY-2005 (b), BAPert 67897).
1422
1494
  *
@@ -1591,7 +1663,11 @@ export function createAuthGetHandler(loginPath: string = '/account-auth/login')
1591
1663
  if (session?.session?.token) {
1592
1664
  sessionToken = session.session.token;
1593
1665
  console.log('[BETTER_AUTH] Got session token from callback:', tokenLogId(session.session.token), '| email:', session.user?.email);
1594
- exchange = await exchangeOAuthForIdpTokens(session.session.token);
1666
+ // PAY-2305: the payez-idp callback already holds an IdP-ISSUED bearer (verified in getUserInfo). It must NOT go
1667
+ // through oauth-callback, the asserted-email path (card 138635), and storing it is what the passkey path does.
1668
+ exchange = url.pathname.endsWith('/callback/payez-idp')
1669
+ ? await storePayezIdpSession(session.session.token)
1670
+ : await exchangeOAuthForIdpTokens(session.session.token);
1595
1671
  } else {
1596
1672
  console.warn('[BETTER_AUTH] Could not get session after OAuth callback');
1597
1673
  }
@@ -0,0 +1,310 @@
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 { createRemoteJWKSet, decodeProtectedHeader, jwtVerify, type JWTVerifyGetKey } from 'jose';
22
+
23
+ export interface IdpTokenGrant {
24
+ accessToken: string;
25
+ refreshToken?: string;
26
+ /** epoch ms */
27
+ accessTokenExpires?: number;
28
+ tokenType?: string;
29
+ /** The `user` object of the IdP response, passed through untouched (shape not contractually fixed). */
30
+ user?: Record<string, unknown>;
31
+ }
32
+
33
+ export interface IdpVerifiedClaims {
34
+ sub: string;
35
+ email: string;
36
+ name: string;
37
+ userId?: string;
38
+ roles: string[];
39
+ /**
40
+ * true ONLY when the verified token asserts email_verified === true (or "true"). Never assumed: better-auth links by email
41
+ * from what getUserInfo returns, so an unasserted value must not let an IdP email attach to an existing local account.
42
+ */
43
+ emailVerified: boolean;
44
+ /** true only when the VERIFIED access token's amr contains "mfa" (Core's HasSecondFactor rule). */
45
+ mfaVerified: boolean;
46
+ }
47
+
48
+ export interface IdpOAuthHookDeps {
49
+ fetch?: typeof fetch;
50
+ /** Key resolver for a jwks_uri. Default: jose createRemoteJWKSet. Tests inject a local key set. */
51
+ getKeySet?: (jwksUri: string) => JWTVerifyGetKey;
52
+ now?: () => number;
53
+ }
54
+
55
+ export interface IdpOAuthHookOptions {
56
+ discoveryUrl: string;
57
+ clientId: string;
58
+ redirectURI: string;
59
+ /** Override for the expected `iss`. Default: the discovery document's `issuer`. */
60
+ issuer?: string;
61
+ /**
62
+ * Extra origins the discovery document may point token_endpoint / jwks_uri at. By default both MUST share the origin of
63
+ * `discoveryUrl`, so a document cannot send the code exchange or the key fetch to another host. Set this only for a
64
+ * split-host deployment (internal discovery URL, public endpoints). Comma-separated env: IDP_OAUTH_TRUSTED_ORIGINS.
65
+ */
66
+ trustedOrigins?: string[];
67
+ onTokens?: (grant: IdpTokenGrant) => void;
68
+ onClaims?: (claims: IdpVerifiedClaims) => void;
69
+ deps?: IdpOAuthHookDeps;
70
+ }
71
+
72
+ const DISCOVERY_TTL_MS = 10 * 60 * 1000;
73
+ const HTTP_TIMEOUT_MS = 10_000;
74
+
75
+ /** The URI sent at authorize AND token time. Explicit env wins; else the registered {app}/api/auth/callback form. */
76
+ export function resolveIdpRedirectUri(appBaseUrl: string, env: NodeJS.ProcessEnv = process.env): string {
77
+ const explicit = env.IDP_OAUTH_REDIRECT_URI?.trim();
78
+ if (explicit) return explicit;
79
+ return appBaseUrl.replace(/\/+$/, '') + '/api/auth/callback';
80
+ }
81
+
82
+ /** amr arrives as an array, a JSON-array string, or a delimited string (Core's own reader accepts all three). */
83
+ export function amrIncludesMfa(amr: unknown): boolean {
84
+ let values: unknown[] = [];
85
+ if (Array.isArray(amr)) values = amr;
86
+ else if (typeof amr === 'string') {
87
+ const t = amr.trim();
88
+ if (t.startsWith('[')) {
89
+ try {
90
+ const parsed = JSON.parse(t);
91
+ if (Array.isArray(parsed)) values = parsed;
92
+ } catch {
93
+ values = [];
94
+ }
95
+ } else {
96
+ values = t.split(/[\s,]+/);
97
+ }
98
+ }
99
+ return values.some((v) => typeof v === 'string' && v.trim().toLowerCase() === 'mfa');
100
+ }
101
+
102
+ /** Max skew accepted on nbf/exp/iat when verifying the IdP access token. */
103
+ export const CLOCK_TOLERANCE_SECONDS = 30;
104
+
105
+ /**
106
+ * Address shape, a POSITIVE character rule (PAY-2337, Scout 74285): the local part may contain only [A-Za-z0-9] and . _ % + - ' ; the domain only
107
+ * [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.
108
+ * Everything else is NOT an address, and in particular a MASK: Core masks data.user.email on purpose (MaskedEmailJsonConverter), today with '*',
109
+ * tomorrow maybe a bullet, an ellipsis or an 'x' run; a blacklist of one glyph would pass the next mask. Not a deliverability check.
110
+ */
111
+ export function isAddressShaped(v: unknown): v is string {
112
+ if (typeof v !== 'string' || v.length === 0 || v.length > 254) return false;
113
+ if (!/^[A-Za-z0-9._%+'-]+@[A-Za-z0-9.-]+$/.test(v)) return false;
114
+ const [local, domain] = v.split('@');
115
+ if (local.length > 64 || local.startsWith('.') || local.endsWith('.') || local.includes('..')) return false;
116
+ return /^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/.test(domain) && !domain.startsWith('-') && !domain.endsWith('-');
117
+ }
118
+
119
+ /**
120
+ * 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
121
+ * 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
122
+ * 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
123
+ * 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.
124
+ */
125
+ export function resolveEmail(payload: Record<string, unknown>, responseEmail?: unknown): string {
126
+ // NOT trimmed: padding or a trailing newline is a malformed address (refused by the shape check), never silently repaired.
127
+ const raw = (v: unknown) => (typeof v === 'string' ? v : '');
128
+ const sources = [raw(payload.email), raw(responseEmail)].filter((v) => v.trim() !== '');
129
+ if (sources.length === 0 || !sources.every(isAddressShaped)) return '';
130
+ const chosen = sources[0];
131
+ const same = (a: string) => a.toLowerCase() === chosen.toLowerCase();
132
+ if (!sources.every(same)) return '';
133
+ return chosen;
134
+ }
135
+
136
+ function rolesOf(claim: unknown): string[] {
137
+ if (Array.isArray(claim)) return claim.filter((r): r is string => typeof r === 'string' && r.length > 0);
138
+ if (typeof claim === 'string' && claim.trim()) {
139
+ const t = claim.trim();
140
+ if (t.startsWith('[')) {
141
+ try {
142
+ const parsed = JSON.parse(t);
143
+ if (Array.isArray(parsed)) return parsed.filter((r): r is string => typeof r === 'string' && r.length > 0);
144
+ } catch {
145
+ return [];
146
+ }
147
+ }
148
+ return t.split(/[\s,]+/).filter(Boolean);
149
+ }
150
+ return [];
151
+ }
152
+
153
+ export function createIdpOAuthHooks(opts: IdpOAuthHookOptions) {
154
+ const fetchFn = opts.deps?.fetch ?? fetch;
155
+ const now = opts.deps?.now ?? (() => Date.now());
156
+ const getKeySet = opts.deps?.getKeySet ?? ((uri: string) => createRemoteJWKSet(new URL(uri)));
157
+
158
+ let discovery: { doc: Record<string, unknown>; at: number } | null = null;
159
+ const keySets = new Map<string, JWTVerifyGetKey>();
160
+ /** access token -> data.user.email / user_name from the token response, consumed by getUserInfo for THAT token (bounded; never keyed by anything shared). */
161
+ const responseUsers = new Map<string, { email: string; userName: string }>();
162
+
163
+ async function loadDiscovery(): Promise<Record<string, unknown>> {
164
+ if (discovery && now() - discovery.at < DISCOVERY_TTL_MS) return discovery.doc;
165
+ const res = await fetchFn(opts.discoveryUrl, {
166
+ headers: { Accept: 'application/json' },
167
+ redirect: 'error',
168
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
169
+ });
170
+ if (!res.ok) throw new Error(`IdP discovery returned ${res.status}`);
171
+ const doc = (await res.json()) as Record<string, unknown>;
172
+ if (typeof doc?.token_endpoint !== 'string' || typeof doc?.jwks_uri !== 'string' || typeof doc?.issuer !== 'string') {
173
+ throw new Error('IdP discovery document is missing token_endpoint, jwks_uri or issuer');
174
+ }
175
+ const allowed = new Set([new URL(opts.discoveryUrl).origin, ...(opts.trustedOrigins ?? []).map((o) => o.replace(/\/+$/, ''))]);
176
+ for (const field of ['token_endpoint', 'jwks_uri'] as const) {
177
+ const origin = new URL(doc[field] as string).origin;
178
+ if (!allowed.has(origin)) throw new Error(`IdP discovery ${field} points at an untrusted origin (${origin})`);
179
+ }
180
+ discovery = { doc, at: now() }; // only a good document is cached
181
+ return doc;
182
+ }
183
+
184
+ /** better-auth `getToken`: JSON body, envelope unwrapped, no data.access_token => refused. */
185
+ async function getToken(data: { code: string; codeVerifier?: string }) {
186
+ const doc = await loadDiscovery();
187
+ if (!data.codeVerifier) throw new Error('IdP token exchange refused: no PKCE code_verifier');
188
+ const res = await fetchFn(doc.token_endpoint as string, {
189
+ method: 'POST',
190
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
191
+ redirect: 'error',
192
+ signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
193
+ body: JSON.stringify({
194
+ grant_type: 'authorization_code',
195
+ code: data.code,
196
+ code_verifier: data.codeVerifier,
197
+ // Deliberately NOT data.redirectURI: that is better-auth's own computed callback, not the registered URI.
198
+ redirect_uri: opts.redirectURI,
199
+ client_id: opts.clientId,
200
+ }),
201
+ });
202
+ let body: any;
203
+ try {
204
+ body = await res.json();
205
+ } catch {
206
+ throw new Error(`IdP token response (${res.status}) was not JSON`);
207
+ }
208
+ if (!res.ok) {
209
+ const code = body?.error?.code ?? body?.error ?? body?.code ?? 'unknown';
210
+ throw new Error(`IdP token exchange refused (${res.status}, ${typeof code === 'string' ? code : 'unknown'})`);
211
+ }
212
+ const d = body?.data;
213
+ if (!d || typeof d.access_token !== 'string' || !d.access_token) {
214
+ throw new Error('IdP token response has no data.access_token (envelope not as expected)');
215
+ }
216
+ const expiresIn = typeof d.expires_in === 'number' && d.expires_in > 0 ? d.expires_in : undefined;
217
+ const expires = expiresIn ? now() + expiresIn * 1000 : undefined;
218
+ if (d.user && typeof d.user === 'object' && typeof d.user.email === 'string') {
219
+ if (responseUsers.size >= 64) responseUsers.delete(responseUsers.keys().next().value as string);
220
+ responseUsers.set(d.access_token, { email: d.user.email, userName: typeof d.user.user_name === 'string' ? d.user.user_name : '' });
221
+ }
222
+ opts.onTokens?.({
223
+ accessToken: d.access_token,
224
+ refreshToken: typeof d.refresh_token === 'string' ? d.refresh_token : undefined,
225
+ accessTokenExpires: expires,
226
+ tokenType: typeof d.token_type === 'string' ? d.token_type : undefined,
227
+ user: d.user && typeof d.user === 'object' ? d.user : undefined,
228
+ });
229
+ return {
230
+ tokenType: typeof d.token_type === 'string' ? d.token_type : 'Bearer',
231
+ accessToken: d.access_token,
232
+ refreshToken: typeof d.refresh_token === 'string' ? d.refresh_token : undefined,
233
+ accessTokenExpiresAt: expires ? new Date(expires) : undefined,
234
+ scopes: [] as string[],
235
+ };
236
+ }
237
+
238
+ /** better-auth `getUserInfo`: the user comes from the VERIFIED access token. Returns null (never a guess) on any doubt. */
239
+ async function getUserInfo(tokens: { accessToken?: string }) {
240
+ try {
241
+ if (!tokens?.accessToken) return null;
242
+ const doc = await loadDiscovery();
243
+ // A token without a kid is refused: no "first key" fallback. (A kid that is not in the key set is refused by jose.)
244
+ const kid = decodeProtectedHeader(tokens.accessToken).kid;
245
+ if (typeof kid !== 'string' || !kid) throw new Error('token header has no kid');
246
+ const jwksUri = doc.jwks_uri as string;
247
+ let keys = keySets.get(jwksUri);
248
+ if (!keys) {
249
+ keys = getKeySet(jwksUri);
250
+ keySets.set(jwksUri, keys);
251
+ }
252
+ const { payload } = await jwtVerify(tokens.accessToken, keys, {
253
+ // RSA signatures ONLY (RS256/384/512, PS256/384/512; BAPert 74117): never HS* (a token "signed" with the public key as an HMAC
254
+ // secret), never none, never ES. 4.7.4/4.7.5 pinned RS256 from a source read; the live IdP's JWKS (dev-93, kid 46) declares alg RS512
255
+ // and every real access token was refused (NightHawk 74114). Which RS alg is binding is the JWKS KEY's own declared
256
+ // `alg`: jose only offers a JWK to a token whose header alg equals that JWK's `alg`, so a token cannot choose a
257
+ // weaker/other algorithm than the published key says. A JWK with no `alg` accepts any listed RSA alg; a symmetric (oct) key is never usable for these algorithms.
258
+ algorithms: ['RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512'],
259
+ issuer: opts.issuer ?? (doc.issuer as string),
260
+ currentDate: new Date(now()),
261
+ // The IdP stamps nbf = issue time with no skew and the token is verified in the same second it is minted (NightHawk 74151:
262
+ // "nbf claim timestamp check failed"). 30 s, bounded. jose applies it to exp too: a token is accepted up to 30 s after exp.
263
+ clockTolerance: CLOCK_TOLERANCE_SECONDS,
264
+ });
265
+ if (payload.token_type !== 'bearer_token') throw new Error('token_type is not bearer_token');
266
+ if (typeof payload.sub !== 'string' || !payload.sub) throw new Error('no sub');
267
+ const hasEmailClaim = typeof payload.email === 'string' && payload.email.trim() !== '';
268
+ const responseUser = responseUsers.get(tokens.accessToken);
269
+ responseUsers.delete(tokens.accessToken);
270
+ // The response user object (UNSIGNED body) must be the TOKEN's user (Scout 74198, DotNetPert-Scout 74202): Core fills data.user.user_name and the
271
+ // signed sub from the same loaded user in one mint, so WHENEVER a response user exists (even if a signed email claim is also present), user_name MUST be present
272
+ // and equal sub (case-insensitive). A different or missing user_name means the address may belong to someone else -> refuse. Does not constrain email == sub.
273
+ if (responseUser && responseUser.userName.trim().toLowerCase() !== payload.sub.trim().toLowerCase()) {
274
+ throw new Error('token response user is missing or does not match the token subject');
275
+ }
276
+ // A MASKED / unusable response address is ABSENT (PAY-2337), not "a different address": a valid signed claim still signs in, and with no claim
277
+ // there is no source. NEVER repaired or unmasked (no prefix match against sub or the claim). Log names only the fact, no value, no length.
278
+ const responseEmailRaw = responseUser?.email ?? '';
279
+ const responseEmailUsable = isAddressShaped(responseEmailRaw);
280
+ const responseMasked = responseEmailRaw.trim() !== '' && !responseEmailUsable;
281
+ if (responseMasked) console.warn('[BETTER_AUTH] payez-idp token response address is masked or unusable; ignored');
282
+ const email = resolveEmail(payload as Record<string, unknown>, responseEmailUsable ? responseEmailRaw : undefined);
283
+ if (!email) {
284
+ throw new Error(responseMasked ? 'response address is masked' : 'no usable email (needs data.user.email or an email claim, address-shaped, equal when both are present)');
285
+ }
286
+ const name =
287
+ (typeof payload.name === 'string' && payload.name.trim()) ||
288
+ (typeof payload.full_name === 'string' && payload.full_name.trim()) ||
289
+ email.split('@')[0];
290
+ const claims: IdpVerifiedClaims = {
291
+ sub: payload.sub,
292
+ email,
293
+ name,
294
+ userId: typeof payload.user_id === 'string' || typeof payload.user_id === 'number' ? String(payload.user_id) : undefined,
295
+ roles: rolesOf(payload.roles),
296
+ mfaVerified: amrIncludesMfa(payload.amr),
297
+ // Only the signed token's own email CLAIM can be verified; an address that came from the token response (data.user.email) never asserts it.
298
+ emailVerified: hasEmailClaim && (payload.email_verified === true || payload.email_verified === 'true'),
299
+ };
300
+ opts.onClaims?.(claims);
301
+ return { id: claims.sub, email: claims.email, name: claims.name, emailVerified: claims.emailVerified };
302
+ } catch (err) {
303
+ // Reason only, never the token.
304
+ console.warn('[BETTER_AUTH] payez-idp access token refused:', err instanceof Error ? err.message : String(err));
305
+ return null;
306
+ }
307
+ }
308
+
309
+ return { getToken, getUserInfo };
310
+ }
@@ -65,6 +65,15 @@ export const DEFAULT_CALLBACK = '/';
65
65
  /** The auth surface. A callback pointing back into it causes a redirect cycle. */
66
66
  const AUTH_PREFIX = '/account-auth/';
67
67
 
68
+ /**
69
+ * PAY-2315 (4.7.1): the middleware now treats the public /login page like /account-auth/login (a signed-in user is sent
70
+ * on), so a callback or a resolved path that is /login itself must be refused here too - otherwise /login with no
71
+ * callbackUrl would resolve its own pathname as the destination and redirect to itself forever.
72
+ */
73
+ function isAuthSurface(pathname: string): boolean {
74
+ return pathname.startsWith(AUTH_PREFIX) || pathname === '/login' || pathname.startsWith('/login/');
75
+ }
76
+
68
77
  /**
69
78
  * Synthetic, unreachable base. Only ever used as the resolution origin for the
70
79
  * comparison below - never emitted, never navigated to. `.invalid` is reserved
@@ -93,7 +102,7 @@ export function safeCallbackPath(raw: unknown, fallback: string = DEFAULT_CALLBA
93
102
  if (url.origin !== BASE) return fallback;
94
103
 
95
104
  // Loopbacks into the auth surface cause redirect cycles.
96
- if (url.pathname.startsWith(AUTH_PREFIX)) return fallback;
105
+ if (isAuthSurface(url.pathname)) return fallback;
97
106
 
98
107
  // PAY-2218: `_rsc` is Next's flight cache-buster. Once skipProxyUrlNormalize lets it reach middleware it leaks
99
108
  // into return targets (`callbackUrl=/overview?_rsc=abc12`, NightHawk 72013); it is never a meaningful part of a
@@ -140,7 +149,7 @@ export function safeCallbackPath(raw: unknown, fallback: string = DEFAULT_CALLBA
140
149
  return fallback;
141
150
  }
142
151
  if (echo.origin !== BASE) return fallback;
143
- if (echo.pathname.startsWith(AUTH_PREFIX)) return fallback;
152
+ if (isAuthSurface(echo.pathname)) return fallback;
144
153
  if (echo.pathname + echo.search + echo.hash !== candidate) return fallback;
145
154
 
146
155
  return candidate;