Skip to content

Commit 913a9b9

Browse files
wip v28
1 parent c3e58aa commit 913a9b9

3 files changed

Lines changed: 101 additions & 6 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { generateKeyPairSync } from 'crypto';
2+
import { generateGitHubAppJWT, normalizeGitHubAppPrivateKey } from '../auth/jwt-helper';
3+
4+
function createPrivateKeyPem(): string {
5+
return generateKeyPairSync('rsa', {
6+
modulusLength: 2048,
7+
privateKeyEncoding: {
8+
type: 'pkcs8',
9+
format: 'pem'
10+
},
11+
publicKeyEncoding: {
12+
type: 'spki',
13+
format: 'pem'
14+
}
15+
}).privateKey;
16+
}
17+
18+
describe('GitHub App JWT helpers', () => {
19+
it('normalizes escaped PEM newlines before signing', () => {
20+
const privateKey = createPrivateKeyPem();
21+
const escapedPrivateKey = privateKey.replace(/\n/g, '\\n');
22+
23+
expect(normalizeGitHubAppPrivateKey(escapedPrivateKey)).toBe(privateKey.trim());
24+
expect(generateGitHubAppJWT('244206', escapedPrivateKey).split('.')).toHaveLength(3);
25+
});
26+
27+
it('normalizes base64-encoded PEM values for deployment envs', () => {
28+
const privateKey = createPrivateKeyPem();
29+
const base64PrivateKey = Buffer.from(privateKey, 'utf8').toString('base64');
30+
31+
expect(normalizeGitHubAppPrivateKey(base64PrivateKey)).toBe(privateKey.trim());
32+
expect(generateGitHubAppJWT('244206', base64PrivateKey).split('.')).toHaveLength(3);
33+
});
34+
35+
it('rejects local private-key paths with an actionable configuration error', () => {
36+
expect(() =>
37+
normalizeGitHubAppPrivateKey('/Users/example/Downloads/bitcode.private-key.pem')
38+
).toThrow(/file path/i);
39+
});
40+
});

packages/github/src/auth/jwt-helper.ts

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,64 @@ function base64url(str: string): string {
1515
.replace(/\//g, '_');
1616
}
1717

18+
const PRIVATE_KEY_BEGIN = /-----BEGIN [A-Z ]*PRIVATE KEY-----/;
19+
const PRIVATE_KEY_END = /-----END [A-Z ]*PRIVATE KEY-----/;
20+
21+
function stripWrappingQuotes(value: string): string {
22+
if (value.length < 2) return value;
23+
24+
const first = value[0];
25+
const last = value[value.length - 1];
26+
27+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
28+
return value.slice(1, -1);
29+
}
30+
31+
return value;
32+
}
33+
34+
function decodeBase64Pem(value: string): string | null {
35+
const compact = value.replace(/\s/g, '');
36+
if (!compact || compact.length % 4 !== 0) return null;
37+
38+
try {
39+
const decoded = Buffer.from(compact, 'base64').toString('utf8').trim();
40+
return PRIVATE_KEY_BEGIN.test(decoded) && PRIVATE_KEY_END.test(decoded)
41+
? decoded
42+
: null;
43+
} catch {
44+
return null;
45+
}
46+
}
47+
48+
export function normalizeGitHubAppPrivateKey(privateKey: string): string {
49+
let key = stripWrappingQuotes(privateKey.trim())
50+
.replace(/\\r\\n/g, '\n')
51+
.replace(/\\n/g, '\n')
52+
.replace(/\\r/g, '\n')
53+
.replace(/\r\n/g, '\n')
54+
.replace(/\r/g, '\n')
55+
.trim();
56+
57+
if (!PRIVATE_KEY_BEGIN.test(key)) {
58+
key = decodeBase64Pem(key) ?? key;
59+
}
60+
61+
if (/^\/.*\.pem$/i.test(key) || /^[A-Z]:\\.*\.pem$/i.test(key)) {
62+
throw new Error(
63+
'GITHUB_PRIVATE_KEY contains a file path. Configure the deployed environment with the PEM contents or a base64-encoded PEM, not a local .pem path.'
64+
);
65+
}
66+
67+
if (!PRIVATE_KEY_BEGIN.test(key) || !PRIVATE_KEY_END.test(key)) {
68+
throw new Error(
69+
'GITHUB_PRIVATE_KEY must be an RSA private key PEM. Expected BEGIN/END PRIVATE KEY markers after env normalization.'
70+
);
71+
}
72+
73+
return key;
74+
}
75+
1876
export function generateGitHubAppJWT(appId: string, privateKey: string): string {
1977
// Current time in seconds
2078
const now = Math.floor(Date.now() / 1000);
@@ -39,10 +97,7 @@ export function generateGitHubAppJWT(appId: string, privateKey: string): string
3997
// Create signature
4098
const signatureInput = `${encodedHeader}.${encodedPayload}`;
4199

42-
// Parse the private key (handle both raw and escaped formats)
43-
const privateKeyContent = privateKey
44-
.replace(/\\n/g, '\n')
45-
.trim();
100+
const privateKeyContent = normalizeGitHubAppPrivateKey(privateKey);
46101

47102
const sign = crypto.createSign('RSA-SHA256');
48103
sign.update(signatureInput);
@@ -57,4 +112,4 @@ export function generateGitHubAppJWT(appId: string, privateKey: string): string
57112

58113
// Combine all parts
59114
return `${encodedHeader}.${encodedPayload}.${encodedSignature}`;
60-
}
115+
}

uapi/.env.example

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ BITBUCKET_REDIRECT_URI=<your domain>/api/integrations/bitbucket/callback
8484
GITHUB_OAUTH_CLIENT_ID=<your GitHub App OAuth client ID>
8585
GITHUB_OAUTH_CLIENT_SECRET=<your GitHub App OAuth client secret>
8686
GITHUB_APP_ID=<your GitHub App ID>
87-
GITHUB_PRIVATE_KEY=<your GitHub App private key (RSA format)>
87+
GITHUB_PRIVATE_KEY=<your GitHub App RSA private key PEM contents, escaped-newline PEM, or base64-encoded PEM; not a local .pem file path>
8888
GITHUB_WEBHOOK_SECRET=<webhook signature verification secret>
8989
# Google Generative AI (Gemini)
9090
GOOGLE_API_KEY=<your Google API key>

0 commit comments

Comments
 (0)