From 83b61600b6c8d66f9ec04788e3e23ed5ffc3a483 Mon Sep 17 00:00:00 2001 From: Morgan Roderick Date: Tue, 11 Aug 2026 10:09:56 +0200 Subject: [PATCH] feat(auth): include linked GitHub account id in OAuth id_token --- src/auth.js | 5 ++ src/auth/id-token-claims.js | 19 +++++++ test/helpers/test-instance.js | 5 ++ test/integration/jwt-payload.test.js | 78 ++++++++++++++++++++++++++++ test/unit/id-token-claims.test.js | 36 +++++++++++++ 5 files changed, 143 insertions(+) create mode 100644 src/auth/id-token-claims.js create mode 100644 test/integration/jwt-payload.test.js create mode 100644 test/unit/id-token-claims.test.js diff --git a/src/auth.js b/src/auth.js index 7c0beb8..ab3696c 100644 --- a/src/auth.js +++ b/src/auth.js @@ -4,6 +4,7 @@ import { admin, magicLink, jwt } from "better-auth/plugins"; import { oauthProvider } from "@better-auth/oauth-provider"; import appConfig from "./config.js"; import { getGithubUserInfo } from "./auth/github-provider.js"; +import { getGithubAccountId } from "./auth/id-token-claims.js"; import { devMagicLinks } from "./dev/magic-links.js"; import { buildMagicLinkPayload } from "./app/utils/magic-link-email.js"; @@ -82,6 +83,10 @@ export const auth = betterAuth({ accessTokenExpiresIn: 900, // 15 minutes validAudiences: ["planner"], allowDynamicClientRegistration: false, + customIdTokenClaims: async ({ user }) => { + const githubId = await getGithubAccountId(db, user.id); + return githubId ? { github_id: String(githubId) } : {}; + }, }), magicLink({ sendMagicLink: async ({ email, url }) => { diff --git a/src/auth/id-token-claims.js b/src/auth/id-token-claims.js new file mode 100644 index 0000000..f38f9b1 --- /dev/null +++ b/src/auth/id-token-claims.js @@ -0,0 +1,19 @@ +/** + * Resolve the linked GitHub account id for a user. + * + * The planner uses this stable id to match returning members whose GitHub + * email differs from their stored planner email. + * + * Failures are allowed to propagate: a transient database error here would + * otherwise silently omit `github_id` and cause the planner to fall back to + * email matching, re-creating the duplicate-member bug this claim is meant + * to prevent. + */ +export async function getGithubAccountId(db, userId) { + const result = await db.query( + 'SELECT "accountId" FROM "account" WHERE "userId" = $1 AND "providerId" = $2 LIMIT 1', + [userId, "github"], + ); + + return result.rows[0]?.accountId ?? null; +} diff --git a/test/helpers/test-instance.js b/test/helpers/test-instance.js index d1059af..02af420 100644 --- a/test/helpers/test-instance.js +++ b/test/helpers/test-instance.js @@ -4,6 +4,7 @@ import { admin, magicLink, jwt } from "better-auth/plugins"; import { oauthProvider } from "@better-auth/oauth-provider"; import { getMigrations } from "better-auth/db/migration"; import { seedPlannerClient } from "../../src/app/db/seed-client.js"; +import { getGithubAccountId } from "../../src/auth/id-token-claims.js"; import { AUTH_DEFAULT_PORT, PLANNER_DEFAULT_PORT } from "../../src/config.js"; /** @@ -95,6 +96,10 @@ export async function getTestInstance(t) { accessTokenExpiresIn: 900, validAudiences: ["planner"], allowDynamicClientRegistration: false, + customIdTokenClaims: async ({ user }) => { + const githubId = await getGithubAccountId(pool, user.id); + return githubId ? { github_id: String(githubId) } : {}; + }, }), magicLink({ sendMagicLink: async ({ email, token, url }) => { diff --git a/test/integration/jwt-payload.test.js b/test/integration/jwt-payload.test.js new file mode 100644 index 0000000..e3c1855 --- /dev/null +++ b/test/integration/jwt-payload.test.js @@ -0,0 +1,78 @@ +import { test } from "tap"; +import { randomUUID } from "node:crypto"; +import { getTestInstance } from "../helpers/test-instance.js"; +import { createApp } from "../../src/app/app.js"; +import { + authorizeParams, + authorizeAndGetCode, + tokenBody, +} from "../helpers/oauth-flow.js"; + +function decodeJwtPayload(idToken) { + return JSON.parse(Buffer.from(idToken.split(".")[1], "base64url").toString()); +} + +test("id_token includes github_id for users with a linked GitHub account", async (t) => { + const testInstance = await getTestInstance(t); + const app = createApp(testInstance.auth, testInstance.db); + const { getAuthHeaders } = testInstance; + const email = "github-linked@example.com"; + + const authHeaders = await getAuthHeaders(email); + + const userResult = await testInstance.db.query( + 'SELECT id FROM "user" WHERE email = $1', + [email], + ); + const userId = userResult.rows[0]?.id; + t.ok(userId, "user record found"); + + await testInstance.db.query( + 'INSERT INTO "account" ("id", "accountId", "providerId", "userId", "createdAt", "updatedAt") VALUES ($1, $2, $3, $4, NOW(), NOW())', + [randomUUID(), "987654321", "github", userId], + ); + + const params = authorizeParams({ state: "jwt-github-state" }); + const { code } = await authorizeAndGetCode(app, authHeaders, params); + + const tokenRes = await app.request("/api/auth/oauth2/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: tokenBody(code), + }); + + t.equal(tokenRes.status, 200, "token endpoint returns 200"); + const body = await tokenRes.json(); + t.ok(body.id_token, "response contains id_token"); + + const payload = decodeJwtPayload(body.id_token); + t.equal(payload.github_id, "987654321", "payload includes linked github_id"); +}); + +test("id_token omits github_id for users without a linked GitHub account", async (t) => { + const testInstance = await getTestInstance(t); + const app = createApp(testInstance.auth, testInstance.db); + const { getAuthHeaders } = testInstance; + const email = "magic-link-only@example.com"; + + const authHeaders = await getAuthHeaders(email); + + const params = authorizeParams({ state: "jwt-no-github-state" }); + const { code } = await authorizeAndGetCode(app, authHeaders, params); + + const tokenRes = await app.request("/api/auth/oauth2/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: tokenBody(code), + }); + + t.equal(tokenRes.status, 200, "token endpoint returns 200"); + const body = await tokenRes.json(); + t.ok(body.id_token, "response contains id_token"); + + const payload = decodeJwtPayload(body.id_token); + t.notOk( + Object.prototype.hasOwnProperty.call(payload, "github_id"), + "payload does not include github_id", + ); +}); diff --git a/test/unit/id-token-claims.test.js b/test/unit/id-token-claims.test.js new file mode 100644 index 0000000..809d108 --- /dev/null +++ b/test/unit/id-token-claims.test.js @@ -0,0 +1,36 @@ +import { test } from "tap"; +import { getGithubAccountId } from "../../src/auth/id-token-claims.js"; + +function makeDb(rows) { + return { + query: async () => { + return { rows }; + }, + }; +} + +test("returns the linked GitHub account id", async (t) => { + const db = makeDb([{ accountId: "12345" }]); + const id = await getGithubAccountId(db, "user-1"); + t.equal(id, "12345"); +}); + +test("returns null when the user has no linked GitHub account", async (t) => { + const db = makeDb([]); + const id = await getGithubAccountId(db, "user-1"); + t.equal(id, null); +}); + +test("propagates database errors instead of swallowing them", async (t) => { + const db = { + query: async () => { + throw new Error("connection lost"); + }, + }; + + await t.rejects( + () => getGithubAccountId(db, "user-1"), + { message: "connection lost" }, + "database error is propagated", + ); +});