Skip to content

Commit ce12016

Browse files
committed
fix: always emit email and name claims in the planner id_token
better-auth 1.7 stopped including the user-record email and name claims in the id_token for sessions not created by a social provider. The planner resolves members by the id_token email claim and falls back to sub (the better-auth user id) when the claim is absent, which silently created duplicate planner members keyed by an opaque 32-character id. Emit email and name explicitly in customIdTokenClaims for every session type. Also align the test suite with production: the test instance now declares the email scope, the flow helpers request it, and the integration test asserts the claims for a magic-link-only user.
1 parent a7bbf73 commit ce12016

4 files changed

Lines changed: 26 additions & 5 deletions

File tree

‎src/auth.js‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,9 @@ export const auth = betterAuth({
8484
validAudiences: ["planner"],
8585
allowDynamicClientRegistration: false,
8686
customIdTokenClaims: async ({ user }) => {
87+
const claims = { email: user.email, name: user.name };
8788
const githubId = await getGithubAccountId(db, user.id);
88-
return githubId ? { github_id: String(githubId) } : {};
89+
return githubId ? { ...claims, github_id: String(githubId) } : claims;
8990
},
9091
}),
9192
magicLink({

‎test/helpers/oauth-flow.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export function authorizeParams(overrides = {}) {
99
client_id: "planner",
1010
redirect_uri: REDIRECT_URI,
1111
response_type: "code",
12-
scope: "openid profile",
12+
scope: "openid profile email",
1313
code_challenge: CODE_CHALLENGE,
1414
code_challenge_method: "S256",
1515
...overrides,

‎test/helpers/test-instance.js‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,13 +92,14 @@ export async function getTestInstance(t) {
9292
}),
9393
oauthProvider({
9494
loginPage: "/login",
95-
scopes: ["openid", "profile"],
95+
scopes: ["openid", "profile", "email"],
9696
accessTokenExpiresIn: 900,
9797
validAudiences: ["planner"],
9898
allowDynamicClientRegistration: false,
9999
customIdTokenClaims: async ({ user }) => {
100+
const claims = { email: user.email, name: user.name };
100101
const githubId = await getGithubAccountId(pool, user.id);
101-
return githubId ? { github_id: String(githubId) } : {};
102+
return githubId ? { ...claims, github_id: String(githubId) } : claims;
102103
},
103104
}),
104105
magicLink({

‎test/integration/oauth-flow.test.js‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ test("end-to-end OAuth 2.1 flow", async (t) => {
2121
redirect_uri: "http://localhost:3000/auth/codebar/callback",
2222
response_type: "code",
2323
state: "integration-state",
24-
scope: "openid profile",
24+
scope: "openid profile email",
2525
code_challenge: codeChallenge,
2626
code_challenge_method: "S256",
2727
});
@@ -98,6 +98,25 @@ test("end-to-end OAuth 2.1 flow", async (t) => {
9898
t.ok(payload.iat, "payload has issued-at");
9999
t.ok(payload.exp, "payload has expiration");
100100

101+
// The planner resolves members by the id_token email claim. When the claim
102+
// is missing it falls back to `sub` (the better-auth user id), which creates
103+
// a duplicate planner member keyed by an opaque id.
104+
const userRow = await testInstance.pool.query(
105+
'SELECT email, name FROM "user" WHERE email = $1',
106+
[email],
107+
);
108+
t.equal(userRow.rows.length, 1, "test user exists");
109+
t.equal(
110+
payload.email,
111+
userRow.rows[0].email,
112+
"payload carries the user's email claim",
113+
);
114+
t.equal(
115+
payload.name,
116+
userRow.rows[0].name,
117+
"payload carries the user's name claim",
118+
);
119+
101120
// Step 5: Verify the access token is usable (e.g., for userinfo if we had one)
102121
// Note: introspection requires client authentication, which is skipped here
103122
// since the core flow (authorize -> code -> token -> JWT) is fully validated.

0 commit comments

Comments
 (0)