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
20 changes: 20 additions & 0 deletions docs/github-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,26 @@ human-facing display value: the GitHub account login is the Context label and
the installation ID remains stable metadata. Realmroot and Toolbox never use
the display label as authorization input.

## Installation permission upgrades

GitHub does not return permission-update acceptance to the App's Setup URL.
The Setup URL remains responsible for new installations and repository-access
changes, but it is not the completion signal for a newly added App permission.

When an existing installation lacks a newly requested permission, the Adapter
keeps the original OAuth intent and opens the exact installation review URL at
`/settings/installations/{installation_id}/permissions/update`. An
Adapter-owned waiting page polls the preserved transaction while GitHub opens
the review in a separate window. The signed `installation` webhook with action
`new_permissions_accepted` updates Adapter-owned authority. Only after that
target installation covers every requested scope does the Adapter complete the
same OAuth transaction, close the GitHub window, and return the controller to
Realmroot automatically.

Incomplete permission upgrades are not persisted as connected authority and do
not surface an intermediate Adapter error page during the normal approval
flow.

## Permission translation

The adapter reads the permissions configured on the GitHub App and exposes
Expand Down
12 changes: 12 additions & 0 deletions specs/github-adapter.feature
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,23 @@ Feature: GitHub App adapter
Then the adapter keeps one stable external subject for the owner
And replaces the installation authorization details with the newly authorized set

@journey:github-installation-permission-upgrade @entrypoint:http
Scenario: Authorization resumes after a GitHub installation permission upgrade
Given an existing GitHub App installation lacks a newly requested permission
When the owner authorizes that permission through Realmroot
Then the adapter preserves the original authorization transaction
And opens the exact target installation's permission review
And waits for GitHub's signed lifecycle event instead of expecting an unsupported browser callback
And resumes the same Realmroot authorization when that target installation accepts the permission
And returns automatically to the pending Realmroot approval
But it does not show an adapter insufficient-scope page

