From 780885f0b5f8a6790768933422cecc1618b0c04b Mon Sep 17 00:00:00 2001 From: charlieww Date: Thu, 26 Mar 2026 23:58:51 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20feat:=20Register=20GitHub=20OAuth?= =?UTF-8?q?=20app=20and=20configure=20environment=20=E2=80=94=20.env.examp?= =?UTF-8?q?le?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6d77888 --- /dev/null +++ b/.env.example @@ -0,0 +1,30 @@ +# ── Infrastructure ─────────────────────────────────────────────────────────── +NATS_URL=nats://localhost:4222 +DATABASE_URL=postgres://opendev:opendev_secret@localhost:5432/opendev +API_BASE_URL=http://localhost:8080/api/v1 + +# ── AI backend ──────────────────────────────────────────────────────────────── +# When unset the runtime falls back to the local `claude` CLI if available, +# then to demo mode with stub responses. +ANTHROPIC_API_KEY= + +# ── GitHub OAuth app ────────────────────────────────────────────────────────── +# Create an OAuth app at: https://github.com/organizations//settings/applications/new +# (or https://github.com/settings/applications/new for personal accounts) +# +# Required settings when registering: +# Application name: OpenDev +# Homepage URL: https://your-domain.com +# Authorization callback URL: https://your-domain.com/api/auth/github/callback +# (for local dev use: http://localhost:8080/api/auth/github/callback) +# +# After registration, copy the Client ID and generate a Client Secret below. +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GITHUB_CALLBACK_URL=http://localhost:8080/api/auth/github/callback + +# ── GitHub fallback token ───────────────────────────────────────────────────── +# Personal access token (classic) or fine-grained PAT used when a repo has no +# stored OAuth token (e.g. during onboarding or for service accounts). +# Scopes needed: repo, workflow +GITHUB_TOKEN= From f3df6ab71a5e2547f905318288ecb5a5a1e353cd Mon Sep 17 00:00:00 2001 From: charlieww Date: Thu, 26 Mar 2026 23:58:52 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20feat:=20Register=20GitHub=20OAuth?= =?UTF-8?q?=20app=20and=20configure=20environment=20=E2=80=94=20src/github?= =?UTF-8?q?-oauth.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/github-oauth.ts | 201 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 src/github-oauth.ts diff --git a/src/github-oauth.ts b/src/github-oauth.ts new file mode 100644 index 0000000..db11acb --- /dev/null +++ b/src/github-oauth.ts @@ -0,0 +1,201 @@ +import { z } from 'zod' + +// ── Config schema ───────────────────────────────────────────────────────────── + +const GitHubOAuthConfigSchema = z.object({ + clientId: z.string().min(1, 'GITHUB_CLIENT_ID is required'), + clientSecret: z.string().min(1, 'GITHUB_CLIENT_SECRET is required'), + callbackUrl: z.string().url('GITHUB_CALLBACK_URL must be a valid URL'), +}) + +export type GitHubOAuthConfig = z.infer + +// ── Token exchange response ──────────────────────────────────────────────────── + +const AccessTokenResponseSchema = z.object({ + access_token: z.string(), + token_type: z.string(), + scope: z.string().optional(), +}) + +export type AccessTokenResponse = z.infer + +// ── GitHub user returned after token exchange ───────────────────────────────── + +const GitHubUserSchema = z.object({ + id: z.number(), + login: z.string(), + name: z.string().nullable().optional(), + email: z.string().nullable().optional(), + avatar_url: z.string().optional(), +}) + +export type GitHubUser = z.infer + +// ── OAuth error ─────────────────────────────────────────────────────────────── + +export class GitHubOAuthError extends Error { + constructor( + message: string, + public readonly code: string, + ) { + super(message) + this.name = 'GitHubOAuthError' + } +} + +// ── Config loader ───────────────────────────────────────────────────────────── + +export function loadGitHubOAuthConfig(): GitHubOAuthConfig { + const result = GitHubOAuthConfigSchema.safeParse({ + clientId: process.env.GITHUB_CLIENT_ID, + clientSecret: process.env.GITHUB_CLIENT_SECRET, + callbackUrl: process.env.GITHUB_CALLBACK_URL, + }) + + if (!result.success) { + const issues = result.error.issues.map(i => ` • ${i.path.join('.')}: ${i.message}`).join('\n') + throw new GitHubOAuthError( + `GitHub OAuth misconfigured:\n${issues}`, + 'CONFIG_INVALID', + ) + } + + return result.data +} + +// ── OAuth client ────────────────────────────────────────────────────────────── + +export class GitHubOAuthClient { + private readonly config: GitHubOAuthConfig + + constructor(config?: GitHubOAuthConfig) { + this.config = config ?? loadGitHubOAuthConfig() + } + + /** + * Builds the GitHub authorization URL that the frontend redirects users to. + * State should be a cryptographically random value stored in the session to + * prevent CSRF attacks. + */ + buildAuthorizationUrl(state: string, scopes: string[] = ['repo', 'read:org', 'workflow']): string { + const params = new URLSearchParams({ + client_id: this.config.clientId, + redirect_uri: this.config.callbackUrl, + scope: scopes.join(' '), + state, + }) + return `https://github.com/login/oauth/authorize?${params.toString()}` + } + + /** + * Exchanges the short-lived authorization code for a long-lived access token. + * Called server-side after GitHub redirects back to GITHUB_CALLBACK_URL. + */ + async exchangeCode(code: string): Promise { + const res = await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + client_id: this.config.clientId, + client_secret: this.config.clientSecret, + code, + redirect_uri: this.config.callbackUrl, + }), + }) + + if (!res.ok) { + throw new GitHubOAuthError( + `Token exchange failed with HTTP ${res.status}`, + 'EXCHANGE_HTTP_ERROR', + ) + } + + const body = await res.json() as Record + + // GitHub returns 200 with an error field on invalid codes + if (body.error) { + throw new GitHubOAuthError( + `Token exchange rejected: ${body.error_description ?? body.error}`, + String(body.error), + ) + } + + const parsed = AccessTokenResponseSchema.safeParse(body) + if (!parsed.success) { + throw new GitHubOAuthError( + 'Unexpected token exchange response shape', + 'EXCHANGE_PARSE_ERROR', + ) + } + + return parsed.data + } + + /** + * Fetches the authenticated GitHub user's profile using their access token. + * Use this to resolve the token to a workspace/user identity after exchange. + */ + async fetchAuthenticatedUser(accessToken: string): Promise { + const res = await fetch('https://api.github.com/user', { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + }) + + if (!res.ok) { + throw new GitHubOAuthError( + `Failed to fetch GitHub user: HTTP ${res.status}`, + 'USER_FETCH_ERROR', + ) + } + + const body = await res.json() as Record + const parsed = GitHubUserSchema.safeParse(body) + if (!parsed.success) { + throw new GitHubOAuthError( + 'Unexpected GitHub user response shape', + 'USER_PARSE_ERROR', + ) + } + + return parsed.data + } + + /** + * Revokes an OAuth access token so it can no longer be used. + * Call this on workspace disconnect or user sign-out. + */ + async revokeToken(accessToken: string): Promise { + const credentials = Buffer.from( + `${this.config.clientId}:${this.config.clientSecret}`, + ).toString('base64') + + const res = await fetch( + `https://api.github.com/applications/${this.config.clientId}/token`, + { + method: 'DELETE', + headers: { + Authorization: `Basic ${credentials}`, + Accept: 'application/vnd.github+json', + 'Content-Type': 'application/json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + body: JSON.stringify({ access_token: accessToken }), + }, + ) + + // 204 = success, 404 = token already gone — both are acceptable + if (!res.ok && res.status !== 404) { + throw new GitHubOAuthError( + `Token revocation failed: HTTP ${res.status}`, + 'REVOKE_ERROR', + ) + } + } +} From e0ca1d4b6fde5920b54a4685d5ec186b2b569922 Mon Sep 17 00:00:00 2001 From: charlieww Date: Thu, 26 Mar 2026 23:58:54 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20feat:=20Register=20GitHub=20OAuth?= =?UTF-8?q?=20app=20and=20configure=20environment=20=E2=80=94=20tests/gith?= =?UTF-8?q?ub-oauth.test.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/github-oauth.test.ts | 213 +++++++++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100644 tests/github-oauth.test.ts diff --git a/tests/github-oauth.test.ts b/tests/github-oauth.test.ts new file mode 100644 index 0000000..a52bf74 --- /dev/null +++ b/tests/github-oauth.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { + GitHubOAuthClient, + GitHubOAuthError, + loadGitHubOAuthConfig, +} from '../src/github-oauth.js' + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function setValidEnv() { + process.env.GITHUB_CLIENT_ID = 'Iv1.abc123' + process.env.GITHUB_CLIENT_SECRET = 'super_secret_value' + process.env.GITHUB_CALLBACK_URL = 'http://localhost:8080/api/auth/github/callback' +} + +function clearOAuthEnv() { + delete process.env.GITHUB_CLIENT_ID + delete process.env.GITHUB_CLIENT_SECRET + delete process.env.GITHUB_CALLBACK_URL +} + +// ── loadGitHubOAuthConfig ───────────────────────────────────────────────────── + +describe('loadGitHubOAuthConfig', () => { + afterEach(clearOAuthEnv) + + it('should return parsed config when all env vars are present', () => { + setValidEnv() + const config = loadGitHubOAuthConfig() + expect(config.clientId).toBe('Iv1.abc123') + expect(config.clientSecret).toBe('super_secret_value') + expect(config.callbackUrl).toBe('http://localhost:8080/api/auth/github/callback') + }) + + it('should throw GitHubOAuthError when GITHUB_CLIENT_ID is missing', () => { + setValidEnv() + delete process.env.GITHUB_CLIENT_ID + expect(() => loadGitHubOAuthConfig()).toThrow(GitHubOAuthError) + expect(() => loadGitHubOAuthConfig()).toThrow('GITHUB_CLIENT_ID is required') + }) + + it('should throw GitHubOAuthError when GITHUB_CLIENT_SECRET is missing', () => { + setValidEnv() + delete process.env.GITHUB_CLIENT_SECRET + expect(() => loadGitHubOAuthConfig()).toThrow(GitHubOAuthError) + expect(() => loadGitHubOAuthConfig()).toThrow('GITHUB_CLIENT_SECRET is required') + }) + + it('should throw GitHubOAuthError when GITHUB_CALLBACK_URL is not a valid URL', () => { + setValidEnv() + process.env.GITHUB_CALLBACK_URL = 'not-a-url' + expect(() => loadGitHubOAuthConfig()).toThrow(GitHubOAuthError) + expect(() => loadGitHubOAuthConfig()).toThrow('valid URL') + }) +}) + +// ── GitHubOAuthClient.buildAuthorizationUrl ─────────────────────────────────── + +describe('GitHubOAuthClient.buildAuthorizationUrl', () => { + const client = new GitHubOAuthClient({ + clientId: 'Iv1.testclient', + clientSecret: 'secret', + callbackUrl: 'http://localhost:8080/api/auth/github/callback', + }) + + it('should include client_id, redirect_uri, state, and default scopes', () => { + const url = new URL(client.buildAuthorizationUrl('csrf-state-token')) + expect(url.origin + url.pathname).toBe('https://github.com/login/oauth/authorize') + expect(url.searchParams.get('client_id')).toBe('Iv1.testclient') + expect(url.searchParams.get('state')).toBe('csrf-state-token') + expect(url.searchParams.get('redirect_uri')).toBe('http://localhost:8080/api/auth/github/callback') + expect(url.searchParams.get('scope')).toContain('repo') + }) + + it('should use custom scopes when provided', () => { + const url = new URL(client.buildAuthorizationUrl('state', ['read:user'])) + expect(url.searchParams.get('scope')).toBe('read:user') + }) +}) + +// ── GitHubOAuthClient.exchangeCode ──────────────────────────────────────────── + +describe('GitHubOAuthClient.exchangeCode', () => { + const client = new GitHubOAuthClient({ + clientId: 'Iv1.testclient', + clientSecret: 'secret', + callbackUrl: 'http://localhost:8080/api/auth/github/callback', + }) + + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('should return access token on successful exchange', async () => { + const mockFetch = vi.mocked(fetch) + mockFetch.mockResolvedValueOnce(new Response( + JSON.stringify({ access_token: 'ghs_abc123', token_type: 'bearer', scope: 'repo' }), + { status: 200 }, + )) + + const result = await client.exchangeCode('temp-code') + expect(result.access_token).toBe('ghs_abc123') + expect(result.token_type).toBe('bearer') + }) + + it('should throw GitHubOAuthError when GitHub returns an error envelope', async () => { + const mockFetch = vi.mocked(fetch) + mockFetch.mockResolvedValueOnce(new Response( + JSON.stringify({ + error: 'bad_verification_code', + error_description: 'The code passed is incorrect or expired.', + }), + { status: 200 }, + )) + + await expect(client.exchangeCode('invalid-code')).rejects.toThrow( + 'The code passed is incorrect or expired.' + ) + }) + + it('should throw GitHubOAuthError on non-2xx HTTP response', async () => { + const mockFetch = vi.mocked(fetch) + mockFetch.mockResolvedValueOnce(new Response('Service Unavailable', { status: 503 })) + + await expect(client.exchangeCode('some-code')).rejects.toThrow( + 'Token exchange failed with HTTP 503' + ) + }) +}) + +// ── GitHubOAuthClient.fetchAuthenticatedUser ────────────────────────────────── + +describe('GitHubOAuthClient.fetchAuthenticatedUser', () => { + const client = new GitHubOAuthClient({ + clientId: 'Iv1.testclient', + clientSecret: 'secret', + callbackUrl: 'http://localhost:8080/api/auth/github/callback', + }) + + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('should return user profile on success', async () => { + const mockFetch = vi.mocked(fetch) + mockFetch.mockResolvedValueOnce(new Response( + JSON.stringify({ id: 42, login: 'octocat', name: 'The Octocat', email: null }), + { status: 200 }, + )) + + const user = await client.fetchAuthenticatedUser('ghs_token') + expect(user.login).toBe('octocat') + expect(user.id).toBe(42) + }) + + it('should throw GitHubOAuthError when the token is invalid (401)', async () => { + const mockFetch = vi.mocked(fetch) + mockFetch.mockResolvedValueOnce(new Response('Unauthorized', { status: 401 })) + + await expect(client.fetchAuthenticatedUser('bad-token')).rejects.toThrow( + 'Failed to fetch GitHub user: HTTP 401' + ) + }) +}) + +// ── GitHubOAuthClient.revokeToken ───────────────────────────────────────────── + +describe('GitHubOAuthClient.revokeToken', () => { + const client = new GitHubOAuthClient({ + clientId: 'Iv1.testclient', + clientSecret: 'secret', + callbackUrl: 'http://localhost:8080/api/auth/github/callback', + }) + + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('should resolve without error on 204 (successful revocation)', async () => { + const mockFetch = vi.mocked(fetch) + mockFetch.mockResolvedValueOnce(new Response(null, { status: 204 })) + + await expect(client.revokeToken('ghs_token')).resolves.toBeUndefined() + }) + + it('should resolve without error on 404 (token already gone)', async () => { + const mockFetch = vi.mocked(fetch) + mockFetch.mockResolvedValueOnce(new Response('Not Found', { status: 404 })) + + await expect(client.revokeToken('stale-token')).resolves.toBeUndefined() + }) + + it('should throw GitHubOAuthError on unexpected failure status', async () => { + const mockFetch = vi.mocked(fetch) + mockFetch.mockResolvedValueOnce(new Response('Internal Server Error', { status: 500 })) + + await expect(client.revokeToken('ghs_token')).rejects.toThrow( + 'Token revocation failed: HTTP 500' + ) + }) +})