diff --git a/.changeset/github-profile-types.md b/.changeset/github-profile-types.md new file mode 100644 index 0000000000..0f112b389d --- /dev/null +++ b/.changeset/github-profile-types.md @@ -0,0 +1,7 @@ +--- +"@better-auth/core": minor +--- + +Allow custom GitHub `getUserInfo` callbacks to return public or private API responses without type assertions. `GithubProfile` now uses numeric IDs and counters, nullable profile attributes, and optional account details. + +Consumers may need to handle null or omitted fields and use `String(profile.id)` when a string ID is required. diff --git a/docs/content/docs/authentication/github.mdx b/docs/content/docs/authentication/github.mdx index 37fc2939d9..d95e91d0da 100644 --- a/docs/content/docs/authentication/github.mdx +++ b/docs/content/docs/authentication/github.mdx @@ -72,6 +72,12 @@ That's all! Now you can copy the Client ID and Client Secret of your app! If you get an `email_not_found` error, it is most often because either (a) you created a GitHub App and did not grant the "Email addresses: Read-only" permission, or (b) the user has set their primary email to private, in which case `GET /user` returns `email: null`. See [Handling Providers Without Email](/docs/concepts/oauth#handling-providers-without-email) for the recommended `mapProfileToUser` fallback; the user's private addresses are also available via [`/user/emails`](https://docs.github.com/en/rest/users/emails). +### Custom profile data + +Custom `getUserInfo` callbacks can return GitHub's [public or private `GET /user` response](https://docs.github.com/en/rest/users/users#get-the-authenticated-user) as `data`. `GithubProfile` uses numeric IDs and counters and allows nullable profile attributes. `twitter_username`, private account details, and `plan` can be omitted. + +When reading profile data, use `String(profile.id)` if you need a string ID, `profile.name ?? profile.login` for a name fallback, and `profile.plan?.name` for an optional plan. + ### Why don't I have a refresh token? Github doesn't issue refresh tokens for OAuth apps. For regular OAuth apps, diff --git a/packages/core/src/social-providers/github.test.ts b/packages/core/src/social-providers/github.test.ts new file mode 100644 index 0000000000..6919772136 --- /dev/null +++ b/packages/core/src/social-providers/github.test.ts @@ -0,0 +1,87 @@ +import { assertType, describe, expect, it } from "vitest"; +import type { GithubProfile } from "./github"; +import { github } from "./github"; + +const publicProfile = { + login: "octocat", + id: 583231, + node_id: "MDQ6VXNlcjU4MzIzMQ==", + avatar_url: "https://avatars.githubusercontent.com/u/583231?v=4", + gravatar_id: null, + url: "https://api.github.com/users/octocat", + html_url: "https://github.com/octocat", + followers_url: "https://api.github.com/users/octocat/followers", + following_url: "https://api.github.com/users/octocat/following{/other_user}", + gists_url: "https://api.github.com/users/octocat/gists{/gist_id}", + starred_url: "https://api.github.com/users/octocat/starred{/owner}{/repo}", + subscriptions_url: "https://api.github.com/users/octocat/subscriptions", + organizations_url: "https://api.github.com/users/octocat/orgs", + repos_url: "https://api.github.com/users/octocat/repos", + events_url: "https://api.github.com/users/octocat/events{/privacy}", + received_events_url: "https://api.github.com/users/octocat/received_events", + type: "User", + site_admin: false, + name: null, + company: null, + blog: null, + location: null, + email: null, + hireable: null, + bio: null, + public_repos: 8, + public_gists: 8, + followers: 20, + following: 9, + created_at: "2011-01-25T18:44:36Z", + updated_at: "2026-01-01T00:00:00Z", +}; + +const privateProfile = { + ...publicProfile, + private_gists: 81, + total_private_repos: 100, + owned_private_repos: 100, + disk_usage: 10_000, + collaborators: 8, + two_factor_authentication: true, +}; + +/** + * @see https://github.com/better-auth/better-auth/issues/11040 + * @see https://docs.github.com/en/rest/users/users#get-the-authenticated-user + */ +describe("github profile", () => { + it("accepts public and private API responses", () => { + assertType(publicProfile); + assertType(privateProfile); + assertType({ + ...publicProfile, + twitter_username: "octocat", + }); + assertType({ + ...privateProfile, + twitter_username: null, + plan: { name: "Medium", space: 400, private_repos: 20, collaborators: 0 }, + }); + }); + + it.each([ + { kind: "public", profile: publicProfile }, + { kind: "private", profile: privateProfile }, + ])("accepts a raw $kind response from getUserInfo", async ({ profile }) => { + const provider = github({ + clientId: "github-app", + getUserInfo: async () => ({ + user: { + name: profile.name ?? profile.login, + email: profile.email, + emailVerified: false, + }, + data: profile, + }), + }); + + const result = await provider.getUserInfo({ accessToken: "access-token" }); + expect(result?.data).toBe(profile); + }); +}); diff --git a/packages/core/src/social-providers/github.ts b/packages/core/src/social-providers/github.ts index 70e2346517..62634a2cdd 100644 --- a/packages/core/src/social-providers/github.ts +++ b/packages/core/src/social-providers/github.ts @@ -10,10 +10,10 @@ import { authorizationCodeRequest } from "../oauth2/validate-authorization-code" export interface GithubProfile { login: string; - id: string; + id: number; node_id: string; avatar_url: string; - gravatar_id: string; + gravatar_id: string | null; url: string; html_url: string; followers_url: string; @@ -27,31 +27,31 @@ export interface GithubProfile { received_events_url: string; type: string; site_admin: boolean; - name: string; - company: string; - blog: string; - location: string; + name: string | null; + company: string | null; + blog: string | null; + location: string | null; email: string | null; - hireable: boolean; - bio: string; - twitter_username: string; - public_repos: string; - public_gists: string; - followers: string; - following: string; + hireable: boolean | null; + bio: string | null; + twitter_username?: string | null; + public_repos: number; + public_gists: number; + followers: number; + following: number; created_at: string; updated_at: string; - private_gists: string; - total_private_repos: string; - owned_private_repos: string; - disk_usage: string; - collaborators: string; - two_factor_authentication: boolean; - plan: { + private_gists?: number; + total_private_repos?: number; + owned_private_repos?: number; + disk_usage?: number; + collaborators?: number; + two_factor_authentication?: boolean; + plan?: { name: string; - space: string; - private_repos: string; - collaborators: string; + space: number; + private_repos: number; + collaborators: number; }; }