diff --git a/src/auth.js b/src/auth.js index 596290b..1059046 100644 --- a/src/auth.js +++ b/src/auth.js @@ -3,6 +3,7 @@ import { betterAuth } from "better-auth"; 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 { devMagicLinks } from "./dev/magic-links.js"; import { buildMagicLinkPayload } from "./app/utils/magic-link-email.js"; @@ -35,6 +36,9 @@ export const auth = betterAuth({ github: { clientId: appConfig.social.github.id, clientSecret: appConfig.social.github.secret, + // Resolve the account email from /user/emails (primary + verified) so + // returning GitHub users match their existing planner accounts. + getUserInfo: getGithubUserInfo, }, }, telemetry: { diff --git a/src/auth/github-provider.js b/src/auth/github-provider.js new file mode 100644 index 0000000..b1cb113 --- /dev/null +++ b/src/auth/github-provider.js @@ -0,0 +1,62 @@ +// GitHub requires a descriptive User-Agent on API requests. +const GITHUB_API = "https://api.github.com"; +const USER_AGENT = "codebar-auth"; + +/** + * GET a GitHub API endpoint with the user's access token. + * Returns parsed JSON, or null on a non-2xx response. + */ +async function githubFetch(path, accessToken) { + try { + const res = await fetch(`${GITHUB_API}${path}`, { + headers: { + Authorization: `Bearer ${accessToken}`, + "User-Agent": USER_AGENT, + Accept: "application/vnd.github+json", + }, + }); + if (!res.ok) return null; + return res.json(); + } catch { + // Fail soft: a network error here must not turn the OAuth callback into a + // 500. Better Auth's default getUserInfo returns null on failure too. + return null; + } +} + +/** + * Build the better-auth GitHub user from GitHub's profile + email endpoints. + * + * Resolves the email the same way the planner's legacy omniauth-github + * integration did: prefer the primary and verified address from + * /user/emails. Better Auth's default prioritises /user.email (the public + * profile email), which routinely differs from the primary account email and + * made returning users appear to be new signups. + * + * Returns the shape better-auth's github provider getUserInfo hook expects: + * `{ user, data }`. + */ +export async function getGithubUserInfo({ accessToken }) { + const [profile, emails] = await Promise.all([ + githubFetch("/user", accessToken), + githubFetch("/user/emails", accessToken), + ]); + + const primary = emails?.find((e) => e.primary && e.verified); + const email = primary?.email || profile?.email || emails?.[0]?.email || ""; + const emailVerified = + primary !== undefined + ? true + : (emails?.some((e) => e.email === email && e.verified) ?? false); + + return { + user: { + id: String(profile?.id ?? ""), + name: profile?.name || profile?.login || "", + email, + image: profile?.avatar_url, + emailVerified, + }, + data: profile, + }; +} diff --git a/test/unit/github-provider.test.js b/test/unit/github-provider.test.js new file mode 100644 index 0000000..ee17722 --- /dev/null +++ b/test/unit/github-provider.test.js @@ -0,0 +1,103 @@ +import { test } from "tap"; +import { getGithubUserInfo } from "../../src/auth/github-provider.js"; + +const originalFetch = global.fetch; + +function mockFetch(routes) { + global.fetch = async (url) => { + const path = new URL(url).pathname; + const body = routes[path]; + if (!body) { + return { ok: false, json: async () => null }; + } + return { ok: true, json: async () => body }; + }; +} + +function afterFetch(t) { + t.after(() => { + global.fetch = originalFetch; + }); +} + +test("prefers primary verified email over the public profile email", async (t) => { + afterFetch(t); + mockFetch({ + "/user": { + id: 12345678, + login: "jdoe", + name: "Jane Doe", + email: "jane@example.com", + }, + "/user/emails": [ + { email: "jane@example.com", primary: false, verified: true }, + { email: "jane.doe@work.example", primary: true, verified: true }, + ], + }); + + const { user } = await getGithubUserInfo({ accessToken: "token" }); + + t.equal(user.email, "jane.doe@work.example"); + t.equal(user.emailVerified, true); + t.equal(user.id, "12345678"); + t.equal(user.name, "Jane Doe"); +}); + +test("falls back to the public profile email when no primary is verified", async (t) => { + afterFetch(t); + mockFetch({ + "/user": { + id: 1, + login: "alice", + name: "Alice", + email: "alice@example.com", + }, + "/user/emails": [ + { email: "alice@example.com", primary: true, verified: false }, + ], + }); + + const { user } = await getGithubUserInfo({ accessToken: "token" }); + + t.equal(user.email, "alice@example.com"); + t.equal(user.emailVerified, false); +}); + +test("uses the public profile email when /user/emails has no entries", async (t) => { + afterFetch(t); + mockFetch({ + "/user": { id: 2, login: "bob", email: "bob@example.com" }, + "/user/emails": [], + }); + + const { user } = await getGithubUserInfo({ accessToken: "token" }); + + t.equal(user.email, "bob@example.com"); +}); + +test("falls back to the first listed email when profile email is blank", async (t) => { + afterFetch(t); + mockFetch({ + "/user": { id: 3, login: "carol" }, + "/user/emails": [ + { email: "carol@example.com", primary: true, verified: true }, + ], + }); + + const { user } = await getGithubUserInfo({ accessToken: "token" }); + + t.equal(user.email, "carol@example.com"); + t.equal(user.emailVerified, true); +}); + +test("returns a user without throwing when GitHub call throws", async (t) => { + afterFetch(t); + global.fetch = async () => { + throw new Error("network down"); + }; + + const { user } = await getGithubUserInfo({ accessToken: "token" }); + + t.equal(user.email, ""); + t.equal(user.emailVerified, false); +});