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
716 changes: 716 additions & 0 deletions IMPLEMENTATION_PLAN.md

Large diffs are not rendered by default.

380 changes: 380 additions & 0 deletions PAT_REMOVAL_PLAN.md

Large diffs are not rendered by default.

28 changes: 16 additions & 12 deletions src/shared/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@
* the earlier GitHub Actions bot, which authorized any identifiable user).
* Centralizing it here lets both workers apply the same policy and makes a
* future policy change a one-file edit.
*
* Auth context: all clients authenticate as the GitHub App installation
* (PAT support removed), so the collaborator check requires the App to hold
* the \"Collaborators: read-only\" repository permission.
*/

import { GitHubClient } from './github.js';
import { resolveSecretValue } from './secrets.js';

/**
* Authorizes a commenter as a repository collaborator.
* @param {import('./github.js').GitHubClient} github
Expand All @@ -25,15 +26,18 @@ export async function authorizeCommenter(github, owner, repo, username) {
return true;
} catch (error) {
if (error.status === 404) return false;
if (error.status === 403) {
// An installation token without the \"Collaborators: read-only\"
// permission gets 403 from GitHub. Surface it as a loud config error —
// misclassifying it as \"not a collaborator\" would silently lock out
// every legitimate /zai command.
const error403 = new Error(
'app_permission_missing: collaborator check requires Collaborators read-only',
);
error403.code = 'app_permission_missing';
error403.retryable = false;
throw error403;
}
throw error;
}
}

/**
* Convenience factory: build a GitHubClient + run the auth check in one call.
* Useful from the heavy worker, which rebuilds a client per invocation.
*/
export async function isAuthorized(env, owner, repo, username) {
const github = new GitHubClient(await resolveSecretValue(env.GITHUB_TOKEN));
return authorizeCommenter(github, owner, repo, username);
}
234 changes: 234 additions & 0 deletions src/shared/github-app-auth.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
/**
* GitHub App Authentication for Cloudflare Workers.
* Generates JWT and fetches Installation Access Tokens.
*
* This is the ONLY authentication path in the codebase (PAT support removed).
* Secrets must be resolved through `resolveSecretValue` — Secrets Store
* bindings can surface as string | {get()} | Promise, and a raw binding
* stringifies to "[object Object]", silently breaking every token mint.
*/

import { resolveSecretValue } from './secrets.js';

const GITHUB_API_BASE = 'https://api.github.com';

/**
* Creates a classified GitHub App authentication error.
* `retryable: false` marks config/permanent failures (queue: fail the job,
* webhook: 503 without pointlessly burning attempts).
* @param {string} code - Stable error code (e.g. 'app_jwt_rejected')
* @param {string} message - Short, log-safe message (no raw provider bodies)
* @param {Object} [opts]
* @param {boolean} [opts.retryable] - default true
* @param {number} [opts.status] - HTTP status from GitHub, when applicable
* @returns {Error}
*/
function appAuthError(code, message, { retryable = true, status } = {}) {
const error = new Error(message);
error.code = code;
error.retryable = retryable;
if (status !== undefined) error.status = status;
return error;
}

