fix(github): support immutable OIDC subjects (#44776)
This commit is contained in:
parent
f8b4dd70ac
commit
f4019cab3e
6 changed files with 97 additions and 31 deletions
|
|
@ -5,6 +5,9 @@
|
|||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "catalog:",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { jwtVerify, createRemoteJWKSet } from "jose"
|
|||
import { createAppAuth } from "@octokit/auth-app"
|
||||
import { Octokit } from "@octokit/rest"
|
||||
import { Resource } from "sst"
|
||||
import { parseRepositoryClaim } from "./github"
|
||||
|
||||
type Env = {
|
||||
SYNC_SERVER: DurableObjectNamespace<SyncServer>
|
||||
|
|
@ -269,42 +270,41 @@ export default new Hono<{ Bindings: Env }>()
|
|||
|
||||
// verify token
|
||||
const JWKS = createRemoteJWKSet(new URL(JWKS_URL))
|
||||
let owner, repo
|
||||
let repository: ReturnType<typeof parseRepositoryClaim>
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, JWKS, {
|
||||
issuer: GITHUB_ISSUER,
|
||||
audience: EXPECTED_AUDIENCE,
|
||||
})
|
||||
const sub = payload.sub // e.g. 'repo:my-org/my-repo:ref:refs/heads/main'
|
||||
const parts = sub.split(":")[1].split("/")
|
||||
owner = parts[0]
|
||||
repo = parts[1]
|
||||
repository = parseRepositoryClaim(payload)
|
||||
} catch (err) {
|
||||
console.error("Token verification failed:", err)
|
||||
return c.json({ error: "Invalid or expired token" }, { status: 403 })
|
||||
}
|
||||
|
||||
// Create app JWT token
|
||||
const auth = createAppAuth({
|
||||
appId: Resource.GITHUB_APP_ID.value,
|
||||
privateKey: Resource.GITHUB_APP_PRIVATE_KEY.value,
|
||||
})
|
||||
const appAuth = await auth({ type: "app" })
|
||||
|
||||
// Lookup installation
|
||||
const octokit = new Octokit({ auth: appAuth.token })
|
||||
const { data: installation } = await octokit.apps.getRepoInstallation({
|
||||
owner,
|
||||
repo,
|
||||
})
|
||||
|
||||
// Get installation token
|
||||
const installationAuth = await auth({
|
||||
type: "installation",
|
||||
installationId: installation.id,
|
||||
})
|
||||
|
||||
return c.json({ token: installationAuth.token })
|
||||
try {
|
||||
const auth = createAppAuth({
|
||||
appId: Resource.GITHUB_APP_ID.value,
|
||||
privateKey: Resource.GITHUB_APP_PRIVATE_KEY.value,
|
||||
})
|
||||
const appAuth = await auth({ type: "app" })
|
||||
const octokit = new Octokit({ auth: appAuth.token })
|
||||
const { data: installation } = await octokit.apps.getRepoInstallation({
|
||||
owner: repository.owner,
|
||||
repo: repository.repo,
|
||||
})
|
||||
const installationAuth = await auth({
|
||||
type: "installation",
|
||||
installationId: installation.id,
|
||||
})
|
||||
return c.json({ token: installationAuth.token })
|
||||
} catch (error) {
|
||||
console.error("GitHub App token exchange failed:", error)
|
||||
return c.json(
|
||||
{ error: `Failed to exchange GitHub App token for ${repository.owner}/${repository.repo}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
})
|
||||
/**
|
||||
* Used by the GitHub action to get GitHub installation access token given user PAT token (used when testing `opencode github run` locally)
|
||||
|
|
|
|||
14
packages/function/src/github.ts
Normal file
14
packages/function/src/github.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { JWTPayload } from "jose"
|
||||
|
||||
export function parseRepositoryClaim(payload: JWTPayload) {
|
||||
const claim = payload.repository
|
||||
if (typeof claim !== "string") throw new Error("Repository claim is missing")
|
||||
|
||||
const parts = claim.split("/")
|
||||
if (parts.length !== 2 || !parts[0] || !parts[1]) throw new Error("Repository claim is invalid")
|
||||
|
||||
return {
|
||||
owner: parts[0],
|
||||
repo: parts[1],
|
||||
}
|
||||
}
|
||||
39
packages/function/test/github.test.ts
Normal file
39
packages/function/test/github.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { parseRepositoryClaim } from "../src/github"
|
||||
|
||||
describe("parseRepositoryClaim", () => {
|
||||
test("reads repository identity with a legacy subject", () => {
|
||||
expect(
|
||||
parseRepositoryClaim({
|
||||
repository: "octocat/my-repo",
|
||||
sub: "repo:octocat/my-repo:ref:refs/heads/main",
|
||||
}),
|
||||
).toEqual({ owner: "octocat", repo: "my-repo" })
|
||||
})
|
||||
|
||||
test("reads repository identity with an immutable subject", () => {
|
||||
expect(
|
||||
parseRepositoryClaim({
|
||||
repository: "octocat/my-repo",
|
||||
sub: "repo:octocat@123456/my-repo@456789:ref:refs/heads/main",
|
||||
}),
|
||||
).toEqual({ owner: "octocat", repo: "my-repo" })
|
||||
})
|
||||
|
||||
test("does not depend on a repository path in a customized subject", () => {
|
||||
expect(
|
||||
parseRepositoryClaim({
|
||||
repository: "octocat/my-repo",
|
||||
sub: "repository_owner:octocat:repository_visibility:private",
|
||||
}),
|
||||
).toEqual({ owner: "octocat", repo: "my-repo" })
|
||||
})
|
||||
|
||||
test("rejects a missing repository claim", () => {
|
||||
expect(() => parseRepositoryClaim({})).toThrow("Repository claim is missing")
|
||||
})
|
||||
|
||||
test("rejects an invalid repository claim", () => {
|
||||
expect(() => parseRepositoryClaim({ repository: "octocat" })).toThrow("Repository claim is invalid")
|
||||
})
|
||||
})
|
||||
|
|
@ -437,6 +437,7 @@ export const githubRun = Effect.fn("Cli.github.run")(function* (args: { event?:
|
|||
let session: { id: SessionID; title: string; version: string }
|
||||
let shareId: string | undefined
|
||||
let exitCode = 0
|
||||
let githubClientReady = false
|
||||
type PromptFiles = Awaited<ReturnType<typeof getUserPrompt>>["promptFiles"]
|
||||
const triggerCommentId = isCommentEvent
|
||||
? (payload as IssueCommentEvent | PullRequestReviewCommentEvent).comment.id
|
||||
|
|
@ -485,6 +486,7 @@ export const githubRun = Effect.fn("Cli.github.run")(function* (args: { event?:
|
|||
octoGraph = graphql.defaults({
|
||||
headers: { authorization: `token ${appToken}` },
|
||||
})
|
||||
githubClientReady = true
|
||||
|
||||
const { userPrompt, promptFiles } = await getUserPrompt()
|
||||
if (!useGithubToken) {
|
||||
|
|
@ -639,9 +641,13 @@ export const githubRun = Effect.fn("Cli.github.run")(function* (args: { event?:
|
|||
} else if (e instanceof Error) {
|
||||
msg = e.message
|
||||
}
|
||||
if (isUserEvent) {
|
||||
await createComment(`${msg}${footer()}`)
|
||||
await removeReaction(commentType)
|
||||
if (isUserEvent && githubClientReady) {
|
||||
try {
|
||||
await createComment(`${msg}${footer()}`)
|
||||
await removeReaction(commentType)
|
||||
} catch (error) {
|
||||
console.error("Failed to report error on GitHub:", error)
|
||||
}
|
||||
}
|
||||
core.setFailed(msg)
|
||||
// Also output the clean error message for the action to capture
|
||||
|
|
@ -1004,8 +1010,9 @@ export const githubRun = Effect.fn("Cli.github.run")(function* (args: { event?:
|
|||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const responseJson = (await response.json()) as { error?: string }
|
||||
throw new Error(`App token exchange failed: ${response.status} ${response.statusText} - ${responseJson.error}`)
|
||||
throw new Error(
|
||||
`App token exchange failed: ${response.status} ${response.statusText} - ${await response.text()}`,
|
||||
)
|
||||
}
|
||||
|
||||
const responseJson = (await response.json()) as { token: string }
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@
|
|||
"dependsOn": ["^build"],
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode-ai/function#test": {
|
||||
"outputs": []
|
||||
},
|
||||
"@opencode-ai/app#test": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": []
|
||||
|
|
|
|||
Loading…
Reference in a new issue