fix(auth): restore GitHub login
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+2
-3
@@ -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"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user