/**
* Encodes bytes to Base64URL format (for JWT parts).
* @param {Uint8Array} bytes
* @returns {string} Base64URL encoded string (no padding)
*/
function encodeBase64UrlBytes(bytes) {
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

/**
* Generates a JWT token signed with the app's private key.
* @param {string} appId - GitHub App ID
* @param {string} privateKey - PEM-encoded private key
* @returns {Promise<string>} JWT token
*/
export async function generateAppJwt(appId, privateKey) {
const now = Math.floor(Date.now() / 1000);
const payload = {
iat: now,
exp: now + 540, // 9 minutes (GitHub recommends max 10 min)
iss: appId,
};

const header = { alg: 'RS256', typ: 'JWT' };

const encoder = new TextEncoder();
const encodedHeader = encodeBase64UrlBytes(encoder.encode(JSON.stringify(header)));
const encodedPayload = encodeBase64UrlBytes(encoder.encode(JSON.stringify(payload)));

// Normalize private key (remove headers, whitespace)
const normalizedKey = privateKey
.replace(/-----BEGIN PRIVATE KEY-----/g, '')
.replace(/-----END PRIVATE KEY-----/g, '')
.replace(/\s+/g, '');

const binaryKey = Uint8Array.from(atob(normalizedKey), (c) => c.charCodeAt(0));

const cryptoKey = await crypto.subtle.importKey(
'pkcs8',
binaryKey,
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
false,
['sign'],
);

const signature = await crypto.subtle.sign(
'RSASSA-PKCS1-v1_5',
cryptoKey,
encoder.encode(`${encodedHeader}.${encodedPayload}`),
);

const encodedSignature = encodeBase64UrlBytes(new Uint8Array(signature));

return `${encodedHeader}.${encodedPayload}.${encodedSignature}`;
}

/**
* Maps an HTTP status from the installation-token endpoint to a classified
* appAuthError. Only status + code are kept — raw response bodies must not
* leak into logs or GitHub comments (SECURITY.md).
*/
function tokenEndpointError(status) {
if (status === 401) {
return appAuthError('app_jwt_rejected', 'GitHub rejected the app JWT (bad key or app id)', {
retryable: false,
status,
});
}
if (status === 403) {
return appAuthError('app_suspended', 'GitHub App is suspended or blocked', {
retryable: false,
status,
});
}
if (status === 404) {
return appAuthError('installation_not_found', 'Installation was removed or inaccessible', {
retryable: false,
status,
});
}
// 5xx / 429 / anything else unexpected: worth another attempt later.
return appAuthError('app_token_fetch_failed', `GitHub token endpoint returned ${status}`, {
retryable: true,
status,
});
}

/**
* Fetches an installation access token from GitHub.
* @param {string} jwt - App JWT token
* @param {string|number} installationId - Installation ID
* @returns {Promise<string>} Installation access token
*/
export async function fetchInstallationToken(jwt, installationId) {
let response;
try {
response = await fetch(`${GITHUB_API_BASE}/app/installations/${installationId}/access_tokens`, {
method: 'POST',
headers: {
Authorization: `Bearer ${jwt}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'zai-code-bot-workers',
},
});
} catch (error) {
// Network-level failure (offline, DNS, timeout): transient.
throw appAuthError('app_token_fetch_failed', `Token endpoint unreachable: ${error.message}`);
}

if (!response.ok) {
// Drain the body so the connection can be reused, then classify.
await response.text().catch(() => {});
throw tokenEndpointError(response.status);
}

const data = await response.json();
if (typeof data?.token !== 'string' || data.token === '') {
throw appAuthError('app_token_fetch_failed', 'Token endpoint returned no token');
}
return data.token;
}

/**
* Caches installation tokens to avoid generating JWT for every request.
* Uses KV namespace for caching (optional optimization). Cache errors are
* swallowed: a cache miss only costs an extra mint, never a failure.
*/
export class AppTokenCache {
constructor(kvNamespace, ttl = 300) {
this.kv = kvNamespace;
this.ttl = ttl; // 5 minutes
}

async get(installationId) {
if (!this.kv) return null;
try {
const { value } = await this.kv.get(`installation_token:${installationId}`, {
type: 'json',
});
return value;
} catch {
return null;
}
}

async set(installationId, token) {
if (!this.kv) return;
try {
await this.kv.put(`installation_token:${installationId}`, JSON.stringify({ value: token }), {
expirationTtl: this.ttl,
});
} catch {
// Ignore cache errors
}
}
}

/**
* Creates a token provider that handles JWT generation and caching.
* Resolves both Secrets Store bindings through `resolveSecretValue` (the
* documented happy path is a plain string, but the same store also surfaces
* `{get()}` and Promise shapes depending on the wrangler/workerd version).
*
* @param {Object} env - Environment bindings (GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, BOT_CACHE)
* @returns {Promise<{available: boolean, getInstallationToken: function}>}
*/
export async function createTokenProvider(env) {
const appId = await resolveSecretValue(env.GITHUB_APP_ID);
const privateKey = await resolveSecretValue(env.GITHUB_APP_PRIVATE_KEY);
const cache = env.BOT_CACHE ? new AppTokenCache(env.BOT_CACHE) : null;

return {
available: Boolean(appId && privateKey),
async getInstallationToken(installationId) {
if (!appId || !privateKey) {
throw appAuthError(
'app_auth_unconfigured',
'GITHUB_APP_ID / GITHUB_APP_PRIVATE_KEY not configured',
{ retryable: false },
);
}
if (!installationId) {
throw appAuthError('missing_installation_id', 'webhook/job carries no installation id', {
retryable: false,
});
}

const cached = cache ? await cache.get(installationId) : null;
if (cached) return cached;

const jwt = await generateAppJwt(appId, privateKey);
const token = await fetchInstallationToken(jwt, installationId);

if (cache) await cache.set(installationId, token);
return token;
},
};
}
6 changes: 4 additions & 2 deletions src/shared/github.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
*
* NOTE: webhook signature verification was extracted to `./crypto.js`
* (Web Crypto API). This module is pure REST I/O and is shared by both workers.
* Authentication is GitHub App only: every token in the system is an
* installation access token, sent as a Bearer credential.
*/

const GITHUB_API_BASE = 'https://api.github.com';

export class GitHubClient {
/**
* @param {string} token - GitHub Personal Access Token
* @param {string} token - GitHub App installation access token
* @param {Object} [opts]
* @param {string} [opts.userAgent='zai-code-bot-workers']
*/
Expand All @@ -33,7 +35,7 @@ export class GitHubClient {
const options = {
method,
headers: {
Authorization: `token ${this.token}`,
Authorization: `Bearer ${this.token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': this.userAgent,
Expand Down
4 changes: 2 additions & 2 deletions src/shared/secrets.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
* against the same Secrets Store (629e5dd…).
*
* USAGE: resolve at the edge, pass a plain string down.
* const token = await resolveSecretValue(env.GITHUB_TOKEN);
* new GitHubClient(token);
* const key = await resolveSecretValue(env.GITHUB_APP_PRIVATE_KEY);
* generateAppJwt(appId, key);
*/

/**
Expand Down
18 changes: 14 additions & 4 deletions src/shared/storage/deliveries.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const JOB_BASE = `
j.head_sha, p.base_sha,
j.status, j.attempt_count, j.available_at, j.claimed_at, j.lease_expires_at,
j.completed_at, j.last_error_code, j.last_failure_at, j.config_version,
j.installation_id,
r.owner AS repository_owner, r.name AS repository_name, r.full_name AS repository_full_name,
p.title, p.author_login, p.state, p.closed_by
FROM jobs j
Expand Down Expand Up @@ -49,6 +50,7 @@ async function createPrJob(db, event, kind, { ownsDelivery }, now = new Date().t
const jobId = crypto.randomUUID();
const repositoryId = Number(event.repositoryId);
const prNumber = Number(event.prNumber);
const installationId = event.installationId || null;
const statements = [
prepare(
db,
Expand Down Expand Up @@ -107,15 +109,16 @@ async function createPrJob(db, event, kind, { ownsDelivery }, now = new Date().t
prepare(
db,
`INSERT INTO jobs
(job_id, delivery_id, kind, repository_id, pr_number, head_sha, status,
(job_id, delivery_id, kind, repository_id, pr_number, head_sha, installation_id, status,
attempt_count, available_at, config_version, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 'queued', 0, ?, 1, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?, 1, ?, ?)`,
jobId,
event.deliveryId,
kind,
repositoryId,
prNumber,
event.headSha,
installationId,
now,
now,
now,
Expand Down Expand Up @@ -152,8 +155,14 @@ async function createPrJob(db, event, kind, { ownsDelivery }, now = new Date().t
* Owns the delivery row and is idempotent on (delivery_id, 'pr_context').
* Only created for headSha-producing actions.
*/
export function createPrContextJob(db, event, now) {
return createPrJob(db, event, PR_CONTEXT_JOB_KIND, { ownsDelivery: true }, now);
export function createPrContextJob(db, event, installationId, now) {
return createPrJob(
db,
{ ...event, installationId },
PR_CONTEXT_JOB_KIND,
{ ownsDelivery: true },
now,
);
}

/**
Expand Down Expand Up @@ -182,6 +191,7 @@ export function createPrSummaryJob(db, contextJob, now) {
title: contextJob.title,
authorLogin: contextJob.author_login,
state: contextJob.state,
installationId: contextJob.installation_id,
},
PR_SUMMARY_JOB_KIND,
{ ownsDelivery: false },
Expand Down
Loading
Loading