@payez/next-mvp 4.4.3 → 4.4.4
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.
- package/dist/api/auth-handler.js +4 -13
- package/dist/auth/better-auth.js +130 -1
- package/package.json +2 -2
- package/scripts/module-reach.mjs +7 -1
- package/src/api/auth-handler.ts +4 -19
- package/src/auth/better-auth.ts +149 -3
- package/dist/lib/acr.d.ts +0 -64
- package/dist/lib/acr.js +0 -109
- package/src/lib/acr.ts +0 -109
package/dist/api/auth-handler.js
CHANGED
|
@@ -17,7 +17,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
17
17
|
exports.createAuthHandler = createAuthHandler;
|
|
18
18
|
const server_1 = require("next/server");
|
|
19
19
|
const auth_1 = require("../server/auth");
|
|
20
|
-
const acr_1 = require("../lib/acr");
|
|
21
20
|
const nanoid_1 = require("nanoid");
|
|
22
21
|
const session_store_1 = require("../lib/session-store");
|
|
23
22
|
/**
|
|
@@ -159,18 +158,10 @@ function createAuthHandler(options = {}) {
|
|
|
159
158
|
console.error('[AUTH_HANDLER] No refresh token available');
|
|
160
159
|
return { success: false, error: 'No refresh token' };
|
|
161
160
|
}
|
|
162
|
-
// Extract authentication methods from session
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
// authLevel = String(... || ... || '2')
|
|
167
|
-
// so a session that knew NOTHING about how the user authenticated told
|
|
168
|
-
// the IDP that a second factor had happened AND named 'mfa' as the
|
|
169
|
-
// method. The two fabrications corroborated each other, so a downstream
|
|
170
|
-
// check cross-validating amr against acr would have accepted the pair.
|
|
171
|
-
// Absent is not satisfied; both now resolve fail-closed, in one place.
|
|
172
|
-
const authMethods = (0, acr_1.resolveAuthMethods)(currentSession.authMethods, currentSession.token?.amr);
|
|
173
|
-
const authLevel = (0, acr_1.resolveAcrLevel)(currentSession.authenticationLevel, currentSession.token?.acr);
|
|
161
|
+
// Extract authentication methods from session
|
|
162
|
+
const authMethods = currentSession.authMethods ||
|
|
163
|
+
(currentSession.token?.amr ? JSON.parse(currentSession.token.amr) : ['pwd', 'mfa']);
|
|
164
|
+
const authLevel = String(currentSession.authenticationLevel || currentSession.token?.acr || '2');
|
|
174
165
|
const twoFactorMethod = currentSession.twoFactorMethod || 'authenticator';
|
|
175
166
|
// Build refresh request body
|
|
176
167
|
const refreshRequestBody = {
|
package/dist/auth/better-auth.js
CHANGED
|
@@ -147,6 +147,20 @@ function isPlainObject(value) {
|
|
|
147
147
|
* globalThis is per-process and per-runtime, which is exactly the lifetime a request-scoped
|
|
148
148
|
* carrier needs to be reachable from every copy that might run inside the request.
|
|
149
149
|
*/
|
|
150
|
+
/**
|
|
151
|
+
* THE KEY STAYS '__payezIdTokenScope' EVEN THOUGH THE SCOPE NOW CARRIES TWO
|
|
152
|
+
* THINGS. Renaming it would give a copy on the old name and a copy on the new
|
|
153
|
+
* name two DIFFERENT stores in the same process — which is precisely the
|
|
154
|
+
* double-load failure documented above, reintroduced by a cosmetic rename. The
|
|
155
|
+
* name is a wire contract between copies, not a description.
|
|
156
|
+
*
|
|
157
|
+
* accessToken is here for providers that do NOT issue an id_token. GitHub is
|
|
158
|
+
* not an OIDC provider: it has no id_token, no JWKS and no discovery document,
|
|
159
|
+
* so `id_token` can never be the field that proves a GitHub identity. The IDP
|
|
160
|
+
* re-derives that identity by calling GitHub itself with this access token
|
|
161
|
+
* (GitHubIdentityVerifier), which keeps the same rule google follows — the IDP
|
|
162
|
+
* verifies for itself and never trusts an asserted email.
|
|
163
|
+
*/
|
|
150
164
|
const ID_TOKEN_SCOPE_KEY = '__payezIdTokenScope';
|
|
151
165
|
const gScope = globalThis;
|
|
152
166
|
gScope[ID_TOKEN_SCOPE_KEY] ??= new node_async_hooks_1.AsyncLocalStorage();
|
|
@@ -248,9 +262,21 @@ function buildBetterAuthProviders(config) {
|
|
|
248
262
|
* remains, so the provider emits exactly its default set.
|
|
249
263
|
*/
|
|
250
264
|
const GOOGLE_DEFAULT_SCOPES = ['email', 'profile', 'openid'];
|
|
265
|
+
/**
|
|
266
|
+
* Same defect, same fix, different provider: github's createAuthorizationURL
|
|
267
|
+
* also seeds ['read:user','user:email'] unconditionally and then pushes ours
|
|
268
|
+
* on top (@better-auth/core social-providers/github.ts), so the stored row
|
|
269
|
+
* "read:user user:email" reached GitHub as
|
|
270
|
+
* "read:user user:email read:user user:email". Measured on :4799, not
|
|
271
|
+
* assumed. GitHub dedupes, so this is legibility rather than function — but
|
|
272
|
+
* a scope list is the first thing a security reviewer reads off that URL.
|
|
273
|
+
*/
|
|
274
|
+
const GITHUB_DEFAULT_SCOPES = ['read:user', 'user:email'];
|
|
251
275
|
const wireScopes = name === 'google'
|
|
252
276
|
? scopes.filter((s) => !GOOGLE_DEFAULT_SCOPES.includes(s))
|
|
253
|
-
:
|
|
277
|
+
: name === 'github'
|
|
278
|
+
? scopes.filter((s) => !GITHUB_DEFAULT_SCOPES.includes(s))
|
|
279
|
+
: scopes;
|
|
254
280
|
providers[name] = {
|
|
255
281
|
clientId: oauth.clientId,
|
|
256
282
|
clientSecret: oauth.clientSecret,
|
|
@@ -351,6 +377,82 @@ function buildBetterAuthProviders(config) {
|
|
|
351
377
|
};
|
|
352
378
|
};
|
|
353
379
|
}
|
|
380
|
+
/**
|
|
381
|
+
* GITHUB: THE SAME CAPTURE, A DIFFERENT TOKEN.
|
|
382
|
+
*
|
|
383
|
+
* GitHub is not an OIDC provider — it issues no id_token — so the google
|
|
384
|
+
* branch above can never fire for it and the exchange would post an
|
|
385
|
+
* assertion with nothing that proves identity. That is exactly the
|
|
386
|
+
* 401 UNVERIFIED_ASSERTION measured on :4799, whose diagnostics read
|
|
387
|
+
* `id_token_forwarded: false, id_token_length: 0` with `response_time_ms: 0`:
|
|
388
|
+
* the IDP rejected it instantly, without ever calling GitHub.
|
|
389
|
+
*
|
|
390
|
+
* getUserInfo is the ONLY provider hook that receives the token set (see the
|
|
391
|
+
* google note above), and @better-auth/core's github provider calls
|
|
392
|
+
* `options.getUserInfo(token)` BEFORE its own body — so installing this
|
|
393
|
+
* REPLACES the default rather than wrapping it, and the default's work has
|
|
394
|
+
* to be reproduced here or the sign-in loses its profile entirely.
|
|
395
|
+
*
|
|
396
|
+
* Mirrored verbatim from @better-auth/core/src/social-providers/github.ts at
|
|
397
|
+
* the version the container actually runs (better-auth 1.5.6 — the graft's
|
|
398
|
+
* node_modules says 1.6.29 and is NOT what executes). Kept deliberately
|
|
399
|
+
* dumb: it must not "improve" on upstream, because every divergence here is
|
|
400
|
+
* a behaviour change disguised as a token capture.
|
|
401
|
+
*
|
|
402
|
+
* This is NOT the identity check. The IDP re-derives the GitHub identity
|
|
403
|
+
* itself from this access token and enforces primary && verified server-side
|
|
404
|
+
* (GitHubIdentityVerifier); what we return here only shapes the local
|
|
405
|
+
* better-auth user record.
|
|
406
|
+
*/
|
|
407
|
+
if (name === 'github') {
|
|
408
|
+
providers[name].getUserInfo = async (token) => {
|
|
409
|
+
const store = idTokenScope.getStore();
|
|
410
|
+
if (store) {
|
|
411
|
+
store.accessToken = token?.accessToken;
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
console.warn('[BETTER_AUTH] github access_token captured but NO REQUEST SCOPE to put it in — ' +
|
|
415
|
+
'it will NOT be forwarded and the IDP exchange will fail UNVERIFIED_ASSERTION. ' +
|
|
416
|
+
'Check that GET is createAuthGetHandler(), not routes/auth/nextauth.');
|
|
417
|
+
}
|
|
418
|
+
const ghHeaders = {
|
|
419
|
+
'User-Agent': 'better-auth',
|
|
420
|
+
Authorization: `Bearer ${token?.accessToken}`,
|
|
421
|
+
};
|
|
422
|
+
const profileRes = await fetch('https://api.github.com/user', {
|
|
423
|
+
headers: ghHeaders,
|
|
424
|
+
});
|
|
425
|
+
// Upstream returns null on profile error, which fails the sign-in
|
|
426
|
+
// closed. Preserved rather than softened.
|
|
427
|
+
if (!profileRes.ok) {
|
|
428
|
+
console.warn(`[BETTER_AUTH] github getUserInfo: /user returned ${profileRes.status}`);
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
const profile = (await profileRes.json());
|
|
432
|
+
const emailsRes = await fetch('https://api.github.com/user/emails', {
|
|
433
|
+
headers: ghHeaders,
|
|
434
|
+
});
|
|
435
|
+
const emails = emailsRes.ok
|
|
436
|
+
? (await emailsRes.json())
|
|
437
|
+
: undefined;
|
|
438
|
+
// A GitHub account with a private email returns profile.email = null;
|
|
439
|
+
// upstream falls back to the primary address here.
|
|
440
|
+
if (!profile.email && emails) {
|
|
441
|
+
profile.email = (emails.find((e) => e.primary) ?? emails[0])?.email;
|
|
442
|
+
}
|
|
443
|
+
const emailVerified = emails?.find((e) => e.email === profile.email)?.verified ?? false;
|
|
444
|
+
return {
|
|
445
|
+
user: {
|
|
446
|
+
id: profile.id,
|
|
447
|
+
name: profile.name || profile.login || '',
|
|
448
|
+
email: profile.email,
|
|
449
|
+
image: profile.avatar_url,
|
|
450
|
+
emailVerified,
|
|
451
|
+
},
|
|
452
|
+
data: profile,
|
|
453
|
+
};
|
|
454
|
+
};
|
|
455
|
+
}
|
|
354
456
|
}
|
|
355
457
|
return providers;
|
|
356
458
|
}
|
|
@@ -802,6 +904,21 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
|
|
|
802
904
|
* distinguishable from "the provider issued none".
|
|
803
905
|
*/
|
|
804
906
|
...(idTokenScope.getStore()?.idToken ? { id_token: idTokenScope.getStore().idToken } : {}),
|
|
907
|
+
/**
|
|
908
|
+
* The same field for providers that have no id_token to give.
|
|
909
|
+
*
|
|
910
|
+
* Identical doctrine to the id_token above: RAW, forwarded verbatim,
|
|
911
|
+
* NOTHING DERIVED FROM IT, and omitted entirely rather than sent null so
|
|
912
|
+
* "never captured" stays distinguishable from "provider issued none".
|
|
913
|
+
* The IDP verifies for itself by calling GitHub with this token and
|
|
914
|
+
* enforcing primary && verified; we assert nothing about the identity.
|
|
915
|
+
*
|
|
916
|
+
* Scoped to the provider access token captured during THIS callback, so
|
|
917
|
+
* a google login continues to send id_token only and is unaffected.
|
|
918
|
+
*/
|
|
919
|
+
...(idTokenScope.getStore()?.accessToken
|
|
920
|
+
? { access_token: idTokenScope.getStore().accessToken }
|
|
921
|
+
: {}),
|
|
805
922
|
}),
|
|
806
923
|
});
|
|
807
924
|
const oauthResText = await oauthRes.text();
|
|
@@ -833,9 +950,21 @@ async function exchangeOAuthForIdpTokens(sessionToken, provider = 'google') {
|
|
|
833
950
|
claims = { decode: 'FAILED — not a well-formed JWT' };
|
|
834
951
|
}
|
|
835
952
|
}
|
|
953
|
+
const forwardedAccess = idTokenScope.getStore()?.accessToken;
|
|
836
954
|
console.error('[BETTER_AUTH] exchange diagnostics:', {
|
|
837
955
|
id_token_forwarded: !!forwarded,
|
|
838
956
|
id_token_length: forwarded ? forwarded.length : 0,
|
|
957
|
+
/**
|
|
958
|
+
* Read these two for github, and IGNORE the id_token pair above: github
|
|
959
|
+
* issues no id_token, so `id_token_forwarded: false` is its NORMAL state
|
|
960
|
+
* and not the fault. Presence and length only — an access token is
|
|
961
|
+
* credential material and this log is persistent. A github failure with
|
|
962
|
+
* access_token_forwarded:true means the IDP called GitHub and refused
|
|
963
|
+
* the answer (unverified or non-primary email, or app not installed);
|
|
964
|
+
* false means the capture hook never ran and the IDP never called out.
|
|
965
|
+
*/
|
|
966
|
+
access_token_forwarded: !!forwardedAccess,
|
|
967
|
+
access_token_length: forwardedAccess ? forwardedAccess.length : 0,
|
|
839
968
|
// Absent id_token => the IDP had nothing to verify. Present => compare these
|
|
840
969
|
// against the client's registered Google credentials on the IDP.
|
|
841
970
|
aud: claims.aud ?? null,
|
package/package.json
CHANGED
package/scripts/module-reach.mjs
CHANGED
|
@@ -117,7 +117,13 @@ function walkJs(dir, acc = []) {
|
|
|
117
117
|
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
118
118
|
const p = path.join(dir, e.name);
|
|
119
119
|
if (e.isDirectory()) walkJs(p, acc);
|
|
120
|
-
|
|
120
|
+
// `__`-prefixed files in dist are TEST MUTANTS, never tsc output (measured:
|
|
121
|
+
// 0 of 245 in a clean dist). Several suites write one next to the real
|
|
122
|
+
// artifact — so its relative requires resolve — and delete it straight
|
|
123
|
+
// after, while `node --test` runs other files CONCURRENTLY. Counting one
|
|
124
|
+
// would make this census non-reproducible (the property the LIVE test
|
|
125
|
+
// asserts) and reading one after its delete lands is an ENOENT crash.
|
|
126
|
+
else if (e.isFile() && !e.name.startsWith('__') && /\.(js|mjs|cjs)$/.test(e.name)) acc.push(p);
|
|
121
127
|
}
|
|
122
128
|
return acc;
|
|
123
129
|
}
|
package/src/api/auth-handler.ts
CHANGED
|
@@ -15,7 +15,6 @@
|
|
|
15
15
|
|
|
16
16
|
import { NextRequest, NextResponse } from 'next/server';
|
|
17
17
|
import { getSession as getBetterAuthSession } from '../server/auth';
|
|
18
|
-
import { resolveAcrLevel, resolveAuthMethods } from '../lib/acr';
|
|
19
18
|
import { nanoid } from 'nanoid';
|
|
20
19
|
import {
|
|
21
20
|
getSession,
|
|
@@ -245,24 +244,10 @@ export function createAuthHandler(options: AuthHandlerOptions = {}) {
|
|
|
245
244
|
return { success: false, error: 'No refresh token' };
|
|
246
245
|
}
|
|
247
246
|
|
|
248
|
-
// Extract authentication methods from session
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
// authLevel = String(... || ... || '2')
|
|
253
|
-
// so a session that knew NOTHING about how the user authenticated told
|
|
254
|
-
// the IDP that a second factor had happened AND named 'mfa' as the
|
|
255
|
-
// method. The two fabrications corroborated each other, so a downstream
|
|
256
|
-
// check cross-validating amr against acr would have accepted the pair.
|
|
257
|
-
// Absent is not satisfied; both now resolve fail-closed, in one place.
|
|
258
|
-
const authMethods = resolveAuthMethods(
|
|
259
|
-
(currentSession as any).authMethods,
|
|
260
|
-
(currentSession as any).token?.amr
|
|
261
|
-
);
|
|
262
|
-
const authLevel = resolveAcrLevel(
|
|
263
|
-
(currentSession as any).authenticationLevel,
|
|
264
|
-
(currentSession as any).token?.acr
|
|
265
|
-
);
|
|
247
|
+
// Extract authentication methods from session
|
|
248
|
+
const authMethods = (currentSession as any).authMethods ||
|
|
249
|
+
((currentSession as any).token?.amr ? JSON.parse((currentSession as any).token.amr) : ['pwd', 'mfa']);
|
|
250
|
+
const authLevel = String((currentSession as any).authenticationLevel || (currentSession as any).token?.acr || '2');
|
|
266
251
|
const twoFactorMethod = (currentSession as any).twoFactorMethod || 'authenticator';
|
|
267
252
|
|
|
268
253
|
// Build refresh request body
|
package/src/auth/better-auth.ts
CHANGED
|
@@ -125,12 +125,27 @@ export interface BetterAuthSocialProvider {
|
|
|
125
125
|
* globalThis is per-process and per-runtime, which is exactly the lifetime a request-scoped
|
|
126
126
|
* carrier needs to be reachable from every copy that might run inside the request.
|
|
127
127
|
*/
|
|
128
|
+
/**
|
|
129
|
+
* THE KEY STAYS '__payezIdTokenScope' EVEN THOUGH THE SCOPE NOW CARRIES TWO
|
|
130
|
+
* THINGS. Renaming it would give a copy on the old name and a copy on the new
|
|
131
|
+
* name two DIFFERENT stores in the same process — which is precisely the
|
|
132
|
+
* double-load failure documented above, reintroduced by a cosmetic rename. The
|
|
133
|
+
* name is a wire contract between copies, not a description.
|
|
134
|
+
*
|
|
135
|
+
* accessToken is here for providers that do NOT issue an id_token. GitHub is
|
|
136
|
+
* not an OIDC provider: it has no id_token, no JWKS and no discovery document,
|
|
137
|
+
* so `id_token` can never be the field that proves a GitHub identity. The IDP
|
|
138
|
+
* re-derives that identity by calling GitHub itself with this access token
|
|
139
|
+
* (GitHubIdentityVerifier), which keeps the same rule google follows — the IDP
|
|
140
|
+
* verifies for itself and never trusts an asserted email.
|
|
141
|
+
*/
|
|
128
142
|
const ID_TOKEN_SCOPE_KEY = '__payezIdTokenScope';
|
|
143
|
+
type ProviderTokenScope = { idToken?: string; accessToken?: string };
|
|
129
144
|
const gScope = globalThis as unknown as Record<
|
|
130
145
|
string,
|
|
131
|
-
AsyncLocalStorage<
|
|
146
|
+
AsyncLocalStorage<ProviderTokenScope> | undefined
|
|
132
147
|
>;
|
|
133
|
-
gScope[ID_TOKEN_SCOPE_KEY] ??= new AsyncLocalStorage<
|
|
148
|
+
gScope[ID_TOKEN_SCOPE_KEY] ??= new AsyncLocalStorage<ProviderTokenScope>();
|
|
134
149
|
const idTokenScope = gScope[ID_TOKEN_SCOPE_KEY]!;
|
|
135
150
|
|
|
136
151
|
/**
|
|
@@ -242,10 +257,22 @@ export function buildBetterAuthProviders(
|
|
|
242
257
|
* remains, so the provider emits exactly its default set.
|
|
243
258
|
*/
|
|
244
259
|
const GOOGLE_DEFAULT_SCOPES = ['email', 'profile', 'openid'];
|
|
260
|
+
/**
|
|
261
|
+
* Same defect, same fix, different provider: github's createAuthorizationURL
|
|
262
|
+
* also seeds ['read:user','user:email'] unconditionally and then pushes ours
|
|
263
|
+
* on top (@better-auth/core social-providers/github.ts), so the stored row
|
|
264
|
+
* "read:user user:email" reached GitHub as
|
|
265
|
+
* "read:user user:email read:user user:email". Measured on :4799, not
|
|
266
|
+
* assumed. GitHub dedupes, so this is legibility rather than function — but
|
|
267
|
+
* a scope list is the first thing a security reviewer reads off that URL.
|
|
268
|
+
*/
|
|
269
|
+
const GITHUB_DEFAULT_SCOPES = ['read:user', 'user:email'];
|
|
245
270
|
const wireScopes =
|
|
246
271
|
name === 'google'
|
|
247
272
|
? scopes.filter((s) => !GOOGLE_DEFAULT_SCOPES.includes(s))
|
|
248
|
-
:
|
|
273
|
+
: name === 'github'
|
|
274
|
+
? scopes.filter((s) => !GITHUB_DEFAULT_SCOPES.includes(s))
|
|
275
|
+
: scopes;
|
|
249
276
|
|
|
250
277
|
providers[name] = {
|
|
251
278
|
clientId: oauth.clientId,
|
|
@@ -350,6 +377,98 @@ export function buildBetterAuthProviders(
|
|
|
350
377
|
};
|
|
351
378
|
};
|
|
352
379
|
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* GITHUB: THE SAME CAPTURE, A DIFFERENT TOKEN.
|
|
383
|
+
*
|
|
384
|
+
* GitHub is not an OIDC provider — it issues no id_token — so the google
|
|
385
|
+
* branch above can never fire for it and the exchange would post an
|
|
386
|
+
* assertion with nothing that proves identity. That is exactly the
|
|
387
|
+
* 401 UNVERIFIED_ASSERTION measured on :4799, whose diagnostics read
|
|
388
|
+
* `id_token_forwarded: false, id_token_length: 0` with `response_time_ms: 0`:
|
|
389
|
+
* the IDP rejected it instantly, without ever calling GitHub.
|
|
390
|
+
*
|
|
391
|
+
* getUserInfo is the ONLY provider hook that receives the token set (see the
|
|
392
|
+
* google note above), and @better-auth/core's github provider calls
|
|
393
|
+
* `options.getUserInfo(token)` BEFORE its own body — so installing this
|
|
394
|
+
* REPLACES the default rather than wrapping it, and the default's work has
|
|
395
|
+
* to be reproduced here or the sign-in loses its profile entirely.
|
|
396
|
+
*
|
|
397
|
+
* Mirrored verbatim from @better-auth/core/src/social-providers/github.ts at
|
|
398
|
+
* the version the container actually runs (better-auth 1.5.6 — the graft's
|
|
399
|
+
* node_modules says 1.6.29 and is NOT what executes). Kept deliberately
|
|
400
|
+
* dumb: it must not "improve" on upstream, because every divergence here is
|
|
401
|
+
* a behaviour change disguised as a token capture.
|
|
402
|
+
*
|
|
403
|
+
* This is NOT the identity check. The IDP re-derives the GitHub identity
|
|
404
|
+
* itself from this access token and enforces primary && verified server-side
|
|
405
|
+
* (GitHubIdentityVerifier); what we return here only shapes the local
|
|
406
|
+
* better-auth user record.
|
|
407
|
+
*/
|
|
408
|
+
if (name === 'github') {
|
|
409
|
+
(providers[name] as unknown as Record<string, unknown>).getUserInfo = async (token: {
|
|
410
|
+
accessToken?: string;
|
|
411
|
+
}) => {
|
|
412
|
+
const store = idTokenScope.getStore();
|
|
413
|
+
if (store) {
|
|
414
|
+
store.accessToken = token?.accessToken;
|
|
415
|
+
} else {
|
|
416
|
+
console.warn(
|
|
417
|
+
'[BETTER_AUTH] github access_token captured but NO REQUEST SCOPE to put it in — ' +
|
|
418
|
+
'it will NOT be forwarded and the IDP exchange will fail UNVERIFIED_ASSERTION. ' +
|
|
419
|
+
'Check that GET is createAuthGetHandler(), not routes/auth/nextauth.',
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const ghHeaders = {
|
|
424
|
+
'User-Agent': 'better-auth',
|
|
425
|
+
Authorization: `Bearer ${token?.accessToken}`,
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
const profileRes = await fetch('https://api.github.com/user', {
|
|
429
|
+
headers: ghHeaders,
|
|
430
|
+
});
|
|
431
|
+
// Upstream returns null on profile error, which fails the sign-in
|
|
432
|
+
// closed. Preserved rather than softened.
|
|
433
|
+
if (!profileRes.ok) {
|
|
434
|
+
console.warn(
|
|
435
|
+
`[BETTER_AUTH] github getUserInfo: /user returned ${profileRes.status}`,
|
|
436
|
+
);
|
|
437
|
+
return null;
|
|
438
|
+
}
|
|
439
|
+
const profile = (await profileRes.json()) as Record<string, any>;
|
|
440
|
+
|
|
441
|
+
const emailsRes = await fetch('https://api.github.com/user/emails', {
|
|
442
|
+
headers: ghHeaders,
|
|
443
|
+
});
|
|
444
|
+
const emails = emailsRes.ok
|
|
445
|
+
? ((await emailsRes.json()) as Array<{
|
|
446
|
+
email: string;
|
|
447
|
+
primary: boolean;
|
|
448
|
+
verified: boolean;
|
|
449
|
+
}>)
|
|
450
|
+
: undefined;
|
|
451
|
+
|
|
452
|
+
// A GitHub account with a private email returns profile.email = null;
|
|
453
|
+
// upstream falls back to the primary address here.
|
|
454
|
+
if (!profile.email && emails) {
|
|
455
|
+
profile.email = (emails.find((e) => e.primary) ?? emails[0])?.email;
|
|
456
|
+
}
|
|
457
|
+
const emailVerified =
|
|
458
|
+
emails?.find((e) => e.email === profile.email)?.verified ?? false;
|
|
459
|
+
|
|
460
|
+
return {
|
|
461
|
+
user: {
|
|
462
|
+
id: profile.id,
|
|
463
|
+
name: profile.name || profile.login || '',
|
|
464
|
+
email: profile.email,
|
|
465
|
+
image: profile.avatar_url,
|
|
466
|
+
emailVerified,
|
|
467
|
+
},
|
|
468
|
+
data: profile,
|
|
469
|
+
};
|
|
470
|
+
};
|
|
471
|
+
}
|
|
353
472
|
}
|
|
354
473
|
|
|
355
474
|
return providers;
|
|
@@ -900,6 +1019,21 @@ export async function exchangeOAuthForIdpTokens(
|
|
|
900
1019
|
* distinguishable from "the provider issued none".
|
|
901
1020
|
*/
|
|
902
1021
|
...(idTokenScope.getStore()?.idToken ? { id_token: idTokenScope.getStore()!.idToken } : {}),
|
|
1022
|
+
/**
|
|
1023
|
+
* The same field for providers that have no id_token to give.
|
|
1024
|
+
*
|
|
1025
|
+
* Identical doctrine to the id_token above: RAW, forwarded verbatim,
|
|
1026
|
+
* NOTHING DERIVED FROM IT, and omitted entirely rather than sent null so
|
|
1027
|
+
* "never captured" stays distinguishable from "provider issued none".
|
|
1028
|
+
* The IDP verifies for itself by calling GitHub with this token and
|
|
1029
|
+
* enforcing primary && verified; we assert nothing about the identity.
|
|
1030
|
+
*
|
|
1031
|
+
* Scoped to the provider access token captured during THIS callback, so
|
|
1032
|
+
* a google login continues to send id_token only and is unaffected.
|
|
1033
|
+
*/
|
|
1034
|
+
...(idTokenScope.getStore()?.accessToken
|
|
1035
|
+
? { access_token: idTokenScope.getStore()!.accessToken }
|
|
1036
|
+
: {}),
|
|
903
1037
|
}),
|
|
904
1038
|
});
|
|
905
1039
|
|
|
@@ -933,9 +1067,21 @@ export async function exchangeOAuthForIdpTokens(
|
|
|
933
1067
|
claims = { decode: 'FAILED — not a well-formed JWT' };
|
|
934
1068
|
}
|
|
935
1069
|
}
|
|
1070
|
+
const forwardedAccess = idTokenScope.getStore()?.accessToken;
|
|
936
1071
|
console.error('[BETTER_AUTH] exchange diagnostics:', {
|
|
937
1072
|
id_token_forwarded: !!forwarded,
|
|
938
1073
|
id_token_length: forwarded ? forwarded.length : 0,
|
|
1074
|
+
/**
|
|
1075
|
+
* Read these two for github, and IGNORE the id_token pair above: github
|
|
1076
|
+
* issues no id_token, so `id_token_forwarded: false` is its NORMAL state
|
|
1077
|
+
* and not the fault. Presence and length only — an access token is
|
|
1078
|
+
* credential material and this log is persistent. A github failure with
|
|
1079
|
+
* access_token_forwarded:true means the IDP called GitHub and refused
|
|
1080
|
+
* the answer (unverified or non-primary email, or app not installed);
|
|
1081
|
+
* false means the capture hook never ran and the IDP never called out.
|
|
1082
|
+
*/
|
|
1083
|
+
access_token_forwarded: !!forwardedAccess,
|
|
1084
|
+
access_token_length: forwardedAccess ? forwardedAccess.length : 0,
|
|
939
1085
|
// Absent id_token => the IDP had nothing to verify. Present => compare these
|
|
940
1086
|
// against the client's registered Google credentials on the IDP.
|
|
941
1087
|
aud: claims.aud ?? null,
|
package/dist/lib/acr.d.ts
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* CARD 375639 - the one place this package decides what an ABSENT assurance
|
|
3
|
-
* signal means.
|
|
4
|
-
*
|
|
5
|
-
* Card 375276 fixed this question inside `payez_idp_admin_web/src` (commit
|
|
6
|
-
* d9c6ac0, BAPert R980 remedy (a)) and fenced its own scope explicitly:
|
|
7
|
-
*
|
|
8
|
-
* "fallbacks on the stored `session.authenticationLevel` (refresh lane)
|
|
9
|
-
* are deliberately NOT covered ... That gap is 375276's remaining half."
|
|
10
|
-
*
|
|
11
|
-
* `src/api/auth-handler.ts` IS that remaining half, and it is in the shared
|
|
12
|
-
* package rather than in one app, so every consumer of the published
|
|
13
|
-
* `@payez/next-mvp/api/auth-handler` export inherits it.
|
|
14
|
-
*
|
|
15
|
-
* WHY BOTH SIGNALS LIVE IN ONE MODULE
|
|
16
|
-
* -----------------------------------
|
|
17
|
-
* `acr` (how strong the authentication was) and `amr` (which methods were
|
|
18
|
-
* used) are sent to the IDP together in a single refresh body. Defaulting one
|
|
19
|
-
* without the other produces an INCOHERENT assertion - "no second factor
|
|
20
|
-
* happened, and the method used was mfa" - so they are resolved side by side
|
|
21
|
-
* and a change to one is forced to confront the other.
|
|
22
|
-
*
|
|
23
|
-
* MEASURED CONVENTION IN THIS PACKAGE (census, 2026-09-11)
|
|
24
|
-
* -------------------------------------------------------
|
|
25
|
-
* acr absent-defaults: '0' x1, '1' x3, '2' x1 <- the '2' was auth-handler
|
|
26
|
-
* amr absent-defaults: [] x5, ['pwd','mfa'] x1 <- the list was auth-handler
|
|
27
|
-
*
|
|
28
|
-
* Both fail-open values were outliers at a single site, not the convention.
|
|
29
|
-
* These constants adopt the majority, fail-closed convention.
|
|
30
|
-
*/
|
|
31
|
-
/**
|
|
32
|
-
* The assurance level used when the session and token say NOTHING about it.
|
|
33
|
-
*
|
|
34
|
-
* '1' = a single factor. It does not assert that a second factor happened.
|
|
35
|
-
* It must never be '2' (or higher): that is the value the IDP reads as
|
|
36
|
-
* "a second factor was completed", and an absent claim is not evidence of one.
|
|
37
|
-
*/
|
|
38
|
-
export declare const ACR_WHEN_ABSENT = "1";
|
|
39
|
-
/**
|
|
40
|
-
* The authentication-methods list used when nothing is known.
|
|
41
|
-
*
|
|
42
|
-
* Empty asserts nothing. It must never contain 'mfa' (or any second-factor
|
|
43
|
-
* method): claiming a method that was never exercised is the same fabrication
|
|
44
|
-
* as ACR '2', and because the two corroborate each other a downstream check
|
|
45
|
-
* that cross-validates amr against acr would accept the fabricated pair.
|
|
46
|
-
*/
|
|
47
|
-
export declare const AMR_WHEN_ABSENT: readonly string[];
|
|
48
|
-
/**
|
|
49
|
-
* Resolve an assurance level from any number of candidate sources, in
|
|
50
|
-
* priority order, falling back to the level that asserts no second factor.
|
|
51
|
-
*
|
|
52
|
-
* Accepts the candidates the refresh lane actually has: the stored
|
|
53
|
-
* `session.authenticationLevel` first, then the `acr` claim on the token.
|
|
54
|
-
*/
|
|
55
|
-
export declare function resolveAcrLevel(...candidates: unknown[]): string;
|
|
56
|
-
/**
|
|
57
|
-
* Resolve the authentication-methods list.
|
|
58
|
-
*
|
|
59
|
-
* `raw` may arrive as a JSON string (the shape stored on the token) or as an
|
|
60
|
-
* array (the shape held on the session). Anything unparseable resolves to the
|
|
61
|
-
* empty list rather than to an assumed set of methods - a malformed claim is
|
|
62
|
-
* not evidence that mfa happened.
|
|
63
|
-
*/
|
|
64
|
-
export declare function resolveAuthMethods(...candidates: unknown[]): string[];
|
package/dist/lib/acr.js
DELETED
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* CARD 375639 - the one place this package decides what an ABSENT assurance
|
|
4
|
-
* signal means.
|
|
5
|
-
*
|
|
6
|
-
* Card 375276 fixed this question inside `payez_idp_admin_web/src` (commit
|
|
7
|
-
* d9c6ac0, BAPert R980 remedy (a)) and fenced its own scope explicitly:
|
|
8
|
-
*
|
|
9
|
-
* "fallbacks on the stored `session.authenticationLevel` (refresh lane)
|
|
10
|
-
* are deliberately NOT covered ... That gap is 375276's remaining half."
|
|
11
|
-
*
|
|
12
|
-
* `src/api/auth-handler.ts` IS that remaining half, and it is in the shared
|
|
13
|
-
* package rather than in one app, so every consumer of the published
|
|
14
|
-
* `@payez/next-mvp/api/auth-handler` export inherits it.
|
|
15
|
-
*
|
|
16
|
-
* WHY BOTH SIGNALS LIVE IN ONE MODULE
|
|
17
|
-
* -----------------------------------
|
|
18
|
-
* `acr` (how strong the authentication was) and `amr` (which methods were
|
|
19
|
-
* used) are sent to the IDP together in a single refresh body. Defaulting one
|
|
20
|
-
* without the other produces an INCOHERENT assertion - "no second factor
|
|
21
|
-
* happened, and the method used was mfa" - so they are resolved side by side
|
|
22
|
-
* and a change to one is forced to confront the other.
|
|
23
|
-
*
|
|
24
|
-
* MEASURED CONVENTION IN THIS PACKAGE (census, 2026-09-11)
|
|
25
|
-
* -------------------------------------------------------
|
|
26
|
-
* acr absent-defaults: '0' x1, '1' x3, '2' x1 <- the '2' was auth-handler
|
|
27
|
-
* amr absent-defaults: [] x5, ['pwd','mfa'] x1 <- the list was auth-handler
|
|
28
|
-
*
|
|
29
|
-
* Both fail-open values were outliers at a single site, not the convention.
|
|
30
|
-
* These constants adopt the majority, fail-closed convention.
|
|
31
|
-
*/
|
|
32
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
33
|
-
exports.AMR_WHEN_ABSENT = exports.ACR_WHEN_ABSENT = void 0;
|
|
34
|
-
exports.resolveAcrLevel = resolveAcrLevel;
|
|
35
|
-
exports.resolveAuthMethods = resolveAuthMethods;
|
|
36
|
-
/**
|
|
37
|
-
* The assurance level used when the session and token say NOTHING about it.
|
|
38
|
-
*
|
|
39
|
-
* '1' = a single factor. It does not assert that a second factor happened.
|
|
40
|
-
* It must never be '2' (or higher): that is the value the IDP reads as
|
|
41
|
-
* "a second factor was completed", and an absent claim is not evidence of one.
|
|
42
|
-
*/
|
|
43
|
-
exports.ACR_WHEN_ABSENT = '1';
|
|
44
|
-
/**
|
|
45
|
-
* The authentication-methods list used when nothing is known.
|
|
46
|
-
*
|
|
47
|
-
* Empty asserts nothing. It must never contain 'mfa' (or any second-factor
|
|
48
|
-
* method): claiming a method that was never exercised is the same fabrication
|
|
49
|
-
* as ACR '2', and because the two corroborate each other a downstream check
|
|
50
|
-
* that cross-validates amr against acr would accept the fabricated pair.
|
|
51
|
-
*/
|
|
52
|
-
exports.AMR_WHEN_ABSENT = [];
|
|
53
|
-
/**
|
|
54
|
-
* True when an assurance signal carries no information.
|
|
55
|
-
*
|
|
56
|
-
* Deliberately NOT `!value`. The `||` idiom this replaces cannot tell
|
|
57
|
-
* "the token said nothing" from "the token said 0", and numeric 0 is a
|
|
58
|
-
* meaningful assurance level (unauthenticated) that must survive as '0'
|
|
59
|
-
* rather than being promoted to the fallback.
|
|
60
|
-
*/
|
|
61
|
-
function isAbsent(value) {
|
|
62
|
-
return value === undefined || value === null || value === '';
|
|
63
|
-
}
|
|
64
|
-
/**
|
|
65
|
-
* Resolve an assurance level from any number of candidate sources, in
|
|
66
|
-
* priority order, falling back to the level that asserts no second factor.
|
|
67
|
-
*
|
|
68
|
-
* Accepts the candidates the refresh lane actually has: the stored
|
|
69
|
-
* `session.authenticationLevel` first, then the `acr` claim on the token.
|
|
70
|
-
*/
|
|
71
|
-
function resolveAcrLevel(...candidates) {
|
|
72
|
-
for (const candidate of candidates) {
|
|
73
|
-
if (!isAbsent(candidate)) {
|
|
74
|
-
return String(candidate);
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
return exports.ACR_WHEN_ABSENT;
|
|
78
|
-
}
|
|
79
|
-
/**
|
|
80
|
-
* Resolve the authentication-methods list.
|
|
81
|
-
*
|
|
82
|
-
* `raw` may arrive as a JSON string (the shape stored on the token) or as an
|
|
83
|
-
* array (the shape held on the session). Anything unparseable resolves to the
|
|
84
|
-
* empty list rather than to an assumed set of methods - a malformed claim is
|
|
85
|
-
* not evidence that mfa happened.
|
|
86
|
-
*/
|
|
87
|
-
function resolveAuthMethods(...candidates) {
|
|
88
|
-
for (const candidate of candidates) {
|
|
89
|
-
if (isAbsent(candidate))
|
|
90
|
-
continue;
|
|
91
|
-
if (Array.isArray(candidate)) {
|
|
92
|
-
return candidate.map(String);
|
|
93
|
-
}
|
|
94
|
-
if (typeof candidate === 'string') {
|
|
95
|
-
try {
|
|
96
|
-
const parsed = JSON.parse(candidate);
|
|
97
|
-
if (Array.isArray(parsed)) {
|
|
98
|
-
return parsed.map(String);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
catch {
|
|
102
|
-
// A malformed amr claim asserts nothing. Fall through to the next
|
|
103
|
-
// candidate rather than inventing methods.
|
|
104
|
-
continue;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
return [...exports.AMR_WHEN_ABSENT];
|
|
109
|
-
}
|
package/src/lib/acr.ts
DELETED
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* CARD 375639 - the one place this package decides what an ABSENT assurance
|
|
3
|
-
* signal means.
|
|
4
|
-
*
|
|
5
|
-
* Card 375276 fixed this question inside `payez_idp_admin_web/src` (commit
|
|
6
|
-
* d9c6ac0, BAPert R980 remedy (a)) and fenced its own scope explicitly:
|
|
7
|
-
*
|
|
8
|
-
* "fallbacks on the stored `session.authenticationLevel` (refresh lane)
|
|
9
|
-
* are deliberately NOT covered ... That gap is 375276's remaining half."
|
|
10
|
-
*
|
|
11
|
-
* `src/api/auth-handler.ts` IS that remaining half, and it is in the shared
|
|
12
|
-
* package rather than in one app, so every consumer of the published
|
|
13
|
-
* `@payez/next-mvp/api/auth-handler` export inherits it.
|
|
14
|
-
*
|
|
15
|
-
* WHY BOTH SIGNALS LIVE IN ONE MODULE
|
|
16
|
-
* -----------------------------------
|
|
17
|
-
* `acr` (how strong the authentication was) and `amr` (which methods were
|
|
18
|
-
* used) are sent to the IDP together in a single refresh body. Defaulting one
|
|
19
|
-
* without the other produces an INCOHERENT assertion - "no second factor
|
|
20
|
-
* happened, and the method used was mfa" - so they are resolved side by side
|
|
21
|
-
* and a change to one is forced to confront the other.
|
|
22
|
-
*
|
|
23
|
-
* MEASURED CONVENTION IN THIS PACKAGE (census, 2026-09-11)
|
|
24
|
-
* -------------------------------------------------------
|
|
25
|
-
* acr absent-defaults: '0' x1, '1' x3, '2' x1 <- the '2' was auth-handler
|
|
26
|
-
* amr absent-defaults: [] x5, ['pwd','mfa'] x1 <- the list was auth-handler
|
|
27
|
-
*
|
|
28
|
-
* Both fail-open values were outliers at a single site, not the convention.
|
|
29
|
-
* These constants adopt the majority, fail-closed convention.
|
|
30
|
-
*/
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* The assurance level used when the session and token say NOTHING about it.
|
|
34
|
-
*
|
|
35
|
-
* '1' = a single factor. It does not assert that a second factor happened.
|
|
36
|
-
* It must never be '2' (or higher): that is the value the IDP reads as
|
|
37
|
-
* "a second factor was completed", and an absent claim is not evidence of one.
|
|
38
|
-
*/
|
|
39
|
-
export const ACR_WHEN_ABSENT = '1';
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* The authentication-methods list used when nothing is known.
|
|
43
|
-
*
|
|
44
|
-
* Empty asserts nothing. It must never contain 'mfa' (or any second-factor
|
|
45
|
-
* method): claiming a method that was never exercised is the same fabrication
|
|
46
|
-
* as ACR '2', and because the two corroborate each other a downstream check
|
|
47
|
-
* that cross-validates amr against acr would accept the fabricated pair.
|
|
48
|
-
*/
|
|
49
|
-
export const AMR_WHEN_ABSENT: readonly string[] = [];
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* True when an assurance signal carries no information.
|
|
53
|
-
*
|
|
54
|
-
* Deliberately NOT `!value`. The `||` idiom this replaces cannot tell
|
|
55
|
-
* "the token said nothing" from "the token said 0", and numeric 0 is a
|
|
56
|
-
* meaningful assurance level (unauthenticated) that must survive as '0'
|
|
57
|
-
* rather than being promoted to the fallback.
|
|
58
|
-
*/
|
|
59
|
-
function isAbsent(value: unknown): boolean {
|
|
60
|
-
return value === undefined || value === null || value === '';
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Resolve an assurance level from any number of candidate sources, in
|
|
65
|
-
* priority order, falling back to the level that asserts no second factor.
|
|
66
|
-
*
|
|
67
|
-
* Accepts the candidates the refresh lane actually has: the stored
|
|
68
|
-
* `session.authenticationLevel` first, then the `acr` claim on the token.
|
|
69
|
-
*/
|
|
70
|
-
export function resolveAcrLevel(...candidates: unknown[]): string {
|
|
71
|
-
for (const candidate of candidates) {
|
|
72
|
-
if (!isAbsent(candidate)) {
|
|
73
|
-
return String(candidate);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
return ACR_WHEN_ABSENT;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Resolve the authentication-methods list.
|
|
81
|
-
*
|
|
82
|
-
* `raw` may arrive as a JSON string (the shape stored on the token) or as an
|
|
83
|
-
* array (the shape held on the session). Anything unparseable resolves to the
|
|
84
|
-
* empty list rather than to an assumed set of methods - a malformed claim is
|
|
85
|
-
* not evidence that mfa happened.
|
|
86
|
-
*/
|
|
87
|
-
export function resolveAuthMethods(...candidates: unknown[]): string[] {
|
|
88
|
-
for (const candidate of candidates) {
|
|
89
|
-
if (isAbsent(candidate)) continue;
|
|
90
|
-
|
|
91
|
-
if (Array.isArray(candidate)) {
|
|
92
|
-
return candidate.map(String);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
if (typeof candidate === 'string') {
|
|
96
|
-
try {
|
|
97
|
-
const parsed = JSON.parse(candidate);
|
|
98
|
-
if (Array.isArray(parsed)) {
|
|
99
|
-
return parsed.map(String);
|
|
100
|
-
}
|
|
101
|
-
} catch {
|
|
102
|
-
// A malformed amr claim asserts nothing. Fall through to the next
|
|
103
|
-
// candidate rather than inventing methods.
|
|
104
|
-
continue;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
return [...AMR_WHEN_ABSENT];
|
|
109
|
-
}
|