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
14 changes: 13 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ NODE_ENV=development
PUBLIC_ORG=
PRIVATE_ORG=

# GitHub Enterprise (GHE.com Data Residency / GHES) configuration.
# Leave unset to use the github.com defaults shown below. For GHE/GHES, set
# each custom URL explicitly.
# GITHUB_SERVER_URL=
# GITHUB_API_URL=
# GITHUB_GRAPHQL_URL=

# Committer email domain used on sync commits. Defaults to
# `users.noreply.github.com`. Set explicitly for GHE/GHES; the exact value
# depends on the instance configuration.
# GITHUB_USER_EMAIL_DOMAIN=

# Used to skip branch protection creation if organization level branch protections are used instead
SKIP_BRANCH_PROTECTION_CREATION=

Expand All @@ -40,7 +52,7 @@ DELETE_INTERNAL_MERGE_COMMITS_ON_SYNC=
# Used to configure the timeout for syncing a mirror before the task gets backgrounded (default is 30 seconds)
MIRROR_SYNC_TIMEOUT_MS=

# Used to configure the number of commits to push at a time when syncing a mirror (default is 100)
# Used to configure the number of commits to push at a time when syncing a mirror (default is 1000)
MIRROR_PUSH_CHUNK_SIZE=

# Used to disable mirror deletion through private mirrors. Hides the delete action in the UI and rejects direct API calls.
Expand Down
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,36 @@ PRIVATE_ORG=name-of-your-ghec-org # Where your private mirrors will be creat

The authentication of the UI will still need to be a user's github.com user, but the app will be able to create forks and mirrors in the GHEC instance.

## Integrating the App into GHE.com (Data Residency) or GHES

The app also supports GitHub Enterprise Cloud with Data Residency (`*.ghe.com`) and GitHub Enterprise Server. Configure the server, REST API, and GraphQL API URLs explicitly for your environment.

Set the following environment variables in addition to the GHEC variables above:

```sh
# Base URL of your GHE instance (no trailing slash).
# GHE.com Data Residency: https://<tenant>.ghe.com
# GHES: https://ghes.example.com
GITHUB_SERVER_URL=https://acme.ghe.com

# REST API and GraphQL URLs for the same GitHub host.
GITHUB_API_URL=https://api.acme.ghe.com
GITHUB_GRAPHQL_URL=https://api.acme.ghe.com/graphql

# Committer email domain used on sync commits. Defaults to `users.noreply.github.com`.
# Set explicitly for GHE/GHES (value depends on instance configuration), e.g.:
# users.noreply.acme.ghe.com
# users.noreply.ghes.example.com
GITHUB_USER_EMAIL_DOMAIN=users.noreply.acme.ghe.com
```

Notes:

- The OAuth App / GitHub App, organizations, members and forks must all live on the same GHE instance.
- GitHub configuration is read at runtime and safely passed from the server to client-side hooks and UI links. No duplicate `NEXT_PUBLIC_*` variables or Docker build arguments are required.
- If these variables are unset, the app uses `github.com`, `api.github.com`, and `users.noreply.github.com` defaults.
- The local webhook relay (`npm run webhook`) uses `github-app-webhook-relay-polling` against the GitHub App hook deliveries endpoint. It is best-effort on GHE; in production, use real webhook deliveries configured directly on your GitHub App.

## Usage

Once the app is installed, follow this document on [Using the Private Mirrors App](docs/using-the-app.md) to get the repository fork and mirrors set up for work.
Expand Down
6 changes: 6 additions & 0 deletions docs/developing.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@ npm run build

This will create an optimized production build of the app in the `out` directory.

### Building for GHE.com / GHES

GHE.com and GHES settings are runtime environment variables. The server passes the validated GitHub URLs to client-side hooks and UI links, so production builds and Docker images do not require separate `NEXT_PUBLIC_*` variables or build arguments.

