Skip to content
Open
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
30 changes: 30 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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/<org>/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=
201 changes: 201 additions & 0 deletions src/github-oauth.ts
Original file line number Diff line number Diff line change
@@ -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<typeof GitHubOAuthConfigSchema>

// ── Token exchange response ────────────────────────────────────────────────────

const AccessTokenResponseSchema = z.object({
access_token: z.string(),
token_type: z.string(),
scope: z.string().optional(),
})

export type AccessTokenResponse = z.infer<typeof AccessTokenResponseSchema>

// ── 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<typeof GitHubUserSchema>

// ── 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<AccessTokenResponse> {
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<string, unknown>

// 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<GitHubUser> {
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<string, unknown>
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<void> {
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',
)
}
}
}
Loading
Loading