Skip to content

Commit f88bcd7

Browse files
himself65Copilot
andcommitted
fix(sso): prefer UserInfo endpoint over ID token and map sub claim correctly (#8276)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 668957c commit f88bcd7

File tree

2 files changed

+176
-34
lines changed

2 files changed

+176
-34
lines changed

packages/sso/src/oidc.test.ts

Lines changed: 133 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});

packages/sso/src/routes/sso.ts

Lines changed: 43 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1630,7 +1630,43 @@ async function handleOIDCCallback(
16301630
emailVerified?: boolean;
16311631
[key: string]: any;
16321632
} | null = null;
1633-
if (tokenResponse.idToken) {
1633+
const mapping = config.mapping || {};
1634+
1635+
if (config.userInfoEndpoint) {
1636+
const userInfoResponse = await betterFetch<Record<string, unknown>>(
1637+
config.userInfoEndpoint,
1638+
{
1639+
headers: {
1640+
Authorization: `Bearer ${tokenResponse.accessToken}`,
1641+
},
1642+
},
1643+
);
1644+
if (userInfoResponse.error) {
1645+
throw ctx.redirect(
1646+
`${errorURL || callbackURL}?error=invalid_provider&error_description=${
1647+
userInfoResponse.error.message
1648+
}`,
1649+
);
1650+
}
1651+
const rawUserInfo = userInfoResponse.data;
1652+
userInfo = {
1653+
...Object.fromEntries(
1654+
Object.entries(mapping.extraFields || {}).map(([key, value]) => [
1655+
key,
1656+
rawUserInfo[value],
1657+
]),
1658+
),
1659+
id: rawUserInfo[mapping.id || "sub"] as string | undefined,
1660+
email: rawUserInfo[mapping.email || "email"] as string | undefined,
1661+
emailVerified: options?.trustEmailVerified
1662+
? (rawUserInfo[mapping.emailVerified || "email_verified"] as
1663+
| boolean
1664+
| undefined)
1665+
: false,
1666+
name: rawUserInfo[mapping.name || "name"] as string | undefined,
1667+
image: rawUserInfo[mapping.image || "picture"] as string | undefined,
1668+
};
1669+
} else if (tokenResponse.idToken) {
16341670
const idToken = decodeJwt(tokenResponse.idToken);
16351671
if (!config.jwksEndpoint) {
16361672
throw ctx.redirect(
@@ -1658,7 +1694,6 @@ async function handleOIDCCallback(
16581694
);
16591695
}
16601696

1661-
const mapping = config.mapping || {};
16621697
userInfo = {
16631698
...Object.fromEntries(
16641699
Object.entries(mapping.extraFields || {}).map(([key, value]) => [
@@ -1680,35 +1715,12 @@ async function handleOIDCCallback(
16801715
image?: string;
16811716
emailVerified?: boolean;
16821717
};
1683-
}
1684-
1685-
if (!userInfo) {
1686-
if (!config.userInfoEndpoint) {
1687-
throw ctx.redirect(
1688-
`${
1689-
errorURL || callbackURL
1690-
}?error=invalid_provider&error_description=user_info_endpoint_not_found`,
1691-
);
1692-
}
1693-
const userInfoResponse = await betterFetch<{
1694-
email?: string;
1695-
name?: string;
1696-
id?: string;
1697-
image?: string;
1698-
emailVerified?: boolean;
1699-
}>(config.userInfoEndpoint, {
1700-
headers: {
1701-
Authorization: `Bearer ${tokenResponse.accessToken}`,
1702-
},
1703-
});
1704-
if (userInfoResponse.error) {
1705-
throw ctx.redirect(
1706-
`${errorURL || callbackURL}?error=invalid_provider&error_description=${
1707-
userInfoResponse.error.message
1708-
}`,
1709-
);
1710-
}
1711-
userInfo = userInfoResponse.data;
1718+
} else {
1719+
throw ctx.redirect(
1720+
`${
1721+
errorURL || callbackURL
1722+
}?error=invalid_provider&error_description=user_info_endpoint_not_found`,
1723+
);
17121724
}
17131725

17141726
if (!userInfo.email || !userInfo.id) {

0 commit comments

Comments
 (0)