Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 }) => {
Expand Down
19 changes: 19 additions & 0 deletions src/auth/id-token-claims.js
Original file line number Diff line number Diff line change
@@ -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"],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would a fixed ‘github’ value inside the query be… clearer? As opposed to being a second arg?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree, since this method is only for github

);

return result.rows[0]?.accountId ?? null;
}
5 changes: 5 additions & 0 deletions test/helpers/test-instance.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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 }) => {
Expand Down
78 changes: 78 additions & 0 deletions test/integration/jwt-payload.test.js
Original file line number Diff line number Diff line change
@@ -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",
);
});
36 changes: 36 additions & 0 deletions test/unit/id-token-claims.test.js
Original file line number Diff line number Diff line change
@@ -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",
);
});