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: 1 addition & 4 deletions .cursor/rules/review-gate.mdc
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
---
description:
globs:
alwaysApply: true
alwaysApply: false
---

## MANDATORY CHECKPOINT (Must be included in every response):
Before ending any response, I MUST:
1. State: "CHECKPOINT: Transitioning to Review Gate V2"
Expand Down
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: CI

on:
push:
branches: ["main", "release/*"]
pull_request:
branches: ["main", "release/*"]

jobs:
lint-typecheck-build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 18
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
env:
SKIP_ENV_VALIDATION: true
- name: Typecheck
run: pnpm type-check
env:
SKIP_ENV_VALIDATION: true
- name: Build
run: pnpm build:selfhosted
env:
SKIP_ENV_VALIDATION: true
33 changes: 33 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: CodeQL

on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
schedule:
- cron: "0 6 * * 1"

jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
security-events: write
actions: read
contents: read
strategy:
fail-fast: false
matrix:
language: ["javascript-typescript"]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
2 changes: 2 additions & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
18

Binary file added example.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 1 addition & 21 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,32 +13,12 @@ const nextConfig: NextConfig = {

// Configure images
images: {
dangerouslyAllowSVG: true,
remotePatterns: [new URL("https://vercel.com/*")],
},

// Configure headers for security and CORS
async headers() {
return [
{
source: "/api/webhooks/:path*",
headers: [
{
key: "Access-Control-Allow-Origin",
value: "*",
},
{
key: "Access-Control-Allow-Methods",
value: "POST, OPTIONS",
},
{
key: "Access-Control-Allow-Headers",
value:
"Content-Type, X-GitHub-Delivery, X-GitHub-Event, X-GitHub-Signature-256",
},
],
},
];
return [];
},
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/*
Warnings:

- You are about to drop the column `refresh_token` on the `github_installations` table. All the data in the column will be lost.
- You are about to drop the column `refresh_token_expires_at` on the `github_installations` table. All the data in the column will be lost.
- You are about to drop the column `token_expires_at` on the `github_installations` table. All the data in the column will be lost.
- You are about to drop the column `user_access_token` on the `github_installations` table. All the data in the column will be lost.

*/
-- AlterTable
ALTER TABLE "github_installations" DROP COLUMN "refresh_token",
DROP COLUMN "refresh_token_expires_at",
DROP COLUMN "token_expires_at",
DROP COLUMN "user_access_token",
ADD COLUMN "refreshToken" TEXT,
ADD COLUMN "refreshTokenExpiresAt" TIMESTAMP(3),
ADD COLUMN "tokenExpiresAt" TIMESTAMP(3),
ADD COLUMN "userAccessToken" TEXT;
Comment thread
iHildy marked this conversation as resolved.
12 changes: 6 additions & 6 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,10 @@ model GitHubInstallation {
LabelPreference LabelPreference?

// User OAuth tokens
user_access_token String?
refresh_token String?
token_expires_at DateTime?
refresh_token_expires_at DateTime?
userAccessToken String?
refreshToken String?
tokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?

// Indexes for performance
@@index([accountId])
Expand Down Expand Up @@ -168,8 +168,8 @@ model LabelPreferenceRepository {

model RateLimit {
id Int @id @default(autoincrement())
identifier String // IP address or user identifier
endpoint String // API endpoint being rate limited
identifier String // IP address or user identifier
endpoint String // API endpoint being rate limited
requests Int @default(1)
windowStart DateTime
expiresAt DateTime
Expand Down
96 changes: 96 additions & 0 deletions src/app/api/auth/authorize/github/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { env } from "@/lib/env";
import logger from "@/lib/logger";
import crypto from "crypto";
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";

// Safe redirect URLs - only allow internal paths or whitelisted domains
const SAFE_REDIRECT_PATTERNS = [
/^\/github-app\/success$/, // Internal success page
/^\/github-app\/label-setup$/, // Internal label setup page
/^\/$/, // Home page
];

function isValidRedirectUrl(redirectTo: string): boolean {
// Check if it's a relative URL (starts with /)
if (redirectTo.startsWith("/")) {
return SAFE_REDIRECT_PATTERNS.some((pattern) => pattern.test(redirectTo));
}

// For absolute URLs, only allow same origin
try {
const redirectUrl = new URL(redirectTo);
const baseUrl = new URL(env.GITHUB_APP_CALLBACK_URL);
return redirectUrl.origin === baseUrl.origin;
} catch {
// Invalid URL format
return false;
}
}

export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const installationId = searchParams.get("installation_id");
const redirectTo = searchParams.get("redirect_to") || "/github-app/success";

if (!installationId) {
return NextResponse.json(
{ error: "Installation ID is required" },
{ status: 400 },
);
}

// Validate redirectTo to prevent open redirect vulnerabilities
if (!isValidRedirectUrl(redirectTo)) {
logger.warn(
{ installationId, redirectTo },
"Invalid redirect URL attempted in OAuth flow",
);
return NextResponse.json(
{ error: "Invalid redirect URL" },
{ status: 400 },
);
}

try {
// Generate CSRF state and encode installation_id and redirectTo using base64
const state = crypto.randomBytes(32).toString("hex");
const stateData = {
state,
installationId,
redirectTo,
};
const stateWithInstallation = Buffer.from(
JSON.stringify(stateData),
).toString("base64");

// Set CSRF state cookie
const cookieStore = await cookies();
cookieStore.set("oauth_state", stateWithInstallation, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 10, // 10 minutes
});

// Build GitHub OAuth authorization URL
const githubAuthUrl = new URL("https://github.com/login/oauth/authorize");
githubAuthUrl.searchParams.set("client_id", env.GITHUB_APP_CLIENT_ID);
githubAuthUrl.searchParams.set("redirect_uri", env.GITHUB_APP_CALLBACK_URL);
githubAuthUrl.searchParams.set("state", stateWithInstallation);
githubAuthUrl.searchParams.set("scope", "repo"); // Add required scopes

logger.info(
{ installationId, redirectTo },
"Redirecting to GitHub OAuth authorization",
);

return NextResponse.redirect(githubAuthUrl.toString());
} catch (error) {
logger.error({ error }, "Failed to create OAuth authorization URL");
return NextResponse.json(
{ error: "Failed to initiate OAuth flow" },
{ status: 500 },
);
}
}
Loading
Loading