@@ -742,14 +742,14 @@ describe("provisioning", async (ctx) => {
742742 headers,
743743 } ) ;
744744 const member = org ?. members . find (
745- ( m : any ) => m . user . email === "sso-user @localhost:8000 .com" ,
745+ ( m : any ) => m . user . email === "test @localhost.com" ,
746746 ) ;
747747 expect ( member ) . toMatchObject ( {
748748 role : "member" ,
749749 user : {
750750 id : expect . any ( String ) ,
751- name : "Test User " ,
752- email : "sso-user @localhost:8000 .com" ,
751+ name : "OAuth2 Test " ,
752+ email : "test @localhost.com" ,
753753 image : "https://test.com/picture.png" ,
754754 } ,
755755 } ) ;
@@ -1261,3 +1261,133 @@ describe("OIDC SSO with defaultSSO array", async () => {
12611261 ) ;
12621262 } ) ;
12631263} ) ;
1264+
1265+ /**
1266+ * @see https://github.com/better-auth/better-auth/issues/8269
1267+ */
1268+ describe ( "SSO OIDC UserInfo endpoint sub claim mapping" , async ( ) => {
1269+ const { auth, signInWithTestUser, customFetchImpl, cookieSetter } =
1270+ await getTestInstance ( {
1271+ trustedOrigins : [ "http://localhost:8080" ] ,
1272+ plugins : [ sso ( ) , organization ( ) ] ,
1273+ } ) ;
1274+
1275+ const authClient = createAuthClient ( {
1276+ plugins : [ ssoClient ( ) ] ,
1277+ baseURL : "http://localhost:3000" ,
1278+ fetchOptions : { customFetchImpl } ,
1279+ } ) ;
1280+
1281+ const userinfoHandler = ( userInfoResponse : any ) => {
1282+ userInfoResponse . body = {
1283+ sub : "userinfo-only-sub-id" ,
1284+ email : "userinfo-only@test.com" ,
1285+ name : "UserInfo Only User" ,
1286+ picture : "https://test.com/picture.png" ,
1287+ email_verified : true ,
1288+ } ;
1289+ userInfoResponse . statusCode = 200 ;
1290+ } ;
1291+
1292+ // Strip the id_token from the token endpoint response to simulate providers
1293+ // that do not include user claims in the ID token (or return no ID token).
1294+ const beforeResponseHandler = ( tokenEndpointResponse : any ) => {
1295+ tokenEndpointResponse . body . id_token = undefined ;
1296+ } ;
1297+
1298+ const tokenHandler = ( token : any ) => {
1299+ // Intentionally leave the token payload minimal — no email claim —
1300+ // so that the UserInfo endpoint path is exercised.
1301+ } ;
1302+
1303+ beforeAll ( async ( ) => {
1304+ await server . issuer . keys . generate ( "RS256" ) ;
1305+ server . service . removeAllListeners ( "beforeUserinfo" ) ;
1306+ server . service . removeAllListeners ( "beforeTokenSigning" ) ;
1307+ server . service . removeAllListeners ( "beforeResponse" ) ;
1308+ server . service . on ( "beforeUserinfo" , userinfoHandler ) ;
1309+ server . service . on ( "beforeTokenSigning" , tokenHandler ) ;
1310+ server . service . on ( "beforeResponse" , beforeResponseHandler ) ;
1311+ await server . start ( 8080 , "localhost" ) ;
1312+ } ) ;
1313+
1314+ afterAll ( async ( ) => {
1315+ server . service . removeListener ( "beforeUserinfo" , userinfoHandler ) ;
1316+ server . service . removeListener ( "beforeTokenSigning" , tokenHandler ) ;
1317+ server . service . removeListener ( "beforeResponse" , beforeResponseHandler ) ;
1318+ await server . stop ( ) . catch ( ( ) => { } ) ;
1319+ } ) ;
1320+
1321+ async function simulateOAuthFlow ( authUrl : string , headers : Headers ) {
1322+ let location : string | null = null ;
1323+ await betterFetch ( authUrl , {
1324+ method : "GET" ,
1325+ redirect : "manual" ,
1326+ onError ( context ) {
1327+ location = context . response . headers . get ( "location" ) ;
1328+ } ,
1329+ } ) ;
1330+
1331+ if ( ! location ) throw new Error ( "No redirect location found" ) ;
1332+ const newHeaders = new Headers ( ) ;
1333+ let callbackURL = "" ;
1334+ await betterFetch ( location , {
1335+ method : "GET" ,
1336+ customFetchImpl,
1337+ headers,
1338+ onError ( context ) {
1339+ callbackURL = context . response . headers . get ( "location" ) || "" ;
1340+ cookieSetter ( newHeaders ) ( context ) ;
1341+ } ,
1342+ } ) ;
1343+
1344+ return { callbackURL, headers : newHeaders } ;
1345+ }
1346+
1347+ it ( "should sign in successfully using sub claim from UserInfo endpoint when no ID token is returned" , async ( ) => {
1348+ const { headers } = await signInWithTestUser ( ) ;
1349+ await auth . api . registerSSOProvider ( {
1350+ body : {
1351+ issuer : server . issuer . url ! ,
1352+ domain : "test.com" ,
1353+ providerId : "userinfo-sub-test" ,
1354+ oidcConfig : {
1355+ clientId : "test" ,
1356+ clientSecret : "test" ,
1357+ authorizationEndpoint : `${ server . issuer . url } /authorize` ,
1358+ tokenEndpoint : `${ server . issuer . url } /token` ,
1359+ jwksEndpoint : `${ server . issuer . url } /jwks` ,
1360+ userInfoEndpoint : `${ server . issuer . url } /userinfo` ,
1361+ discoveryEndpoint : `${ server . issuer . url } /.well-known/openid-configuration` ,
1362+ } ,
1363+ } ,
1364+ headers,
1365+ } ) ;
1366+
1367+ const signInHeaders = new Headers ( ) ;
1368+ const res = await authClient . signIn . sso ( {
1369+ email : "user@test.com" ,
1370+ callbackURL : "/dashboard" ,
1371+ fetchOptions : {
1372+ throw : true ,
1373+ onSuccess : cookieSetter ( signInHeaders ) ,
1374+ } ,
1375+ } ) ;
1376+
1377+ const { callbackURL, headers : sessionHeaders } = await simulateOAuthFlow (
1378+ res . url ,
1379+ signInHeaders ,
1380+ ) ;
1381+
1382+ // Should redirect to dashboard, not an error page
1383+ expect ( callbackURL ) . toContain ( "/dashboard" ) ;
1384+ expect ( callbackURL ) . not . toContain ( "error=invalid_provider" ) ;
1385+ expect ( callbackURL ) . not . toContain ( "missing_user_info" ) ;
1386+
1387+ // Verify the session was created with the correct email from UserInfo
1388+ const session = await authClient . getSession ( {
1389+ fetchOptions : { headers : sessionHeaders } ,
1390+ } ) ;
1391+ expect ( session . data ?. user . email ) . toBe ( "userinfo-only@test.com" ) ;
1392+ } ) ;
1393+ } ) ;
0 commit comments