From 32d9693bdc160c2d3c5259ee6aab21d2a21e6b56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fatih=20Kadir=20Ak=C4=B1n?= Date: Thu, 27 Aug 2026 13:04:24 +0300 Subject: [PATCH] fix(auth): restore GitHub login Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .env.example | 5 +- src/__tests__/lib/plugins/auth/github.test.ts | 138 ++++++++++++++++++ src/app/docs/self-hosting/page.tsx | 4 +- src/lib/plugins/auth/github.ts | 109 +++++++++++++- 4 files changed, 245 insertions(+), 11 deletions(-) create mode 100644 src/__tests__/lib/plugins/auth/github.test.ts diff --git a/.env.example b/.env.example index 30579bce..acea41dc 100644 --- a/.env.example +++ b/.env.example @@ -15,8 +15,8 @@ NEXTAUTH_SECRET="your-super-secret-key-change-in-production" # AZURE_AD_CLIENT_ID="" # AZURE_AD_CLIENT_SECRET="" # AZURE_AD_TENANT_ID="" -# GITHUB_CLIENT_ID=your_client_id -# GITHUB_CLIENT_SECRET=your_client_secret +# AUTH_GITHUB_ID=your_client_id +# AUTH_GITHUB_SECRET=your_client_secret # Run `npx auth add apple` to generate the secret, follow the instructions in the prompt # AUTH_APPLE_ID="" @@ -113,4 +113,3 @@ CRON_SECRET="your-secret-key-here" # AUTH_OAUTH_JWKS_URL="https://sso.example.com/jwks" # AUTH_OAUTH_TOKEN_AUTH_METHOD="client_secret_basic" # Allowed values: "client_secret_basic", "client_secret_post", "none" # AUTH_OAUTH_ENABLE_PKCE="true" # PKCE is enabled by default. Set to "false" to disable. - diff --git a/src/__tests__/lib/plugins/auth/github.test.ts b/src/__tests__/lib/plugins/auth/github.test.ts new file mode 100644 index 00000000..3e370ef5 --- /dev/null +++ b/src/__tests__/lib/plugins/auth/github.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { GitHubProfile } from "next-auth/providers/github"; +import type { OAuthConfig, OAuthUserConfig } from "next-auth/providers"; +import { githubPlugin } from "@/lib/plugins/auth/github"; + +interface GitHubProviderOptions extends OAuthUserConfig { + userinfo: { + request: (params: { + tokens: { access_token?: string }; + }) => Promise; + }; +} + +function getProviderOptions(): GitHubProviderOptions { + const provider = githubPlugin.getProvider() as OAuthConfig; + return provider.options as GitHubProviderOptions; +} + +function mockGitHubResponses( + emails: Array<{ + email: string; + primary: boolean; + verified: boolean; + visibility: "public" | "private"; + }> +) { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + id: 123, + login: "octocat", + name: "The Octocat", + email: null, + avatar_url: "https://avatars.githubusercontent.com/u/123", + }), + { status: 200 } + ) + ) + .mockResolvedValueOnce( + new Response(JSON.stringify(emails), { status: 200 }) + ); + vi.stubGlobal("fetch", fetchMock); + + return fetchMock; +} + +describe("GitHub auth plugin", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.AUTH_GITHUB_ID; + delete process.env.AUTH_GITHUB_SECRET; + delete process.env.GITHUB_CLIENT_ID; + delete process.env.GITHUB_CLIENT_SECRET; + }); + + afterEach(() => { + process.env = originalEnv; + vi.unstubAllGlobals(); + }); + + it("uses Auth.js GitHub credential names", () => { + process.env.AUTH_GITHUB_ID = "auth-client-id"; + process.env.AUTH_GITHUB_SECRET = "auth-client-secret"; + + const options = getProviderOptions(); + + expect(options.clientId).toBe("auth-client-id"); + expect(options.clientSecret).toBe("auth-client-secret"); + }); + + it("supports legacy GitHub credential names", () => { + process.env.GITHUB_CLIENT_ID = "legacy-client-id"; + process.env.GITHUB_CLIENT_SECRET = "legacy-client-secret"; + + const options = getProviderOptions(); + + expect(options.clientId).toBe("legacy-client-id"); + expect(options.clientSecret).toBe("legacy-client-secret"); + }); + + it("enables account linking only with a verified GitHub email", async () => { + const fetchMock = mockGitHubResponses([ + { + email: "unverified@example.com", + primary: true, + verified: false, + visibility: "private", + }, + { + email: "verified@example.com", + primary: false, + verified: true, + visibility: "private", + }, + ]); + const options = getProviderOptions(); + + const profile = await options.userinfo.request({ + tokens: { access_token: "github-token" }, + }); + + expect(options.allowDangerousEmailAccountLinking).toBe(true); + expect(profile.email).toBe("verified@example.com"); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.github.com/user/emails", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer github-token", + }), + }) + ); + }); + + it("rejects GitHub profiles without a verified email", async () => { + mockGitHubResponses([ + { + email: "unverified@example.com", + primary: true, + verified: false, + visibility: "private", + }, + ]); + const options = getProviderOptions(); + + await expect( + options.userinfo.request({ + tokens: { access_token: "github-token" }, + }) + ).rejects.toThrow( + "GitHub account does not have a verified email address" + ); + }); +}); diff --git a/src/app/docs/self-hosting/page.tsx b/src/app/docs/self-hosting/page.tsx index c9b927e7..773c4438 100644 --- a/src/app/docs/self-hosting/page.tsx +++ b/src/app/docs/self-hosting/page.tsx @@ -236,11 +236,11 @@ export default async function SelfHostingPage() { - GITHUB_CLIENT_ID + AUTH_GITHUB_ID GitHub OAuth App client ID - GITHUB_CLIENT_SECRET + AUTH_GITHUB_SECRET GitHub OAuth App client secret diff --git a/src/lib/plugins/auth/github.ts b/src/lib/plugins/auth/github.ts index ed8bb4d0..b404a796 100644 --- a/src/lib/plugins/auth/github.ts +++ b/src/lib/plugins/auth/github.ts @@ -1,13 +1,109 @@ -import GitHub from "next-auth/providers/github"; +import GitHub, { + type GitHubEmail, + type GitHubProfile, +} from "next-auth/providers/github"; +import type { TokenSet } from "@auth/core/types"; import type { AuthPlugin } from "../types"; +const GITHUB_API_URL = "https://api.github.com"; + +function isGitHubProfile(value: unknown): value is GitHubProfile { + if (!value || typeof value !== "object") return false; + + const profile = value as Partial; + return ( + typeof profile.id === "number" && + typeof profile.login === "string" && + typeof profile.avatar_url === "string" + ); +} + +function isGitHubEmail(value: unknown): value is GitHubEmail { + if (!value || typeof value !== "object") return false; + + const email = value as Partial; + return ( + typeof email.email === "string" && + typeof email.primary === "boolean" && + typeof email.verified === "boolean" + ); +} + +async function getVerifiedGitHubProfile( + accessToken: string +): Promise { + const headers = { + Authorization: `Bearer ${accessToken}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "prompts.chat", + }; + const [profileResponse, emailsResponse] = await Promise.all([ + fetch(`${GITHUB_API_URL}/user`, { headers }), + fetch(`${GITHUB_API_URL}/user/emails`, { headers }), + ]); + + if (!profileResponse.ok) { + throw new Error( + `GitHub profile request failed with status ${profileResponse.status}` + ); + } + if (!emailsResponse.ok) { + throw new Error( + `GitHub email request failed with status ${emailsResponse.status}` + ); + } + + const profile: unknown = await profileResponse.json(); + const emails: unknown = await emailsResponse.json(); + + if (!isGitHubProfile(profile)) { + throw new Error("GitHub returned an invalid user profile"); + } + if (!Array.isArray(emails)) { + throw new Error("GitHub returned an invalid email list"); + } + + const githubEmails = emails.filter(isGitHubEmail); + const verifiedEmail = + githubEmails.find((email) => email.primary && email.verified) ?? + githubEmails.find((email) => email.verified); + + if (!verifiedEmail) { + throw new Error("GitHub account does not have a verified email address"); + } + + return { + ...profile, + email: verifiedEmail.email, + }; +} + +const githubUserinfo = { + url: `${GITHUB_API_URL}/user`, + async request({ tokens }: { tokens: TokenSet }) { + if (!tokens.access_token) { + throw new Error("GitHub did not return an access token"); + } + + return getVerifiedGitHubProfile(tokens.access_token); + }, +}; + export const githubPlugin: AuthPlugin = { id: "github", name: "GitHub", - getProvider: () => - GitHub({ - clientId: process.env.GITHUB_CLIENT_ID!, - clientSecret: process.env.GITHUB_CLIENT_SECRET!, + getProvider: () => { + const clientId = + process.env.AUTH_GITHUB_ID || process.env.GITHUB_CLIENT_ID; + const clientSecret = + process.env.AUTH_GITHUB_SECRET || process.env.GITHUB_CLIENT_SECRET; + + return GitHub({ + clientId, + clientSecret, + allowDangerousEmailAccountLinking: true, + userinfo: githubUserinfo, profile(profile) { return { id: profile.id.toString(), @@ -18,5 +114,6 @@ export const githubPlugin: AuthPlugin = { githubUsername: profile.login, // Immutable GitHub username for contributor attribution }; }, - }), + }); + }, };