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(` + + +