1 Commits

Author SHA1 Message Date
Fatih Kadir Akın 32d9693bdc fix(auth): restore GitHub login
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-08-27 13:04:24 +03:00
4 changed files with 245 additions and 11 deletions
+2 -3
View File
@@ -15,8 +15,8 @@ NEXTAUTH_SECRET="your-super-secret-key-change-in-production"
# AZURE_AD_CLIENT_ID="" # AZURE_AD_CLIENT_ID=""
# AZURE_AD_CLIENT_SECRET="" # AZURE_AD_CLIENT_SECRET=""
# AZURE_AD_TENANT_ID="" # AZURE_AD_TENANT_ID=""
# GITHUB_CLIENT_ID=your_client_id # AUTH_GITHUB_ID=your_client_id
# GITHUB_CLIENT_SECRET=your_client_secret # AUTH_GITHUB_SECRET=your_client_secret
# Run `npx auth add apple` to generate the secret, follow the instructions in the prompt # Run `npx auth add apple` to generate the secret, follow the instructions in the prompt
# AUTH_APPLE_ID="" # AUTH_APPLE_ID=""
@@ -113,4 +113,3 @@ CRON_SECRET="your-secret-key-here"
# AUTH_OAUTH_JWKS_URL="https://sso.example.com/jwks" # 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_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. # AUTH_OAUTH_ENABLE_PKCE="true" # PKCE is enabled by default. Set to "false" to disable.
@@ -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<GitHubProfile> {
userinfo: {
request: (params: {
tokens: { access_token?: string };
}) => Promise<GitHubProfile>;
};
}
function getProviderOptions(): GitHubProviderOptions {
const provider = githubPlugin.getProvider() as OAuthConfig<GitHubProfile>;
return provider.options as GitHubProviderOptions;
}
function mockGitHubResponses(
emails: Array<{
email: string;
primary: boolean;
verified: boolean;
visibility: "public" | "private";
}>
) {
const fetchMock = vi
.fn<typeof fetch>()
.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"
);
});
});
+2 -2
View File
@@ -236,11 +236,11 @@ export default async function SelfHostingPage() {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
<TableRow> <TableRow>
<TableCell className="font-mono text-xs">GITHUB_CLIENT_ID</TableCell> <TableCell className="font-mono text-xs">AUTH_GITHUB_ID</TableCell>
<TableCell className="text-muted-foreground text-sm">GitHub OAuth App client ID</TableCell> <TableCell className="text-muted-foreground text-sm">GitHub OAuth App client ID</TableCell>
</TableRow> </TableRow>
<TableRow> <TableRow>
<TableCell className="font-mono text-xs">GITHUB_CLIENT_SECRET</TableCell> <TableCell className="font-mono text-xs">AUTH_GITHUB_SECRET</TableCell>
<TableCell className="text-muted-foreground text-sm">GitHub OAuth App client secret</TableCell> <TableCell className="text-muted-foreground text-sm">GitHub OAuth App client secret</TableCell>
</TableRow> </TableRow>
<TableRow> <TableRow>
+103 -6
View File
@@ -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"; 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<GitHubProfile>;
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<GitHubEmail>;
return (
typeof email.email === "string" &&
typeof email.primary === "boolean" &&
typeof email.verified === "boolean"
);
}
async function getVerifiedGitHubProfile(
accessToken: string
): Promise<GitHubProfile> {
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 = { export const githubPlugin: AuthPlugin = {
id: "github", id: "github",
name: "GitHub", name: "GitHub",
getProvider: () => getProvider: () => {
GitHub({ const clientId =
clientId: process.env.GITHUB_CLIENT_ID!, process.env.AUTH_GITHUB_ID || process.env.GITHUB_CLIENT_ID;
clientSecret: process.env.GITHUB_CLIENT_SECRET!, const clientSecret =
process.env.AUTH_GITHUB_SECRET || process.env.GITHUB_CLIENT_SECRET;
return GitHub({
clientId,
clientSecret,
allowDangerousEmailAccountLinking: true,
userinfo: githubUserinfo,
profile(profile) { profile(profile) {
return { return {
id: profile.id.toString(), id: profile.id.toString(),
@@ -18,5 +114,6 @@ export const githubPlugin: AuthPlugin = {
githubUsername: profile.login, // Immutable GitHub username for contributor attribution githubUsername: profile.login, // Immutable GitHub username for contributor attribution
}; };
}, },
}), });
},
}; };