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/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 65ad928..8fa8a80 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,6 +1,7 @@
name: CI
on:
+ workflow_dispatch:
pull_request:
push:
branches:
diff --git a/api/auth.test.ts b/api/auth.test.ts
index 9072e5a..4b25c0b 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,60 @@ 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.stringContaining("Domain=.devguimaraes.com.br"),
+ );
+ });
+
+ it("sets Domain for www subdomain host", async () => {
+ const req = mockReq({
+ query: { provider: "github" },
+ headers: { host: "www.devguimaraes.com.br", origin: "https://www.devguimaraes.com.br" },
+ });
+ const res = mockRes();
+
+ await handler(req, res);
+
+ expect(res.setHeader).toHaveBeenCalledWith(
+ "Set-Cookie",
+ expect.stringContaining("Domain=.devguimaraes.com.br"),
+ );
+ });
+
+ it("sets Domain for staging subdomain host", async () => {
+ const req = mockReq({
+ query: { provider: "github" },
+ headers: { host: "staging.devguimaraes.com.br", origin: "https://staging.devguimaraes.com.br" },
+ });
+ const res = mockRes();
+
+ await handler(req, res);
+
+ expect(res.setHeader).toHaveBeenCalledWith(
+ "Set-Cookie",
+ expect.stringContaining("Domain=.staging.devguimaraes.com.br"),
+ );
+ });
+
+ it("sets Domain for apex host", async () => {
+ const req = mockReq({
+ query: { provider: "github" },
+ headers: { host: "devguimaraes.com.br", origin: "https://devguimaraes.com.br" },
+ });
+ const res = mockRes();
+
+ await handler(req, res);
+
+ expect(res.setHeader).toHaveBeenCalledWith(
+ "Set-Cookie",
+ expect.stringContaining("Domain=.devguimaraes.com.br"),
+ );
});
it("returns 400 for unsupported provider", async () => {
@@ -136,6 +186,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 +239,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..4007583 100644
--- a/api/auth.ts
+++ b/api/auth.ts
@@ -2,10 +2,40 @@ 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 getDomain(host: string): string {
+ // Strip www. prefix so cookie is shared between apex and www
+ if (host.startsWith("www.")) {
+ return `.${host.slice(4)}`;
+ }
+ return `.${host}`;
+}
+
+function getStateCookie(oauthState: string, host: string): string {
+ const cookieParts = [
+ `${CSRF_STATE_PREFIX}${oauthState}=1`,
+ "Path=/",
+ "HttpOnly",
+ "Secure",
+ "SameSite=Lax",
+ "Max-Age=600",
+ `Domain=${getDomain(host)}`,
+ ];
+
+ 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 +52,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, req.headers.host ?? ""));
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 +72,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 +121,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 +129,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(`
+
+
+