Skip to content
Merged
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
4 changes: 4 additions & 0 deletions src/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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: {
Expand Down
62 changes: 62 additions & 0 deletions src/auth/github-provider.js
Original file line number Diff line number Diff line change
@@ -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,
};
}
103 changes: 103 additions & 0 deletions test/unit/github-provider.test.js
Original file line number Diff line number Diff line change
@@ -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);
});