diff --git a/.env.example b/.env.example index f640076..9b8caff 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,15 @@ -DATABASE_URL="your-database-url" -JWT_SECRET="your-jwt-secret" -GITHUB_CLIENT_ID="your-github-client-id" -GITHUB_CLIENT_SECRET="your-github-client-secret" +DATABASE_URL="postgresql://user:password@localhost:5432/proj_dash?schema=public" +JWT_SECRET="your-jwt-secret-here-use-openssl-rand-base64-32" + +# GitHub App Configuration (modern approach with fine-grained permissions) +# See GITHUB_APP_SETUP.md for detailed setup instructions +GITHUB_APP_ID="your-github-app-id" +GITHUB_APP_CLIENT_ID="your-github-app-client-id" +GITHUB_APP_CLIENT_SECRET="your-github-app-client-secret" + +# Optional: For App-level authentication (not required for user auth) +# GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----" + FRONTEND_URL="http://localhost:5173" UPSTASH_REDIS_URL="http://localhost:6379" UPSTASH_REDIS_TOKEN="your-upstash-redis-token" diff --git a/GITHUB_APP_SETUP.md b/GITHUB_APP_SETUP.md new file mode 100644 index 0000000..ce5111d --- /dev/null +++ b/GITHUB_APP_SETUP.md @@ -0,0 +1,145 @@ +# GitHub App Setup Guide + +This project uses **GitHub App** for authentication, which provides better security and more fine-grained permissions compared to OAuth Apps. + +## Why GitHub App? + +- **Fine-grained permissions**: Request only the permissions you need +- **Better security**: Separate user authentication from app installation +- **Modern approach**: Recommended by GitHub for new applications +- **Flexible**: Support both user authentication and app-level operations + +## Creating a GitHub App + +### 1. Navigate to GitHub App Settings + +Go to [GitHub Developer Settings](https://github.com/settings/apps) and click **New GitHub App**. + +### 2. Fill in Basic Information + +- **GitHub App name**: Your app name (e.g., "Project Dashboard Dev") +- **Homepage URL**: `http://localhost:5173` (for development) +- **Callback URL**: `http://localhost:5173/callback` +- **Webhook**: Uncheck "Active" (unless you need webhooks) + +### 3. Configure Permissions + +Under **Repository permissions**, select: + +- **Contents**: Read-only (to read repository data) +- **Metadata**: Read-only (required, automatically enabled) +- **Commit statuses**: Read-only (for commit statistics) + +Under **Account permissions**, select: + +- **Email addresses**: Read-only (to get user email) + +### 4. Where can this GitHub App be installed? + +Choose: + +- **Any account** (allows any user to authenticate) + +### 5. Create the App + +Click **Create GitHub App**. + +### 6. Get Your Credentials + +After creation, you'll see: + +1. **App ID**: Found at the top of the app settings page +2. **Client ID**: Under "About" section +3. **Client secrets**: Click "Generate a new client secret" + +### 7. Update Your `.env` File + +Copy the credentials to your `.env` file: + +```env +GITHUB_APP_ID="123456" +GITHUB_APP_CLIENT_ID="Iv1.abc123def456" +GITHUB_APP_CLIENT_SECRET="your-client-secret-here" +``` + +## User Authentication Flow + +The authentication flow for GitHub Apps is similar to OAuth Apps: + +1. **Frontend**: Redirect user to GitHub authorization URL: + + ``` + https://github.com/login/oauth/authorize?client_id=YOUR_CLIENT_ID + ``` + +2. **GitHub**: User authorizes the app and is redirected back with a `code` + +3. **Frontend**: Send the `code` to your backend API: + + ``` + POST /auth/login + { "code": "received_code" } + ``` + +4. **Backend**: + - Exchange code for access token + - Fetch user information + - Generate JWT token + - Return to frontend + +## Frontend Integration Example + +```typescript +// Redirect to GitHub for authorization +const clientId = 'YOUR_GITHUB_APP_CLIENT_ID' +const redirectUri = 'http://localhost:5173/callback' +const authUrl = `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${redirectUri}` + +window.location.href = authUrl + +// In your callback page, extract the code +const urlParams = new URLSearchParams(window.location.search) +const code = urlParams.get('code') + +// Send to backend +const response = await fetch('http://localhost:3333/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code }), +}) + +const { data } = await response.json() +const token = data.token // Use this JWT for subsequent API calls +``` + +## Differences from OAuth Apps + +| Feature | OAuth App | GitHub App | +| ----------- | --------------------- | ----------------------------------------- | +| Permissions | Coarse-grained scopes | Fine-grained permissions | +| Token Type | User access token | User access token + Installation token | +| Rate Limits | 5,000 requests/hour | 5,000 requests/hour (user) + 15,000 (app) | +| Recommended | Legacy | **✓ Modern** | + +## Troubleshooting + +### "Bad verification code" error + +- Ensure the code hasn't expired (codes are single-use and expire after 10 minutes) +- Check that your Client ID and Client Secret are correct + +### "Not found" error + +- Verify your GitHub App is set to "Any account" installation +- Check that the callback URL matches your frontend URL + +### API Rate Limiting + +- User authentication has 5,000 requests/hour per user +- Consider implementing Redis caching (already included in this project) + +## Additional Resources + +- [GitHub Apps Documentation](https://docs.github.com/en/apps) +- [User-to-Server Authentication](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app) +- [GitHub App Permissions](https://docs.github.com/en/rest/overview/permissions-required-for-github-apps) diff --git a/prisma/migrations/20251027054428_init/migration.sql b/prisma/migrations/20251027054428_init/migration.sql new file mode 100644 index 0000000..e6de554 --- /dev/null +++ b/prisma/migrations/20251027054428_init/migration.sql @@ -0,0 +1,44 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "githubId" TEXT NOT NULL, + "username" TEXT NOT NULL, + "avatarUrl" TEXT NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Repository" ( + "id" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "owner" TEXT NOT NULL, + + CONSTRAINT "Repository_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PullRequest" ( + "id" TEXT NOT NULL, + "number" INTEGER NOT NULL, + "title" TEXT NOT NULL, + "state" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL, + "closed_at" TIMESTAMP(3), + "repositoryId" INTEGER NOT NULL, + "authorId" TEXT NOT NULL, + + CONSTRAINT "PullRequest_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_githubId_key" ON "User"("githubId"); + +-- CreateIndex +CREATE UNIQUE INDEX "PullRequest_repositoryId_number_key" ON "PullRequest"("repositoryId", "number"); + +-- AddForeignKey +ALTER TABLE "PullRequest" ADD CONSTRAINT "PullRequest_repositoryId_fkey" FOREIGN KEY ("repositoryId") REFERENCES "Repository"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PullRequest" ADD CONSTRAINT "PullRequest_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/src/routes/auth.ts b/src/routes/auth.ts index c922cc6..ee84928 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -23,7 +23,7 @@ export const authRoutes: FastifyPluginAsyncZod = async (app) => { '/login', { schema: { - description: 'Login with GitHub OAuth code', + description: 'Login with GitHub App OAuth code (user-to-server authentication)', tags: ['auth'], body: loginBodySchema, response: { diff --git a/src/server.ts b/src/server.ts index cc1eeac..043e78f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -88,7 +88,7 @@ GitHub API rate limits apply. Authenticated requests: 5,000/hour. description: 'Local development server', }, { - url: 'https://api.projectdash.example.com', + url: 'https://zany-couscous-jg4x4q5q6gj2p945-3333.app.github.dev/', description: 'Production server', }, ], diff --git a/src/services/github.service.ts b/src/services/github.service.ts index 9eefa3f..887f00b 100644 --- a/src/services/github.service.ts +++ b/src/services/github.service.ts @@ -9,12 +9,30 @@ const GITHUB_API_BASE_URL = 'https://api.github.com' // Define a type for the repository list response data for clarity type ReposListForAuthenticatedUserResponse = Endpoints['GET /user/repos']['response']['data'] +// GitHub OAuth token response type +interface GitHubTokenResponse { + access_token: string + token_type: string + scope: string + error?: string + error_description?: string +} + +/** + * Exchange GitHub OAuth code for access token + * Works with both OAuth Apps and GitHub Apps (user-to-server flow) + * @see https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app + */ export async function exchangeCodeForToken(code: string): Promise { - const response = await axios.post( + console.log('Exchanging code for token...') + console.log('Client ID:', process.env['GITHUB_APP_CLIENT_ID']) + console.log('Code:', code.substring(0, 10) + '...') + + const response = await axios.post( 'https://github.com/login/oauth/access_token', { - client_id: process.env.GITHUB_CLIENT_ID, - client_secret: process.env.GITHUB_CLIENT_SECRET, + client_id: process.env['GITHUB_APP_CLIENT_ID'], + client_secret: process.env['GITHUB_APP_CLIENT_SECRET'], code, }, { @@ -22,8 +40,22 @@ export async function exchangeCodeForToken(code: string): Promise { } ) - // eslint-disable-next-line @typescript-eslint/no-unsafe-return,@typescript-eslint/no-unsafe-member-access - return response.data?.access_token + console.log('GitHub OAuth response status:', response.status) + console.log('GitHub OAuth response data:', response.data) + + // Check for errors in the response + if (response.data.error) { + throw new Error( + `GitHub OAuth error: ${response.data.error} - ${response.data.error_description ?? ''}` + ) + } + + if (!response.data.access_token) { + console.error('GitHub OAuth response:', response.data) + throw new Error('No access token received from GitHub') + } + + return response.data.access_token } export async function getGithubUser(accessToken: string): Promise { @@ -45,15 +77,27 @@ export class GitHubService { async getRepositories(): Promise { const cacheKey = `repos:${this.username}` - const cachedRepos = await redis.get(cacheKey) - if (cachedRepos) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return JSON.parse(cachedRepos as string) + // Try to get from cache, but don't fail if Redis is unavailable + try { + const cachedRepos = await redis.get(cacheKey) + if (cachedRepos) { + console.log('Cache hit for repositories') + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return JSON.parse(cachedRepos as string) + } + } catch (error) { + console.warn('Redis cache unavailable, fetching from GitHub API directly:', error) } const { data } = await this.octokit.repos.listForAuthenticatedUser() - await redis.set(cacheKey, JSON.stringify(data), { ex: 3600 }) // Cache for 1 hour + + // Try to cache, but don't fail if Redis is unavailable + try { + await redis.set(cacheKey, JSON.stringify(data), { ex: 3600 }) // Cache for 1 hour + } catch (error) { + console.warn('Failed to cache repositories:', error) + } return data }