diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..d3b5741
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,5 @@
+GITHUB_CLIENT_ID=your_github_app_client_id
+GITHUB_CLIENT_SECRET=your_github_app_client_secret
+GITHUB_REPOSITORY_ID=your_repository_id
+ALLOWED_GITHUB_USER=devguimaraes
+OAUTH_REDIRECT_URI=https://www.devguimaraes.com.br/api/auth/callback
diff --git a/api/auth.test.ts b/api/auth.test.ts
index 9072e5a..035a0f6 100644
--- a/api/auth.test.ts
+++ b/api/auth.test.ts
@@ -54,6 +54,7 @@ async function importHandler() {
vi.stubEnv("GITHUB_CLIENT_ID", "test-client-id");
vi.stubEnv("GITHUB_CLIENT_SECRET", "test-client-secret");
vi.stubEnv("ALLOWED_GITHUB_USER", "devguimaraes");
+ vi.stubEnv("GITHUB_REPOSITORY_ID", "123456789");
const mod = await import("./auth");
return mod.default;
}
@@ -78,11 +79,15 @@ describe("api/auth handler", () => {
expect(redirectUrl).toContain("https://github.com/login/oauth/authorize");
expect(redirectUrl).toContain("client_id=test-client-id");
expect(redirectUrl).toContain("state=test-state-uuid");
- expect(redirectUrl).toContain("scope=repo%2Cuser%3Aemail");
+ expect(redirectUrl).not.toContain("scope=");
expect(res.setHeader).toHaveBeenCalledWith(
"Set-Cookie",
expect.stringContaining("oauth_state:test-state-uuid="),
);
+ expect(res.setHeader).toHaveBeenCalledWith(
+ "Set-Cookie",
+ expect.not.stringContaining("Domain=devguimaraes.com.br"),
+ );
});
it("returns 400 for unsupported provider", async () => {
@@ -136,6 +141,19 @@ describe("api/auth handler", () => {
await handler(req, res);
+ expect(mockFetch).toHaveBeenNthCalledWith(
+ 1,
+ "https://github.com/login/oauth/access_token",
+ expect.objectContaining({
+ body: JSON.stringify({
+ client_id: "test-client-id",
+ client_secret: "test-client-secret",
+ code: "abc123",
+ redirect_uri: "https://www.devguimaraes.com.br/api/auth/callback",
+ repository_id: "123456789",
+ }),
+ }),
+ );
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith("Access denied");
});
@@ -176,6 +194,45 @@ describe("api/auth handler", () => {
expect(html).toContain("window.close");
});
+ it("escapes callback auth data before embedding it in script HTML", async () => {
+ mockFetch
+ .mockResolvedValueOnce({
+ json: () =>
+ Promise.resolve({
+ access_token: "tok",
+ token_type: "bearer",
+ scope: "",
+ }),
+ })
+ .mockResolvedValueOnce({
+ json: () =>
+ Promise.resolve({
+ login: "devguimaraes",
+ name: "Bruno ",
+ avatar_url: "https://example.com/avatar.png?a=&b=>",
+ bio: "bio & test",
+ }),
+ });
+
+ const req = mockReq({
+ query: { code: "valid-code", state: "test-state-uuid" },
+ headers: {
+ host: "devguimaraes.com.br",
+ origin: "https://devguimaraes.com.br",
+ cookie: "oauth_state:test-state-uuid=1",
+ },
+ });
+ const res = mockRes();
+
+ await handler(req, res);
+
+ const html = (res.send as ReturnType).mock.calls[0][0] as string;
+ const scriptBody = html.match(/");
+ expect(scriptBody).toContain("\\u003c/script\\u003e");
+ expect(scriptBody).toContain("\\u0026");
+ });
+
it("returns 404 for unknown routes", async () => {
const req = mockReq({ query: {} });
const res = mockRes();
diff --git a/api/auth.ts b/api/auth.ts
index e5f7987..f2e807a 100644
--- a/api/auth.ts
+++ b/api/auth.ts
@@ -2,10 +2,31 @@ import type { VercelRequest, VercelResponse } from "@vercel/node";
const CLIENT_ID = process.env.GITHUB_CLIENT_ID ?? "";
const CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET ?? "";
+const REPOSITORY_ID = process.env.GITHUB_REPOSITORY_ID ?? "";
const ALLOWED_USER = process.env.ALLOWED_GITHUB_USER ?? "";
const CSRF_STATE_PREFIX = "oauth_state:";
const REDIRECT_URI = process.env.OAUTH_REDIRECT_URI ?? "https://www.devguimaraes.com.br/api/auth/callback";
+function getStateCookie(oauthState: string): string {
+ const cookieParts = [
+ `${CSRF_STATE_PREFIX}${oauthState}=1`,
+ "Path=/",
+ "HttpOnly",
+ "Secure",
+ "SameSite=Lax",
+ "Max-Age=600",
+ ];
+
+ return cookieParts.join("; ");
+}
+
+function escapeForScript(value: string): string {
+ return value
+ .replace(//g, "\\u003e")
+ .replace(/&/g, "\\u0026");
+}
+
export default async function handler(
req: VercelRequest,
res: VercelResponse,
@@ -22,11 +43,10 @@ export default async function handler(
}
const oauthState = crypto.randomUUID();
- res.setHeader("Set-Cookie", `${CSRF_STATE_PREFIX}${oauthState}=1; Path=/; Domain=devguimaraes.com.br; HttpOnly; Secure; SameSite=Lax; Max-Age=600`);
+ res.setHeader("Set-Cookie", getStateCookie(oauthState));
const params = new URLSearchParams({
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
- scope: "repo,user:email",
state: oauthState,
});
res.redirect(`https://github.com/login/oauth/authorize?${params}`);
@@ -43,18 +63,23 @@ export default async function handler(
}
// Exchange code for access token
+ const tokenRequestBody: Record = {
+ client_id: CLIENT_ID,
+ client_secret: CLIENT_SECRET,
+ code,
+ redirect_uri: REDIRECT_URI,
+ };
+ if (REPOSITORY_ID) {
+ tokenRequestBody.repository_id = REPOSITORY_ID;
+ }
+
const tokenRes = await fetch("https://github.com/login/oauth/access_token", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
- body: JSON.stringify({
- client_id: CLIENT_ID,
- client_secret: CLIENT_SECRET,
- code,
- redirect_uri: REDIRECT_URI,
- }),
+ body: JSON.stringify(tokenRequestBody),
});
const tokenData = (await tokenRes.json()) as {
@@ -87,7 +112,7 @@ export default async function handler(
// Return HTML that uses the NetlifyAuthenticator protocol:
// 1. Listen for "authorizing:github" from parent, respond to handshake
// 2. Then send "authorization:github:success:{JSON}" with token data
- const authData = JSON.stringify({
+ const authData = escapeForScript(JSON.stringify({
token: tokenData.access_token,
provider: "github",
scope: tokenData.scope,
@@ -95,7 +120,7 @@ export default async function handler(
name: userData.name || userData.login,
avatar_url: userData.avatar_url || "",
bio: userData.bio || "",
- });
+ }));
res.status(200).setHeader("Content-Type", "text/html; charset=utf-8").send(`
+
+
+