Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 32d9693bdc |
+2
-3
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
<TableBody>
|
||||
<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>
|
||||
</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>
|
||||
</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";
|
||||
|
||||
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 = {
|
||||
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
|
||||
};
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user