See the [GHE.com / GHES section in the README](../README.md#integrating-the-app-into-ghecom-data-residency-or-ghes) for the full list of environment variables.

## Deployment

To deploy the app, follow the instructions for your preferred hosting provider. The app can be deployed to any hosting provider that supports Next.js/Docker.
36 changes: 36 additions & 0 deletions env.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { createEnv } from '@t3-oss/env-nextjs'
import { z } from 'zod'

const DEFAULT_GITHUB_SERVER_URL = 'https://github.com'
const DEFAULT_GITHUB_API_URL = 'https://api.github.com'
const DEFAULT_GITHUB_GRAPHQL_URL = 'https://api.github.com/graphql'
const DEFAULT_GITHUB_USER_EMAIL_DOMAIN = 'users.noreply.github.com'

export const env = createEnv({
/*
* Serverside Environment variables, not available on the client.
Expand All @@ -21,6 +26,30 @@ export const env = createEnv({
NODE_ENV: z.string().optional().default('development'),
PUBLIC_ORG: z.string().optional(),
PRIVATE_ORG: z.string().optional(),
// GitHub Enterprise (GHE.com Data Residency / GHES) configuration.
GITHUB_SERVER_URL: z
.string()
.url()
.optional()
.default(DEFAULT_GITHUB_SERVER_URL)
.transform((value) => value.replace(/\/+$/, '')),
GITHUB_API_URL: z
.string()
.url()
.optional()
.default(DEFAULT_GITHUB_API_URL)
.transform((value) => value.replace(/\/+$/, '')),
GITHUB_GRAPHQL_URL: z
.string()
.url()
.optional()
.default(DEFAULT_GITHUB_GRAPHQL_URL)
.transform((value) => value.replace(/\/+$/, '')),
GITHUB_USER_EMAIL_DOMAIN: z
.string()
.min(1)
.optional()
.default(DEFAULT_GITHUB_USER_EMAIL_DOMAIN),
// Custom validation for a comma separated list of strings
// ex: ajhenry,github,ahpook
ALLOWED_HANDLES: z
Expand Down Expand Up @@ -122,6 +151,13 @@ export const env = createEnv({
NODE_ENV: process.env.NODE_ENV,
PUBLIC_ORG: process.env.PUBLIC_ORG,
PRIVATE_ORG: process.env.PRIVATE_ORG,
GITHUB_SERVER_URL:
process.env.GITHUB_SERVER_URL ?? DEFAULT_GITHUB_SERVER_URL,
GITHUB_API_URL: process.env.GITHUB_API_URL ?? DEFAULT_GITHUB_API_URL,
GITHUB_GRAPHQL_URL:
process.env.GITHUB_GRAPHQL_URL ?? DEFAULT_GITHUB_GRAPHQL_URL,
GITHUB_USER_EMAIL_DOMAIN:
process.env.GITHUB_USER_EMAIL_DOMAIN ?? DEFAULT_GITHUB_USER_EMAIL_DOMAIN,
ALLOWED_HANDLES: process.env.ALLOWED_HANDLES,
ALLOWED_ORGS: process.env.ALLOWED_ORGS,
SKIP_BRANCH_PROTECTION_CREATION:
Expand Down
45 changes: 24 additions & 21 deletions scripts/webhook-relay.mjs
Original file line number Diff line number Diff line change
@@ -1,26 +1,34 @@
import { sign } from '@octokit/webhooks-methods'
import WebhookRelay from 'github-app-webhook-relay-polling'
import crypto from 'node:crypto'
import { App } from 'octokit'
import { App, Octokit } from 'octokit'

import './proxy.mjs'
import { env } from '../env.mjs'

if (!process.env.PUBLIC_ORG) {
if (!env.PUBLIC_ORG) {
console.error(
'Missing PUBLIC_ORG environment variable. This is required for the webhook relay to work locally.',
)
process.exit(1)
}

const url = `${process.env.NEXTAUTH_URL}/api/webhooks`
const url = `${env.NEXTAUTH_URL}/api/webhooks`
const apiBaseUrl = env.GITHUB_API_URL

const privateKey =
process.env.PRIVATE_KEY &&
!process.env.PRIVATE_KEY.includes('-----BEGIN RSA PRIVATE KEY-----')
? // Support optional base64 decoding of the private key to prevent issues with complicated environment variable passing scenarios
Buffer.from(process.env.PRIVATE_KEY, 'base64').toString('utf8')
: // Handle a bug with multiline envs in docker - See https://github.com/moby/moby/issues/46773
(process.env.PRIVATE_KEY?.replace(/\\n/g, '\n') ?? '')
if (apiBaseUrl !== 'https://api.github.com') {
console.warn(
`[webhook-relay] Using API base URL: ${apiBaseUrl}. The polling webhook relay relies on the GitHub App hook deliveries endpoint and may not work against all GHE deployments.`,
)
}

const RelayOctokit = Octokit.defaults({ baseUrl: apiBaseUrl })

const privateKey = !env.PRIVATE_KEY.includes('-----BEGIN RSA PRIVATE KEY-----')
? // Support optional base64 decoding of the private key to prevent issues with complicated environment variable passing scenarios
Buffer.from(env.PRIVATE_KEY, 'base64').toString('utf8')
: // Handle a bug with multiline envs in docker - See https://github.com/moby/moby/issues/46773
env.PRIVATE_KEY.replace(/\\n/g, '\n')

const privateKeyPkcs8 = crypto.createPrivateKey(privateKey).export({
type: 'pkcs8',
Expand All @@ -29,12 +37,13 @@ const privateKeyPkcs8 = crypto.createPrivateKey(privateKey).export({

const setupForwarder = (organizationOwner) => {
const app = new App({
appId: process.env.APP_ID,
appId: env.APP_ID,
privateKey: privateKeyPkcs8,
webhooks: {
// value does not matter, but has to be set.
secret: 'secret',
},
Octokit: RelayOctokit,
})

const relay = new WebhookRelay({
Expand Down Expand Up @@ -65,10 +74,7 @@ const setupForwarder = (organizationOwner) => {

const headers = {}

headers['x-hub-signature-256'] = await sign(
process.env.WEBHOOK_SECRET,
parsedEvent,
)
headers['x-hub-signature-256'] = await sign(env.WEBHOOK_SECRET, parsedEvent)
headers['x-github-event'] = eventNameWithAction
headers['x-github-delivery'] = event.id
headers['content-type'] = 'application/json'
Expand All @@ -91,12 +97,9 @@ const setupForwarder = (organizationOwner) => {
relay.start()
}

setupForwarder(process.env.PUBLIC_ORG)
setupForwarder(env.PUBLIC_ORG)

if (
process.env.PRIVATE_ORG &&
process.env.PUBLIC_ORG !== process.env.PRIVATE_ORG
) {
if (env.PRIVATE_ORG && env.PUBLIC_ORG !== env.PRIVATE_ORG) {
console.log('Setting up private organization webhook relay')
setupForwarder(process.env.PRIVATE_ORG)
setupForwarder(env.PRIVATE_ORG)
}
4 changes: 3 additions & 1 deletion src/app/[organizationId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@ import Fuse from 'fuse.js'
import { OrgHeader } from 'app/components/header/OrgHeader'
import { OrgBreadcrumbs } from 'app/components/breadcrumbs/OrgBreadcrumbs'
import { ErrorFlash } from 'app/components/flash/ErrorFlash'
import { useGitHubEnvironment } from 'app/context/GitHubEnvironmentProvider'

const Organization = () => {
const { organizationId } = useParams()
const { serverUrl } = useGitHubEnvironment()
const { data, isLoading } = trpc.checkInstallation.useQuery({
orgId: organizationId as string,
})
Expand Down Expand Up @@ -207,7 +209,7 @@ const Organization = () => {
<Text sx={{ color: 'fg.muted' }}>
Forked from{' '}
<Link
href={`https://github.com/${row.parent.owner.login}/${row.parent.name}`}
href={`${serverUrl}/${row.parent.owner.login}/${row.parent.name}`}
target="_blank"
rel="noreferrer noopener"
sx={{ color: 'fg.muted' }}
Expand Down
84 changes: 69 additions & 15 deletions src/app/api/auth/lib/nextauth-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@ import { AuthOptions, Profile } from 'next-auth'
import { JWT } from 'next-auth/jwt'
import GitHub from 'next-auth/providers/github'
import { logger } from '../../../../utils/logger'
import { env } from '../../../../../env.mjs'

import 'utils/proxy'

const authLogger = logger.getSubLogger({ name: 'auth' })
const githubEndpointConfig = {
apiUrl: env.GITHUB_API_URL,
graphQlUrl: env.GITHUB_GRAPHQL_URL,
}

/**
* Converts seconds until expiration to date in milliseconds
Expand All @@ -25,7 +30,7 @@ const normalizeExpirationDate = (seconds: number) => {
export const verifySession = async (token: string | undefined) => {
if (!token) return false

const octokit = personalOctokit(token)
const octokit = personalOctokit(token, githubEndpointConfig)
try {
await octokit.rest.users.getAuthenticated()
return true
Expand Down Expand Up @@ -57,8 +62,7 @@ export const refreshAccessToken = async (
grant_type: 'refresh_token',
})

const url =
'https://github.com/login/oauth/access_token?' + params.toString()
const url = `${env.GITHUB_SERVER_URL}/login/oauth/access_token?${params.toString()}`

const response = await fetch(url, {
headers: {
Expand Down Expand Up @@ -97,23 +101,70 @@ export const refreshAccessToken = async (
}
}

const apiBaseUrl = env.GITHUB_API_URL

export const createGitHubUserinfoRequest =
(apiBaseUrl: string) =>
async ({
client,
tokens,
}: {
client: { userinfo: (accessToken: string) => Promise<unknown> }
tokens: { access_token?: string | null }
}) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const profile = (await client.userinfo(tokens.access_token!)) as any

if (!profile.email) {
try {
const res = await fetch(`${apiBaseUrl}/user/emails`, {
headers: {
Authorization: `token ${tokens.access_token}`,
'User-Agent': 'private-mirrors-app',
},
})

if (res.ok) {
const emails: Array<{
email: string
primary: boolean
verified: boolean
}> = await res.json()
profile.email = (emails.find((e) => e.primary) ?? emails[0])?.email
}
} catch (error) {
authLogger.warn('Failed to fetch user emails', { error })
}
}

return profile
}

export const nextAuthOptions: AuthOptions = {
pages: {
signIn: '/auth/login',
error: '/auth/error',
},
debug: process.env.NODE_ENV === 'development',
debug: env.NODE_ENV === 'development',
providers: [
GitHub({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
issuer: 'https://github.com/login/oauth',
clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET,
issuer: `${env.GITHUB_SERVER_URL}/login/oauth`,
authorization: {
url: `${env.GITHUB_SERVER_URL}/login/oauth/authorize`,
params: { scope: 'repo, user, read:org' },
},
token: `${env.GITHUB_SERVER_URL}/login/oauth/access_token`,
userinfo: {
url: `${apiBaseUrl}/user`,
// The built-in GitHub provider hardcodes `https://api.github.com/user/emails`
// for the email fallback. Override the request so we use the configured API host.
request: createGitHubUserinfoRequest(apiBaseUrl),
},
}),
],
secret: process.env.NEXTAUTH_SECRET!,
secret: env.NEXTAUTH_SECRET,
logger: {
error(code, metadata) {
if (!(metadata instanceof Error) && metadata.provider) {
Expand Down Expand Up @@ -143,12 +194,12 @@ export const nextAuthOptions: AuthOptions = {
}

// Get the allowed handles list
const allowedHandles = (
process.env.ALLOWED_HANDLES?.split(',') ?? []
).filter((handle) => handle !== '')
const allowedHandles = env.ALLOWED_HANDLES.split(',').filter(
(handle) => handle !== '',
)

// Get the allowed orgs list
const allowedOrgs = (process.env.ALLOWED_ORGS?.split(',') ?? []).filter(
const allowedOrgs = env.ALLOWED_ORGS.split(',').filter(
(org) => org !== '',
)

Expand Down Expand Up @@ -181,7 +232,10 @@ export const nextAuthOptions: AuthOptions = {
"Checking if any of user's orgs are in allowed orgs list",
)

const octokit = personalOctokit(params.account?.access_token as string)
const octokit = personalOctokit(
params.account?.access_token as string,
githubEndpointConfig,
)

// Get the user's organizations
const orgs = await octokit
Expand Down Expand Up @@ -258,8 +312,8 @@ export const nextAuthOptions: AuthOptions = {
// Refresh the access token
const refreshedToken = await refreshAccessToken(
token,
process.env.GITHUB_CLIENT_ID!,
process.env.GITHUB_CLIENT_SECRET!,
env.GITHUB_CLIENT_ID,
env.GITHUB_CLIENT_SECRET,
token.refreshToken,
)

Expand Down
Loading
Loading