Compare commits
202 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dbe3bc707a | |||
| eaab6b14a0 | |||
| 845e5981c3 | |||
| 94f8f316af | |||
| e46fc4ff0e | |||
| d84e95fc0e | |||
| 8bb66f546d | |||
| 5dba701ae5 | |||
| b6a7d1646a | |||
| 7ed8d29aa8 | |||
| 91fcde5a2e | |||
| b7be86970b | |||
| 8e85009ad1 | |||
| b23360f0dd | |||
| bac39d75f4 | |||
| 1b1da2a0d4 | |||
| 838923cb14 | |||
| 9f2c42867f | |||
| 0e116b9953 | |||
| 8b3edb2f69 | |||
| fb78462eeb | |||
| 112fe9c247 | |||
| 951c254bea | |||
| 17ee1fd8ac | |||
| 5dd5b05700 | |||
| 5d1ab876e1 | |||
| ccf602ce13 | |||
| 384701326b | |||
| 12aeba2854 | |||
| 095aa0b298 | |||
| af532289c6 | |||
| 078ef933e4 | |||
| f20d8c36f3 | |||
| 27dd78e36d | |||
| 4e1b7e61c1 | |||
| bf3eebf267 | |||
| 0600cf5611 | |||
| 2a5ad784aa | |||
| ba29ddc9c0 | |||
| 2a77c8b2c8 | |||
| 6efcdaac16 | |||
| 54cc5991b9 | |||
| d2281ef4b3 | |||
| 9dc504d3ee | |||
| 2d2d410e31 | |||
| b4a4fa223a | |||
| 9eca40ca7c | |||
| 2c89f1ac4a | |||
| 1b5a4f4881 | |||
| b412371a15 | |||
| 0caef2a4b2 | |||
| d00bd44653 | |||
| 7e2e1b253c | |||
| 33a0148e51 | |||
| 88b6e804ce | |||
| 901d269050 | |||
| b8c1030095 | |||
| f4a7a7a4c0 | |||
| 496ccb7496 | |||
| 5e4a1ba4c9 | |||
| c9fdcfcdda | |||
| 90272ab2c3 | |||
| 7f3032b4ee | |||
| 4235f9cff6 | |||
| 414b71ae0b | |||
| df9a3413d9 | |||
| af4cec26fb | |||
| 3180b786f8 | |||
| 955ca36cb4 | |||
| 66b9e0bd9e | |||
| 555fcbc3fe | |||
| cdf3b252d8 | |||
| 5748aeb23a | |||
| 1f911b8bdf | |||
| 451ec5b63f | |||
| 21c700acc9 | |||
| 4508f9575c | |||
| 0d75ea6995 | |||
| 861a5cb20b | |||
| 392e209153 | |||
| 2224524146 | |||
| 07cb5c2e9c | |||
| 266ae098c7 | |||
| f0e0c0efbd | |||
| 128192dff7 | |||
| 7d04d0cead | |||
| b623bdbbfb | |||
| a274b1831c | |||
| a0169b2af9 | |||
| b24b65893b | |||
| 450f5d274a | |||
| 479b607726 | |||
| 49d4d89d23 | |||
| a023e114d5 | |||
| e4435c3fb0 | |||
| 7affa4e4c0 | |||
| e17072878d | |||
| c989304868 | |||
| ba2a3265ae | |||
| 57b27327c2 | |||
| fc06885541 | |||
| cfeeb61d51 | |||
| 1f16ad3dbd | |||
| 5678aa4586 | |||
| 50c4a5a1e7 | |||
| d1fdf222d9 | |||
| 1926094861 | |||
| b67516fb08 | |||
| 8fb5c86021 | |||
| 069b39ac35 | |||
| 43668b3aa5 | |||
| 623024a1f8 | |||
| 5dc319a5ec | |||
| c1081e2a87 | |||
| cf48b03d88 | |||
| 6ed3ba4910 | |||
| bc05cb7175 | |||
| cbd014bdd7 | |||
| dc8a06edf8 | |||
| 92defaf111 | |||
| 99514cdbaf | |||
| cd4894a002 | |||
| 7eea347638 | |||
| 29ee983651 | |||
| 7068c512fc | |||
| eb43a23641 | |||
| 9e50487605 | |||
| a2648cc198 | |||
| 63fe8e647a | |||
| d6e63b0b1f | |||
| dfe4fb271e | |||
| 7a583d77fd | |||
| aa58dd4f6b | |||
| fb22290c07 | |||
| 5ef85ea7f4 | |||
| 300a045ef6 | |||
| 2dd936b48e | |||
| 3cfa103d43 | |||
| 465be358f6 | |||
| c5d1f6f143 | |||
| c60e4d0cec | |||
| 450c71c946 | |||
| e9649445ad | |||
| edff650f46 | |||
| 55e52317ae | |||
| fbc9b78a3a | |||
| fa5b387993 | |||
| 0b203286dd | |||
| e66527ea64 | |||
| 857ed8304b | |||
| a389c2b057 | |||
| ecb85c0edd | |||
| 106d56ec88 | |||
| d321214fa3 | |||
| fa2a1bc5ac | |||
| 15a33b4ba6 | |||
| 380f4debc9 | |||
| 750576bd77 | |||
| e650f93fdc | |||
| 4b39c48abc | |||
| 2fac6b73f0 | |||
| 76ec700def | |||
| 199a607dac | |||
| 690402df4c | |||
| 5d075fb3fd | |||
| 7bc358e458 | |||
| 5652acc9e6 | |||
| 23ae8eb7d5 | |||
| b3e8cdefec | |||
| e8d9faa5e1 | |||
| 57b7e4e85e | |||
| 6a324fa85b | |||
| 50d06a54a1 | |||
| ffb23b3b75 | |||
| 19d276b5ee | |||
| 516ae5d574 | |||
| dab6227074 | |||
| 68b73f99fb | |||
| 0dd6df1d88 | |||
| 8b22931c33 | |||
| 59bdb4b35c | |||
| a5f67df0f5 | |||
| 5dd79322c0 | |||
| 10d54a6c8b | |||
| 69f21f4ea6 | |||
| c53a4f1e28 | |||
| 0df0776a4c | |||
| 197057f2f5 | |||
| 89873f6a1b | |||
| 75d9a7c77a | |||
| 411c817989 | |||
| 508b602d6e | |||
| fc82c54298 | |||
| 616d379f7d | |||
| ba1f39c70d | |||
| 0409f91f04 | |||
| bc2c84def4 | |||
| 6f22791329 | |||
| 1590feb25e | |||
| 5d3ef708e1 | |||
| 5906c55874 | |||
| b4bc0a327e |
+3
-2
@@ -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=""
|
||||
# AUTH_GITHUB_ID=your_client_id
|
||||
# AUTH_GITHUB_SECRET=your_client_secret
|
||||
# GITHUB_CLIENT_ID=your_client_id
|
||||
# GITHUB_CLIENT_SECRET=your_client_secret
|
||||
|
||||
# Run `npx auth add apple` to generate the secret, follow the instructions in the prompt
|
||||
# AUTH_APPLE_ID=""
|
||||
@@ -113,3 +113,4 @@ 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.
|
||||
|
||||
|
||||
+2648
-57
File diff suppressed because it is too large
Load Diff
+2298
-55
File diff suppressed because it is too large
Load Diff
@@ -1,138 +1,110 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { Auth, type AuthConfig } from "@auth/core";
|
||||
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>;
|
||||
};
|
||||
const ORIGIN = "https://prompts.test";
|
||||
const GITHUB_ISSUER = "https://github.com/login/oauth";
|
||||
|
||||
function responseCookies(response: Response): string {
|
||||
return response.headers.getSetCookie().map((cookie) => cookie.split(";")[0]).join("; ");
|
||||
}
|
||||
|
||||
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;
|
||||
describe("GitHub OAuth callback", () => {
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
const signIn = vi.fn(() => true);
|
||||
const logError = vi.fn();
|
||||
let config: AuthConfig;
|
||||
|
||||
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;
|
||||
vi.stubEnv("GITHUB_CLIENT_ID", "test-github-client");
|
||||
vi.stubEnv("GITHUB_CLIENT_SECRET", "test-github-secret");
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
fetchMock.mockImplementation(async (input) => {
|
||||
const url = String(input);
|
||||
if (url === "https://github.com/login/oauth/access_token") {
|
||||
return Response.json({ access_token: "test-access-token", token_type: "bearer" });
|
||||
}
|
||||
if (url === "https://api.github.com/user") {
|
||||
return Response.json({
|
||||
id: 123,
|
||||
login: "testuser",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
avatar_url: "https://avatars.githubusercontent.com/u/123",
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
config = {
|
||||
providers: [githubPlugin.getProvider()],
|
||||
secret: "github-callback-test-secret",
|
||||
trustHost: true,
|
||||
basePath: "/api/auth",
|
||||
callbacks: { signIn },
|
||||
logger: { error: logError, warn: vi.fn(), debug: vi.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
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",
|
||||
async function startSignIn(): Promise<string> {
|
||||
const csrfResponse = await Auth(new Request(`${ORIGIN}/api/auth/csrf`), config);
|
||||
const { csrfToken } = await csrfResponse.json() as { csrfToken: string };
|
||||
const response = await Auth(new Request(`${ORIGIN}/api/auth/signin/github`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Cookie: responseCookies(csrfResponse),
|
||||
},
|
||||
{
|
||||
email: "verified@example.com",
|
||||
primary: false,
|
||||
verified: true,
|
||||
visibility: "private",
|
||||
},
|
||||
]);
|
||||
const options = getProviderOptions();
|
||||
body: new URLSearchParams({ csrfToken, callbackUrl: ORIGIN }),
|
||||
}), config);
|
||||
|
||||
const profile = await options.userinfo.request({
|
||||
tokens: { access_token: "github-token" },
|
||||
});
|
||||
const authorizationUrl = new URL(response.headers.get("location")!);
|
||||
expect(authorizationUrl.origin).toBe("https://github.com");
|
||||
expect(authorizationUrl.searchParams.get("code_challenge_method")).toBe("S256");
|
||||
expect(authorizationUrl.searchParams.get("code_challenge")).toBeTruthy();
|
||||
return responseCookies(response);
|
||||
}
|
||||
|
||||
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.each([GITHUB_ISSUER, undefined])("accepts GitHub callbacks with issuer %s", async (issuer) => {
|
||||
const cookies = await startSignIn();
|
||||
const callbackUrl = new URL(`${ORIGIN}/api/auth/callback/github?code=test-code`);
|
||||
if (issuer) callbackUrl.searchParams.set("iss", issuer);
|
||||
|
||||
const response = await Auth(new Request(callbackUrl, { headers: { Cookie: cookies } }), config);
|
||||
|
||||
expect(logError).not.toHaveBeenCalled();
|
||||
expect(response.headers.get("location")).toBe(ORIGIN);
|
||||
expect(signIn).toHaveBeenCalledWith(expect.objectContaining({
|
||||
user: expect.objectContaining({ email: "test@example.com", username: "testuser" }),
|
||||
account: expect.objectContaining({ provider: "github", providerAccountId: "123" }),
|
||||
}));
|
||||
expect(responseCookies(response)).toContain("__Secure-authjs.session-token=");
|
||||
const tokenRequest = fetchMock.mock.calls.find(([url]) => String(url).endsWith("/access_token"));
|
||||
expect((tokenRequest?.[1]?.body as URLSearchParams).get("code_verifier")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("rejects GitHub profiles without a verified email", async () => {
|
||||
mockGitHubResponses([
|
||||
{
|
||||
email: "unverified@example.com",
|
||||
primary: true,
|
||||
verified: false,
|
||||
visibility: "private",
|
||||
},
|
||||
]);
|
||||
const options = getProviderOptions();
|
||||
it("rejects a callback from an unexpected issuer before exchanging the code", async () => {
|
||||
const cookies = await startSignIn();
|
||||
const callbackUrl = new URL(`${ORIGIN}/api/auth/callback/github?code=test-code`);
|
||||
callbackUrl.searchParams.set("iss", "https://unexpected.example.com");
|
||||
|
||||
await expect(
|
||||
options.userinfo.request({
|
||||
tokens: { access_token: "github-token" },
|
||||
})
|
||||
).rejects.toThrow(
|
||||
"GitHub account does not have a verified email address"
|
||||
);
|
||||
const response = await Auth(new Request(callbackUrl, { headers: { Cookie: cookies } }), config);
|
||||
|
||||
expect(new URL(response.headers.get("location")!).searchParams.get("error")).toBe("Configuration");
|
||||
expect(logError).toHaveBeenCalledWith(expect.objectContaining({
|
||||
cause: expect.objectContaining({
|
||||
err: expect.objectContaining({ message: 'unexpected "iss" (issuer) response parameter value' }),
|
||||
}),
|
||||
}));
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(signIn).not.toHaveBeenCalled();
|
||||
expect(responseCookies(response)).not.toContain("authjs.session-token");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -236,11 +236,11 @@ export default async function SelfHostingPage() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono text-xs">AUTH_GITHUB_ID</TableCell>
|
||||
<TableCell className="font-mono text-xs">GITHUB_CLIENT_ID</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">GitHub OAuth App client ID</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono text-xs">AUTH_GITHUB_SECRET</TableCell>
|
||||
<TableCell className="font-mono text-xs">GITHUB_CLIENT_SECRET</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">GitHub OAuth App client secret</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
|
||||
@@ -1,109 +1,15 @@
|
||||
import GitHub, {
|
||||
type GitHubEmail,
|
||||
type GitHubProfile,
|
||||
} from "next-auth/providers/github";
|
||||
import type { TokenSet } from "@auth/core/types";
|
||||
import GitHub from "next-auth/providers/github";
|
||||
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: () => {
|
||||
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,
|
||||
getProvider: () =>
|
||||
GitHub({
|
||||
// GitHub includes this issuer in OAuth authorization responses.
|
||||
issuer: "https://github.com/login/oauth",
|
||||
clientId: process.env.GITHUB_CLIENT_ID!,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.id.toString(),
|
||||
@@ -114,6 +20,5 @@ export const githubPlugin: AuthPlugin = {
|
||||
githubUsername: profile.login, // Immutable GitHub username for contributor attribution
|
||||
};
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user