Skip to content

Commit 83b6160

Browse files
committed
feat(auth): include linked GitHub account id in OAuth id_token
1 parent 899a4c2 commit 83b6160

5 files changed

Lines changed: 143 additions & 0 deletions

File tree

src/auth.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { admin, magicLink, jwt } from "better-auth/plugins";
44
import { oauthProvider } from "@better-auth/oauth-provider";
55
import appConfig from "./config.js";
66
import { getGithubUserInfo } from "./auth/github-provider.js";
7+
import { getGithubAccountId } from "./auth/id-token-claims.js";
78
import { devMagicLinks } from "./dev/magic-links.js";
89
import { buildMagicLinkPayload } from "./app/utils/magic-link-email.js";
910

@@ -82,6 +83,10 @@ export const auth = betterAuth({
8283
accessTokenExpiresIn: 900, // 15 minutes
8384
validAudiences: ["planner"],
8485
allowDynamicClientRegistration: false,
86+
customIdTokenClaims: async ({ user }) => {
87+
const githubId = await getGithubAccountId(db, user.id);
88+
return githubId ? { github_id: String(githubId) } : {};
89+
},
8590
}),
8691
magicLink({
8792
sendMagicLink: async ({ email, url }) => {

src/auth/id-token-claims.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Resolve the linked GitHub account id for a user.
3+
*
4+
* The planner uses this stable id to match returning members whose GitHub
5+
* email differs from their stored planner email.
6+
*
7+
* Failures are allowed to propagate: a transient database error here would
8+
* otherwise silently omit `github_id` and cause the planner to fall back to
9+
* email matching, re-creating the duplicate-member bug this claim is meant
10+
* to prevent.
11+
*/
12+
export async function getGithubAccountId(db, userId) {
13+
const result = await db.query(
14+
'SELECT "accountId" FROM "account" WHERE "userId" = $1 AND "providerId" = $2 LIMIT 1',
15+
[userId, "github"],
16+
);
17+
18+
return result.rows[0]?.accountId ?? null;
19+
}

test/helpers/test-instance.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { admin, magicLink, jwt } from "better-auth/plugins";
44
import { oauthProvider } from "@better-auth/oauth-provider";
55
import { getMigrations } from "better-auth/db/migration";
66
import { seedPlannerClient } from "../../src/app/db/seed-client.js";
7+
import { getGithubAccountId } from "../../src/auth/id-token-claims.js";
78
import { AUTH_DEFAULT_PORT, PLANNER_DEFAULT_PORT } from "../../src/config.js";
89

910
/**
@@ -95,6 +96,10 @@ export async function getTestInstance(t) {
9596
accessTokenExpiresIn: 900,
9697
validAudiences: ["planner"],
9798
allowDynamicClientRegistration: false,
99+
customIdTokenClaims: async ({ user }) => {
100+
const githubId = await getGithubAccountId(pool, user.id);
101+
return githubId ? { github_id: String(githubId) } : {};
102+
},
98103
}),
99104
magicLink({
100105
sendMagicLink: async ({ email, token, url }) => {
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { test } from "tap";
2+
import { randomUUID } from "node:crypto";
3+
import { getTestInstance } from "../helpers/test-instance.js";
4+
import { createApp } from "../../src/app/app.js";
5+
import {
6+
authorizeParams,
7+
authorizeAndGetCode,
8+
tokenBody,
9+
} from "../helpers/oauth-flow.js";
10+
11+
function decodeJwtPayload(idToken) {
12+
return JSON.parse(Buffer.from(idToken.split(".")[1], "base64url").toString());
13+
}
14+
15+
test("id_token includes github_id for users with a linked GitHub account", async (t) => {
16+
const testInstance = await getTestInstance(t);
17+
const app = createApp(testInstance.auth, testInstance.db);
18+
const { getAuthHeaders } = testInstance;
19+
const email = "github-linked@example.com";
20+
21+
const authHeaders = await getAuthHeaders(email);
22+
23+
const userResult = await testInstance.db.query(
24+
'SELECT id FROM "user" WHERE email = $1',
25+
[email],
26+
);
27+
const userId = userResult.rows[0]?.id;
28+
t.ok(userId, "user record found");
29+
30+
await testInstance.db.query(
31+
'INSERT INTO "account" ("id", "accountId", "providerId", "userId", "createdAt", "updatedAt") VALUES ($1, $2, $3, $4, NOW(), NOW())',
32+
[randomUUID(), "987654321", "github", userId],
33+
);
34+
35+
const params = authorizeParams({ state: "jwt-github-state" });
36+
const { code } = await authorizeAndGetCode(app, authHeaders, params);
37+
38+
const tokenRes = await app.request("/api/auth/oauth2/token", {
39+
method: "POST",
40+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
41+
body: tokenBody(code),
42+
});
43+
44+
t.equal(tokenRes.status, 200, "token endpoint returns 200");
45+
const body = await tokenRes.json();
46+
t.ok(body.id_token, "response contains id_token");
47+
48+
const payload = decodeJwtPayload(body.id_token);
49+
t.equal(payload.github_id, "987654321", "payload includes linked github_id");
50+
});
51+
52+
test("id_token omits github_id for users without a linked GitHub account", async (t) => {
53+
const testInstance = await getTestInstance(t);
54+
const app = createApp(testInstance.auth, testInstance.db);
55+
const { getAuthHeaders } = testInstance;
56+
const email = "magic-link-only@example.com";
57+
58+
const authHeaders = await getAuthHeaders(email);
59+
60+
const params = authorizeParams({ state: "jwt-no-github-state" });
61+
const { code } = await authorizeAndGetCode(app, authHeaders, params);
62+
63+
const tokenRes = await app.request("/api/auth/oauth2/token", {
64+
method: "POST",
65+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
66+
body: tokenBody(code),
67+
});
68+
69+
t.equal(tokenRes.status, 200, "token endpoint returns 200");
70+
const body = await tokenRes.json();
71+
t.ok(body.id_token, "response contains id_token");
72+
73+
const payload = decodeJwtPayload(body.id_token);
74+
t.notOk(
75+
Object.prototype.hasOwnProperty.call(payload, "github_id"),
76+
"payload does not include github_id",
77+
);
78+
});

test/unit/id-token-claims.test.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { test } from "tap";
2+
import { getGithubAccountId } from "../../src/auth/id-token-claims.js";
3+
4+
function makeDb(rows) {
5+
return {
6+
query: async () => {
7+
return { rows };
8+
},
9+
};
10+
}
11+
12+
test("returns the linked GitHub account id", async (t) => {
13+
const db = makeDb([{ accountId: "12345" }]);
14+
const id = await getGithubAccountId(db, "user-1");
15+
t.equal(id, "12345");
16+
});
17+
18+
test("returns null when the user has no linked GitHub account", async (t) => {
19+
const db = makeDb([]);
20+
const id = await getGithubAccountId(db, "user-1");
21+
t.equal(id, null);
22+
});
23+
24+
test("propagates database errors instead of swallowing them", async (t) => {
25+
const db = {
26+
query: async () => {
27+
throw new Error("connection lost");
28+
},
29+
};
30+
31+
await t.rejects(
32+
() => getGithubAccountId(db, "user-1"),
33+
{ message: "connection lost" },
34+
"database error is propagated",
35+
);
36+
});

0 commit comments

Comments
 (0)