Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
59 changes: 58 additions & 1 deletion api/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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");
});
Expand Down Expand Up @@ -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</script><script>alert(1)</script>",
token_type: "bearer",
scope: "",
}),
})
.mockResolvedValueOnce({
json: () =>
Promise.resolve({
login: "devguimaraes",
name: "Bruno </script>",
avatar_url: "https://example.com/avatar.png?a=<x>&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<typeof vi.fn>).mock.calls[0][0] as string;
const scriptBody = html.match(/<script>([\s\S]*)<\/script>/)?.[1] ?? "";
expect(scriptBody).not.toContain("</script>");
expect(scriptBody).toContain("\\u003c/script\\u003e");
expect(scriptBody).toContain("\\u0026");
});

it("returns 404 for unknown routes", async () => {
const req = mockReq({ query: {} });
const res = mockRes();
Expand Down
45 changes: 35 additions & 10 deletions api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, "\\u003c")
.replace(/>/g, "\\u003e")
.replace(/&/g, "\\u0026");
}

export default async function handler(
req: VercelRequest,
res: VercelResponse,
Expand All @@ -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}`);
Expand All @@ -43,18 +63,23 @@ export default async function handler(
}

// Exchange code for access token
const tokenRequestBody: Record<string, string> = {
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 {
Expand Down Expand Up @@ -87,15 +112,15 @@ 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,
login: userData.login,
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(`<!DOCTYPE html>
<html><body><script>
function receiveMessage(e) {
Expand Down
28 changes: 0 additions & 28 deletions public/admin/config.yml

This file was deleted.

41 changes: 40 additions & 1 deletion public/admin/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,45 @@
<title>Admin | Bruno Guimarães</title>
</head>
<body>
<script src="https://unpkg.com/decap-cms@3.0.0/dist/decap-cms.js"></script>
<script>
window.CMS_MANUAL_INIT = true;
</script>
<script src="https://unpkg.com/decap-cms@3.0.0/dist/decap-cms.js" config="false"></script>
<script>
CMS.init({
config: {
backend: {
name: "github",
repo: "devguimaraes/developer-bruno",
branch: "main",
base_url: window.location.origin,
auth_endpoint: "/api/auth",
},
media_folder: "public/uploads",
public_folder: "/uploads",
locale: "pt-BR",
collections: [
{
name: "blog",
label: "Blog",
folder: "src/content/blog",
create: true,
slug: "{{slug}}",
preview_path: "blog/{{slug}}",
fields: [
{ label: "Título", name: "title", widget: "string" },
{ label: "Data", name: "date", widget: "datetime", format: "YYYY-MM-DD" },
{ label: "Tags", name: "tags", widget: "list", allow_add: true },
{ label: "Resumo", name: "excerpt", widget: "text" },
{ label: "Destacado", name: "featured", widget: "boolean", default: false },
{ label: "Imagem", name: "image", widget: "image", required: false },
{ label: "Autor", name: "author", widget: "string", default: "Bruno Guimarães" },
{ label: "Conteúdo", name: "body", widget: "markdown" },
],
},
],
},
});
</script>
</body>
</html>
Loading