Skip to content

Commit 7744038

Browse files
committed
make installation id info stateless
1 parent b33349f commit 7744038

File tree

4 files changed

+281
-669
lines changed

4 files changed

+281
-669
lines changed

src/github/auth.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import fetch from 'node-fetch';
2+
import jwt from 'jsonwebtoken';
3+
import { readFileSync } from 'fs';
4+
5+
interface TokenCacheEntry {
6+
token: string;
7+
expires: number;
8+
}
9+
10+
interface InstallationTokenResponse {
11+
token: string;
12+
expires_at: string;
13+
}
14+
15+
const tokenCache = new Map<number, TokenCacheEntry>();
16+
17+
export async function generateAppToken(): Promise<string> {
18+
if (!process.env.GITHUB_APP_ID) {
19+
throw new Error('GITHUB_APP_ID environment variable is required');
20+
}
21+
22+
// Read private key from environment or file
23+
let privateKey: string;
24+
25+
if (process.env.GITHUB_APP_PRIVATE_KEY) {
26+
const rawKey = process.env.GITHUB_APP_PRIVATE_KEY.replace(/\\n/g, '\n');
27+
28+
// Check if the key is base64 encoded
29+
if (!rawKey.includes('-----BEGIN') && /^[A-Za-z0-9+/]+=*$/.test(rawKey.replace(/\s/g, ''))) {
30+
try {
31+
privateKey = Buffer.from(rawKey, 'base64').toString('utf8');
32+
} catch (error) {
33+
throw new Error(`Failed to decode base64 private key: ${error}`);
34+
}
35+
} else {
36+
privateKey = rawKey;
37+
}
38+
} else {
39+
// Read from file
40+
const keyPath = process.env.GITHUB_APP_PRIVATE_KEY_PATH || 'private-key.pem';
41+
try {
42+
privateKey = readFileSync(keyPath, 'utf8');
43+
} catch (error) {
44+
throw new Error(`Failed to read private key file at ${keyPath}: ${error}`);
45+
}
46+
}
47+
48+
// Auto-format private key if it's missing PEM headers
49+
if (!privateKey.includes('-----BEGIN')) {
50+
const base64Content = privateKey
51+
.replace(/\s/g, '')
52+
.replace(/\n/g, '')
53+
.replace(/\r/g, '')
54+
.trim();
55+
56+
if (!/^[A-Za-z0-9+/]+=*$/.test(base64Content)) {
57+
throw new Error('Private key content does not appear to be valid base64');
58+
}
59+
60+
const formattedContent = base64Content.match(/.{1,64}/g)?.join('\n') || base64Content;
61+
privateKey = `-----BEGIN PRIVATE KEY-----\n${formattedContent}\n-----END PRIVATE KEY-----`;
62+
}
63+
64+
const payload = {
65+
iat: Math.floor(Date.now() / 1000) - 60, // Issued 1 minute ago
66+
exp: Math.floor(Date.now() / 1000) + (10 * 60), // Expires in 10 minutes
67+
iss: process.env.GITHUB_APP_ID,
68+
};
69+
70+
try {
71+
return jwt.sign(payload, privateKey, { algorithm: 'RS256' });
72+
} catch (error) {
73+
throw new Error(`Failed to sign JWT: ${error}`);
74+
}
75+
}
76+
77+
export async function getInstallationAccessToken(installationId: string, appToken: string): Promise<InstallationTokenResponse> {
78+
const response = await fetch(`https://api.github.com/app/installations/${installationId}/access_tokens`, {
79+
method: 'POST',
80+
headers: {
81+
'Authorization': `Bearer ${appToken}`,
82+
'Accept': 'application/vnd.github.v3+json',
83+
'User-Agent': 'GitHub-Code-Review-Agent/1.0',
84+
},
85+
});
86+
87+
if (!response.ok) {
88+
const errorText = await response.text();
89+
throw new Error(`Failed to get installation access token: ${response.status} - ${errorText}`);
90+
}
91+
92+
return await response.json() as InstallationTokenResponse;
93+
}
94+
95+
export async function getInstallationToken(installationId: number): Promise<string> {
96+
const cached = tokenCache.get(installationId);
97+
if (cached && cached.expires > Date.now() + 300_000) { // 5 min buffer
98+
return cached.token;
99+
}
100+
101+
try {
102+
const appJwt = await generateAppToken();
103+
const response = await getInstallationAccessToken(installationId.toString(), appJwt);
104+
105+
// Parse expires_at from GitHub API response (returns ISO string like "2024-01-01T12:00:00Z")
106+
const expires = new Date(response.expires_at).getTime();
107+
108+
tokenCache.set(installationId, { token: response.token, expires });
109+
return response.token;
110+
} catch (error) {
111+
// Remove from cache on error
112+
tokenCache.delete(installationId);
113+
throw error;
114+
}
115+
}
116+
117+
export function invalidateTokenCache(installationId: number): void {
118+
tokenCache.delete(installationId);
119+
}

0 commit comments

Comments
 (0)