@journey:github-context-catalog @entrypoint:http
Scenario: GitHub describes installation Contexts without exposing credentials
Given the Adapter holds an active GitHub external authorization
When Realmroot reads the advertised authorization-detail catalog with the connected subject token
Then the adapter returns each active installation with its account name, stable installation ID, account type, and repository selection
And reports the exact scopes currently granted by each installation
And the response uses the shared authorization-detail catalog representation
And the authorization detail type is a stable URI owned by the Adapter
But it does not expose installation credentials
Expand Down
98 changes: 86 additions & 12 deletions src/core/external-authorization-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export type ExternalProviderAuthorization = {
list(input: { subject: string; limit: number; offset: number }): Promise<{
items: Array<{
authorizationDetail: Record<string, unknown>
grantedScopes?: string[]
display: { label: string; description?: string; metadata?: Record<string, string> }
}>
pagination: { limit: number; offset: number; total: number; hasMore: boolean; nextOffset: number | null }
Expand All @@ -44,6 +45,11 @@ export type ExternalProviderAuthorization = {
requested: Array<Record<string, unknown>>
granted: Array<Record<string, unknown>>
}): boolean
validateScopes?(input: {
subject: string
scopes: string[]
authorizationDetails: Array<Record<string, unknown>>
}): Promise<boolean>
validateGrant?(input: {
subject: string
scopes: string[]
Expand All @@ -64,6 +70,14 @@ export type ExternalProviderAuthorization = {
}): Promise<
| { type: 'continue'; url: string; stage: string; data: Record<string, unknown>; providerState: string }
| { type: 'complete'; grant: Omit<ExternalOAuthGrant, 'providerId' | 'clientId'> }
| { type: 'error'; error: 'access_denied' | 'server_error'; description: string }
>
resume?(input: {
intent: ExternalOAuthIntent
}): Promise<
| { type: 'pending' }
| { type: 'complete'; grant: Omit<ExternalOAuthGrant, 'providerId' | 'clientId'> }
| { type: 'error'; error: 'access_denied' | 'server_error'; description: string }
>
}

Expand Down Expand Up @@ -170,6 +184,19 @@ export async function createExternalAuthorizationServer(input: {
app.get(providerCallbackPath, async (c) => {
const state = required(c.req.query('state'), 'state')
const intent = await input.store.intentByProviderState(await sha256(state))
const providerError = c.req.query('error')
if (providerError) {
await input.store.cancelIntent(intent)
return c.redirect(
authorizationErrorRedirect(
intent,
providerError === 'access_denied' ? 'access_denied' : 'server_error',
providerError === 'access_denied'
? 'Provider authorization was denied.'
: 'Provider authorization failed.',
),
)
}
const result = await input.provider.complete({
callbackUrl: c.req.url,
intent,
Expand All @@ -185,17 +212,32 @@ export async function createExternalAuthorizationServer(input: {
})
return c.redirect(result.url)
}
const code = opaque('code')
await input.store.completeIntent(
intent,
{ ...result.grant, providerId: input.provider.id, clientId: intent.clientId },
code,
)
const callback = new URL(intent.redirectUri)
callback.searchParams.set('code', code)
callback.searchParams.set('state', intent.realmrootState)
return c.redirect(callback.toString())
if (result.type === 'error') {
await input.store.cancelIntent(intent)
return c.redirect(authorizationErrorRedirect(intent, result.error, result.description))
}
return c.redirect(await completeAuthorization(input, intent, result.grant))
})
const resume = input.provider.resume
if (resume) {
app.get(`/oauth/${input.provider.id}/continue`, async (c) => {
c.header('Cache-Control', 'no-store')
const intent = await input.store.intentByProviderState(await sha256(required(c.req.query('state'), 'state')))
const result = await resume({ intent })
if (result.type === 'pending') return c.json({ status: 'pending' }, 202)
if (result.type === 'error') {
await input.store.cancelIntent(intent)
return c.json({
status: 'failed',
redirectUrl: authorizationErrorRedirect(intent, result.error, result.description),
})
}
return c.json({
status: 'complete',
redirectUrl: await completeAuthorization(input, intent, result.grant),
})
})
}
app.post(`/oauth/${input.provider.id}/token`, async (c) => {
const client = await authenticateClient(input.store, input.provider.id, c.req.raw)
const form = await c.req.formData()
Expand Down Expand Up @@ -262,10 +304,17 @@ export async function createExternalAuthorizationServer(input: {
}
const scopes = normalizeScopes(requiredForm(form, 'scope'))
const subjectScopes = normalizeScopes(String(subject.payload.scope ?? ''))
if (scopes.some((scope) => !subjectScopes.includes(scope))) {
const authorizationDetails = parseAuthorizationDetails(form.get('authorization_details'))
const scopesGranted = input.provider.validateScopes
? await input.provider.validateScopes({
subject: String(subject.payload.sub),
scopes,
authorizationDetails,
})
: scopes.every((scope) => subjectScopes.includes(scope))
if (!scopesGranted) {
throw oauthError('invalid_scope', 'Requested scope exceeds the connected account.')
}
const authorizationDetails = parseAuthorizationDetails(form.get('authorization_details'))
const subjectDetails = authorizationDetailsValue(subject.payload.authorization_details)
if (
!(input.provider.authorizationDetailsSubset ?? authorizationDetailsSubset)({
Expand Down Expand Up @@ -472,6 +521,31 @@ export async function createExternalAuthorizationServer(input: {
}
}

async function completeAuthorization(
input: Pick<Parameters<typeof createExternalAuthorizationServer>[0], 'provider' | 'store'>,
intent: ExternalOAuthIntent,
grant: Omit<ExternalOAuthGrant, 'providerId' | 'clientId'>,
) {
const code = opaque('code')
await input.store.completeIntent(intent, { ...grant, providerId: input.provider.id, clientId: intent.clientId }, code)
const callback = new URL(intent.redirectUri)
callback.searchParams.set('code', code)
callback.searchParams.set('state', intent.realmrootState)
return callback.toString()
}

function authorizationErrorRedirect(
intent: ExternalOAuthIntent,
error: 'access_denied' | 'server_error',
description: string,
) {
const callback = new URL(intent.redirectUri)
callback.searchParams.set('error', error)
callback.searchParams.set('error_description', description)
callback.searchParams.set('state', intent.realmrootState)
return callback.toString()
}

async function authenticateClient(store: D1ExternalOAuthStore, providerId: string, request: Request) {
const header = request.headers.get('authorization')
if (!header?.startsWith('Basic ')) throw oauthError('invalid_client', 'Client authentication is required.', 401)
Expand Down
8 changes: 8 additions & 0 deletions src/core/external-oauth-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,14 @@ export class D1ExternalOAuthStore {
if (results[1]?.meta.changes !== 1) throw unauthorized('External OAuth authorization was already completed.')
}

async cancelIntent(intent: ExternalOAuthIntent) {
const result = await this.db
.prepare("DELETE FROM external_oauth_intent WHERE id = ? AND status = 'pending'")
.bind(intent.id)
.run()
if (result.meta.changes !== 1) throw unauthorized('External OAuth authorization was already completed.')
}

async consumeCode(input: { code: string; clientId: string; redirectUri: string; verifier: string }) {
const codeHash = await sha256(input.code)
const row = await this.db
Expand Down
14 changes: 9 additions & 5 deletions src/providers/github/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ export function createGitHubAdapter(
const createToken = await provider.installationToken({
installationId: installation.installationId,
permissions: scopesToPermissions(new Set(['pull_requests:write']), available),
repositories: [repository as string],
...repositoryRestriction(installation, repository as string),
})
const response = await createGitHubPullRequestWithRest({
provider,
Expand Down Expand Up @@ -198,7 +198,7 @@ export function createGitHubAdapter(
const commentToken = await provider.installationToken({
installationId: installation.installationId,
permissions: scopesToPermissions(new Set([commentScope]), available),
repositories: [repository as string],
...repositoryRestriction(installation, repository as string),
})
const response = await createGitHubCommentWithRest({
provider,
Expand Down Expand Up @@ -241,7 +241,7 @@ export function createGitHubAdapter(
const mergeToken = await provider.installationToken({
installationId: installation.installationId,
permissions: scopesToPermissions(new Set(['contents:write']), available),
repositories: [repository as string],
...repositoryRestriction(installation, repository as string),
})
const response = await mergeGitHubPullRequestWithRest({
provider,
Expand Down Expand Up @@ -297,7 +297,7 @@ export function createGitHubAdapter(
const token = await provider.installationToken({
installationId: installation.installationId,
permissions,
repositories: [repository as string],
...repositoryRestriction(installation, repository as string),
})
const response = await provider.request(
new Request(target.url, {
Expand Down Expand Up @@ -341,7 +341,7 @@ export function createGitHubAdapter(
const token = await provider.installationToken({
installationId: installation.installationId,
permissions,
...(repository ? { repositories: [repository] } : {}),
...(repository ? repositoryRestriction(installation, repository) : {}),
})
const upstreamRequest = new Request(upstream, {
method: c.req.method,
Expand Down Expand Up @@ -489,6 +489,10 @@ function repositoryTarget(path: string, installation: GitHubAuthorizationContext
return repository
}

function repositoryRestriction(installation: GitHubAuthorizationContext, repository: string) {
return installation.repositorySelection === 'selected' ? { repositories: [repository] } : {}
}

function installationId(value: string) {
const parsed = Number(value)
if (!Number.isSafeInteger(parsed) || parsed <= 0) throw badRequest('The GitHub installation ID is invalid.')
Expand Down
18 changes: 15 additions & 3 deletions src/providers/github/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const userInstallationsSchema = z.object({
installations: z.array(
z.object({
id: z.number().int().positive(),
html_url: z.url().refine((value) => new URL(value).origin === 'https://github.com'),
account: z.object({ login: z.string().min(1) }),
target_type: z.string().min(1),
permissions: permissionsSchema,
Expand Down Expand Up @@ -99,7 +100,7 @@ export function createGitHubProvider(input: GitHubClientInput): GitHubProvider {
signal: AbortSignal.timeout(mode === 'git' ? 10 * 60_000 : 30_000),
}),
)
if (requireSuccess && !response.ok) throw providerFailure(response, request.url)
if (requireSuccess && !response.ok) throw await providerFailure(response, request.url)
return response
}
}
Expand Down Expand Up @@ -147,6 +148,7 @@ export function createGitHubConnectionProvider(
return Promise.all(
parsed.installations.map(async (installation) => ({
id: installation.id,
htmlUrl: installation.html_url,
accountLogin: installation.account.login,
targetType: installation.target_type,
permissions: installation.permissions,
Expand All @@ -166,6 +168,9 @@ export function createGitHubConnectionProvider(
url.searchParams.set('state', state)
return url.toString()
},
permissionUpdateUrl(installation) {
return `${installation.htmlUrl}/permissions/update`
},
}

async function listInstallationRepositories(token: string, installationId: number) {
Expand Down Expand Up @@ -224,10 +229,17 @@ function forwardedHeaders(input: Headers) {
return headers
}

function providerFailure(response: Response, target: string) {
async function providerFailure(response: Response, target: string) {
const requestId = response.headers.get('x-github-request-id')
const body = z.object({ message: z.string().min(1).max(500) }).safeParse(
await response
.clone()
.json()
.catch(() => null),
)
const message = body.success ? `: ${body.data.message}` : ''
return failedDependency(
`GitHub rejected ${new URL(target, 'https://api.github.com').pathname} with ${response.status}${requestId ? ` (${requestId})` : ''}.`,
`GitHub rejected ${new URL(target, 'https://api.github.com').pathname} with ${response.status}${requestId ? ` (${requestId})` : ''}${message}.`,
)
}

Expand Down
Loading
Loading