Files
clawhub/convex/lib/githubActionsOidc.ts

533 lines
18 KiB
TypeScript

import { buildGitHubApiHeaders, buildGitHubHeaders } from "./githubAuth";
type JwtHeader = {
alg?: unknown;
kid?: unknown;
typ?: unknown;
};
type JwtPayload = Record<string, unknown>;
type JwkSet = {
keys?: Array<JsonWebKey & { kid?: string; alg?: string; use?: string; kty?: string }>;
};
export type TrustedGitHubActionsPublisher = {
repository: string;
repositoryId: string;
repositoryOwner: string;
repositoryOwnerId: string;
workflowFilename: string;
environment?: string;
};
export type VerifiedGitHubActionsIdentity = {
repository: string;
repositoryId: string;
repositoryOwner: string;
repositoryOwnerId: string;
workflowFilename: string;
workflowName: string;
workflowRef: string;
jobWorkflowRef?: string;
environment?: string;
runnerEnvironment: string;
eventName: string;
sha: string;
ref: string;
refType?: string;
actor?: string;
actorId?: string;
runId: string;
runAttempt: string;
};
type VerifyGitHubActionsOidcOptions = {
fetchImpl?: typeof fetch;
now?: () => number;
};
type GitHubActionsWorkflowPolicy = {
allowedEvents: readonly string[];
expectedRef?: string;
reusableWorkflow: { repository: string; workflowFilename: string } | null;
};
type GitHubRepositoryIdentity = {
repository: string;
repositoryId: string;
repositoryOwner: string;
repositoryOwnerId: string;
};
type ParsedWorkflowRef = {
repository: string;
workflowFilename: string;
};
const GITHUB_ACTIONS_ISSUER = "https://token.actions.githubusercontent.com";
const GITHUB_ACTIONS_JWKS_URL = `${GITHUB_ACTIONS_ISSUER}/.well-known/jwks`;
const TRUSTED_AUDIENCE = "clawhub";
const CLOCK_SKEW_MS = 60_000;
const JWKS_CACHE_TTL_MS = 5 * 60_000;
const OFFICIAL_REUSABLE_WORKFLOW_REPOSITORY = "openclaw/clawhub";
const OFFICIAL_REUSABLE_WORKFLOW_FILENAME = "package-publish.yml";
const SKILLS_SH_SYNC_WORKFLOW: TrustedGitHubActionsPublisher = {
repository: "openclaw/clawhub",
repositoryId: "1127248221",
repositoryOwner: "openclaw",
repositoryOwnerId: "252820863",
workflowFilename: "skills-sh-sync.yml",
environment: "Production",
};
let cachedJwks: { value: JwkSet; fetchedAt: number } | null = null;
export async function verifyGitHubActionsTrustedPublishJwt(
jwt: string,
trustedPublisher: TrustedGitHubActionsPublisher,
options: VerifyGitHubActionsOidcOptions = {},
): Promise<VerifiedGitHubActionsIdentity> {
return await verifyGitHubActionsWorkflowJwt(
jwt,
trustedPublisher,
{
allowedEvents: ["workflow_dispatch"],
reusableWorkflow: {
repository: OFFICIAL_REUSABLE_WORKFLOW_REPOSITORY,
workflowFilename: OFFICIAL_REUSABLE_WORKFLOW_FILENAME,
},
},
options,
);
}
export async function verifyGitHubActionsSkillsShSyncJwt(
jwt: string,
options: VerifyGitHubActionsOidcOptions = {},
): Promise<VerifiedGitHubActionsIdentity> {
return await verifyGitHubActionsWorkflowJwt(
jwt,
SKILLS_SH_SYNC_WORKFLOW,
{
allowedEvents: ["schedule", "workflow_dispatch"],
expectedRef: "refs/heads/main",
reusableWorkflow: null,
},
options,
);
}
async function verifyGitHubActionsWorkflowJwt(
jwt: string,
trustedPublisher: TrustedGitHubActionsPublisher,
workflowPolicy: GitHubActionsWorkflowPolicy,
options: VerifyGitHubActionsOidcOptions,
): Promise<VerifiedGitHubActionsIdentity> {
const fetchImpl = options.fetchImpl ?? fetch;
const now = (options.now ?? Date.now)();
const { signingInput, signature, header, payload } = decodeJwt(jwt);
if (header.alg !== "RS256") {
throw new Error(
`Unsupported GitHub OIDC signing algorithm: ${formatClaimValue(header.alg ?? "<missing>")}`,
);
}
const keyId = requireString(header.kid, "kid");
let jwks = await fetchGitHubActionsJwks(fetchImpl, now);
let jwk = jwks.keys?.find((entry) => entry.kid === keyId);
if (!jwk) {
jwks = await fetchGitHubActionsJwks(fetchImpl, now, true);
jwk = jwks.keys?.find((entry) => entry.kid === keyId);
}
if (!jwk) throw new Error(`Unknown GitHub OIDC signing key: ${keyId}`);
const key = await crypto.subtle.importKey(
"jwk",
jwk,
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
false,
["verify"],
);
const verified = await crypto.subtle.verify(
"RSASSA-PKCS1-v1_5",
key,
signature,
new TextEncoder().encode(signingInput),
);
if (!verified) throw new Error("Invalid GitHub OIDC signature");
const issuer = requireString(payload.iss, "iss");
if (issuer !== GITHUB_ACTIONS_ISSUER) {
throw new Error(`Unexpected GitHub OIDC issuer: ${issuer}`);
}
if (!claimContainsAudience(payload.aud, TRUSTED_AUDIENCE)) {
throw new Error(`Unexpected GitHub OIDC audience: ${formatAudience(payload.aud)}`);
}
assertTokenTimeWindow(payload, now);
const repository = requireString(payload.repository, "repository");
const repositoryId = requireClaimString(payload.repository_id, "repository_id");
const repositoryOwner = requireString(payload.repository_owner, "repository_owner");
const repositoryOwnerId = requireClaimString(payload.repository_owner_id, "repository_owner_id");
const workflowRef = requireString(payload.workflow_ref, "workflow_ref");
const workflow = parseWorkflowRef(workflowRef, repository);
const jobWorkflowRef = optionalString(payload.job_workflow_ref);
const runnerEnvironment = requireString(payload.runner_environment, "runner_environment");
const environment = optionalString(payload.environment);
const eventName = requireString(payload.event_name, "event_name");
const workflowName = requireString(payload.workflow, "workflow");
const sha = requireString(payload.sha, "sha");
const ref = requireString(payload.ref, "ref");
const runId = requireClaimString(payload.run_id, "run_id");
const runAttempt = requireClaimString(payload.run_attempt, "run_attempt");
const refType = optionalString(payload.ref_type);
const actor = optionalString(payload.actor);
const actorId = optionalStringValue(payload.actor_id);
if (repository !== trustedPublisher.repository) {
throw new Error(
`GitHub OIDC repository mismatch: expected ${trustedPublisher.repository}, got ${repository}`,
);
}
if (repositoryId !== trustedPublisher.repositoryId) {
throw new Error(
`GitHub OIDC repository_id mismatch: expected ${trustedPublisher.repositoryId}, got ${repositoryId}`,
);
}
if (repositoryOwner !== trustedPublisher.repositoryOwner) {
throw new Error(
`GitHub OIDC repository_owner mismatch: expected ${trustedPublisher.repositoryOwner}, got ${repositoryOwner}`,
);
}
if (repositoryOwnerId !== trustedPublisher.repositoryOwnerId) {
throw new Error(
`GitHub OIDC repository_owner_id mismatch: expected ${trustedPublisher.repositoryOwnerId}, got ${repositoryOwnerId}`,
);
}
if (workflow.workflowFilename !== trustedPublisher.workflowFilename) {
throw new Error(
`GitHub OIDC workflow mismatch: expected ${trustedPublisher.workflowFilename}, got ${workflow.workflowFilename}`,
);
}
// GitHub now includes job_workflow_ref for direct jobs too; only a different
// workflow ref represents a reusable-workflow delegation.
if (jobWorkflowRef && jobWorkflowRef !== workflowRef) {
const reusableWorkflow = parseWorkflowRef(jobWorkflowRef);
if (!workflowPolicy.reusableWorkflow) {
throw new Error("Reusable workflows may not run the skills.sh production sync");
}
const usesOfficialReusableWorkflow =
reusableWorkflow.repository === workflowPolicy.reusableWorkflow.repository &&
reusableWorkflow.workflowFilename === workflowPolicy.reusableWorkflow.workflowFilename;
if (!usesOfficialReusableWorkflow) {
throw new Error(
"Only the official ClawHub reusable workflow is supported for trusted publishing",
);
}
}
if (runnerEnvironment !== "github-hosted") {
throw new Error(
`Only GitHub-hosted runners may mint trusted publish tokens, got ${runnerEnvironment}`,
);
}
if (!workflowPolicy.allowedEvents.includes(eventName)) {
const expected = workflowPolicy.allowedEvents.join(" or ");
throw new Error(`GitHub OIDC workflow requires ${expected}, got ${eventName}`);
}
if (trustedPublisher.environment && environment !== trustedPublisher.environment) {
throw new Error(
`GitHub OIDC environment mismatch: expected ${trustedPublisher.environment}, got ${formatClaimValue(environment ?? "<missing>")}`,
);
}
if (workflowPolicy.expectedRef && ref !== workflowPolicy.expectedRef) {
throw new Error(`GitHub OIDC ref mismatch: expected ${workflowPolicy.expectedRef}, got ${ref}`);
}
return {
repository,
repositoryId,
repositoryOwner,
repositoryOwnerId,
workflowFilename: workflow.workflowFilename,
workflowName,
workflowRef,
...(jobWorkflowRef ? { jobWorkflowRef } : {}),
...(environment ? { environment } : {}),
runnerEnvironment,
eventName,
sha,
ref,
...(refType ? { refType } : {}),
...(actor ? { actor } : {}),
...(actorId ? { actorId } : {}),
runId,
runAttempt,
};
}
export async function fetchGitHubRepositoryIdentity(
repository: string,
fetchImpl: typeof fetch = fetch,
): Promise<GitHubRepositoryIdentity> {
const normalizedRepository = normalizeGitHubRepository(repository);
if (!normalizedRepository) {
throw new Error(`Invalid GitHub repository: ${repository}`);
}
const url = `https://api.github.com/repos/${normalizedRepository}`;
const headers = await buildGitHubRepositoryLookupHeaders(fetchImpl);
let response = await fetchImpl(url, {
headers,
});
if (shouldRetryRepositoryLookupWithoutAppAuth(response, headers)) {
response = await fetchImpl(url, {
headers: await buildGitHubRepositoryLookupHeaders(fetchImpl, { useGitHubApp: false }),
});
}
if (!response.ok) {
throw new Error(
`GitHub repository lookup failed for ${normalizedRepository}: ${response.status}`,
);
}
const body = await readPublicGitHubRepositoryLookupResponse({
response,
normalizedRepository,
url,
headers,
fetchImpl,
});
return repositoryIdentityFromLookupResponse(body);
}
async function readPublicGitHubRepositoryLookupResponse(options: {
response: Response;
normalizedRepository: string;
url: string;
headers: Record<string, string>;
fetchImpl: typeof fetch;
}) {
const body = (await options.response.json()) as GitHubRepositoryLookupResponse;
if (!isPublicGitHubRepository(body)) {
if (options.headers.Authorization) {
const publicResponse = await options.fetchImpl(options.url, {
headers: buildGitHubAnonymousRepositoryLookupHeaders(),
});
if (!publicResponse.ok) {
throw new Error(
`GitHub repository lookup failed for ${options.normalizedRepository}: ${publicResponse.status}`,
);
}
const publicBody = (await publicResponse.json()) as GitHubRepositoryLookupResponse;
if (isPublicGitHubRepository(publicBody)) {
return publicBody;
}
}
throw new Error(
`GitHub repository lookup failed for ${options.normalizedRepository}: repository must be public`,
);
}
return body;
}
function repositoryIdentityFromLookupResponse(body: GitHubRepositoryLookupResponse) {
const resolvedRepository = requireString(body.full_name, "full_name");
const ownerLogin = requireString(body.owner?.login, "owner.login");
return {
repository: resolvedRepository,
repositoryId: requireClaimString(body.id, "id"),
repositoryOwner: ownerLogin,
repositoryOwnerId: requireClaimString(body.owner?.id, "owner.id"),
};
}
type GitHubRepositoryLookupResponse = {
id?: unknown;
full_name?: unknown;
owner?: { login?: unknown; id?: unknown };
private?: unknown;
visibility?: unknown;
};
function isPublicGitHubRepository(body: GitHubRepositoryLookupResponse) {
return body.private === false && (body.visibility === undefined || body.visibility === "public");
}
async function buildGitHubRepositoryLookupHeaders(
fetchImpl: typeof fetch,
options: { useGitHubApp?: boolean } = {},
) {
return await buildGitHubApiHeaders({
accept: "application/vnd.github+json",
fetchImpl,
userAgent: "clawhub/package-trusted-publisher",
// Prefer authenticated app/PAT requests for public repository metadata so
// trusted-publisher setup does not depend on anonymous GitHub API limits.
useGitHubApp: options.useGitHubApp,
});
}
function buildGitHubAnonymousRepositoryLookupHeaders() {
return buildGitHubHeaders({
accept: "application/vnd.github+json",
userAgent: "clawhub/package-trusted-publisher",
});
}
function shouldRetryRepositoryLookupWithoutAppAuth(
response: Response,
headers: Record<string, string>,
) {
return Boolean(headers.Authorization) && [401, 403, 404].includes(response.status);
}
export function normalizeGitHubRepository(repository: string) {
const trimmed = repository
.trim()
.replace(/^https?:\/\/github\.com\//i, "")
.replace(/\.git$/i, "");
const match = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/.exec(trimmed);
if (!match) return null;
return `${match[1]}/${match[2]}`;
}
export function extractWorkflowFilenameFromWorkflowRef(
workflowRef: string,
expectedRepository?: string,
) {
return parseWorkflowRef(workflowRef, expectedRepository).workflowFilename;
}
function parseWorkflowRef(workflowRef: string, expectedRepository?: string): ParsedWorkflowRef {
const match = /^([^/]+\/[^/]+)\/\.github\/workflows\/([^@/]+)@.+$/.exec(workflowRef.trim());
if (!match?.[1] || !match[2]) {
throw new Error(`Invalid GitHub workflow_ref claim: ${workflowRef}`);
}
if (expectedRepository && match[1] !== expectedRepository) {
throw new Error(
`GitHub workflow_ref repository mismatch: expected ${expectedRepository}, got ${match[1]}`,
);
}
return {
repository: match[1],
workflowFilename: match[2],
};
}
function decodeJwt(jwt: string) {
const parts = jwt.trim().split(".");
if (parts.length !== 3) throw new Error("Invalid GitHub OIDC token format");
const [encodedHeader, encodedPayload, encodedSignature] = parts;
const header = parseJsonSegment(encodedHeader, "header") as JwtHeader;
const payload = parseJsonSegment(encodedPayload, "payload") as JwtPayload;
return {
header,
payload,
signingInput: `${encodedHeader}.${encodedPayload}`,
signature: base64UrlToBytes(encodedSignature),
};
}
function parseJsonSegment(segment: string, label: string): unknown {
try {
return JSON.parse(new TextDecoder().decode(base64UrlToBytes(segment)));
} catch {
throw new Error(`Invalid GitHub OIDC ${label}`);
}
}
function base64UrlToBytes(value: string) {
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
async function fetchGitHubActionsJwks(fetchImpl: typeof fetch, now: number, forceRefresh = false) {
if (!forceRefresh && cachedJwks && now - cachedJwks.fetchedAt < JWKS_CACHE_TTL_MS) {
return cachedJwks.value;
}
const response = await fetchImpl(GITHUB_ACTIONS_JWKS_URL, {
headers: {
Accept: "application/json",
"User-Agent": "clawhub/github-actions-oidc",
},
});
if (!response.ok) {
throw new Error(`Failed to fetch GitHub OIDC JWKS: ${response.status}`);
}
const jwks = (await response.json()) as JwkSet;
cachedJwks = { value: jwks, fetchedAt: now };
return jwks;
}
function claimContainsAudience(audience: unknown, expected: string) {
if (typeof audience === "string") return audience === expected;
if (!Array.isArray(audience)) return false;
return audience.includes(expected);
}
function formatAudience(audience: unknown) {
if (typeof audience === "string") return audience;
if (Array.isArray(audience)) return audience.join(", ");
return formatClaimValue(audience ?? "<missing>");
}
function formatClaimValue(value: unknown) {
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return JSON.stringify(value);
}
function assertTokenTimeWindow(payload: JwtPayload, now: number) {
const expiresAt = requireNumericClaim(payload.exp, "exp") * 1000;
if (now - CLOCK_SKEW_MS >= expiresAt) {
throw new Error("GitHub OIDC token has expired");
}
const notBefore =
payload.nbf === undefined ? undefined : requireNumericClaim(payload.nbf, "nbf") * 1000;
if (typeof notBefore === "number" && now + CLOCK_SKEW_MS < notBefore) {
throw new Error("GitHub OIDC token is not active yet");
}
}
function requireClaimString(value: unknown, label: string) {
const normalized = optionalStringValue(value);
if (!normalized) throw new Error(`Missing GitHub OIDC claim: ${label}`);
return normalized;
}
function requireString(value: unknown, label: string) {
if (typeof value !== "string" || !value.trim()) {
throw new Error(`Missing GitHub OIDC claim: ${label}`);
}
return value;
}
function optionalString(value: unknown) {
return typeof value === "string" && value.trim() ? value : undefined;
}
function optionalStringValue(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) return String(value);
return optionalString(value);
}
function requireNumericClaim(value: unknown, label: string) {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
throw new Error(`Missing GitHub OIDC claim: ${label}`);
}
export const __test = {
base64UrlToBytes,
claimContainsAudience,
};