@flink-app/github-app-plugin 0.12.1-alpha.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +209 -0
- package/LICENSE +21 -0
- package/README.md +667 -0
- package/SECURITY.md +498 -0
- package/dist/GitHubAppInternalContext.d.ts +44 -0
- package/dist/GitHubAppInternalContext.js +2 -0
- package/dist/GitHubAppPlugin.d.ts +45 -0
- package/dist/GitHubAppPlugin.js +367 -0
- package/dist/GitHubAppPluginContext.d.ts +242 -0
- package/dist/GitHubAppPluginContext.js +2 -0
- package/dist/GitHubAppPluginOptions.d.ts +369 -0
- package/dist/GitHubAppPluginOptions.js +2 -0
- package/dist/handlers/InitiateInstallation.d.ts +32 -0
- package/dist/handlers/InitiateInstallation.js +66 -0
- package/dist/handlers/InstallationCallback.d.ts +42 -0
- package/dist/handlers/InstallationCallback.js +248 -0
- package/dist/handlers/UninstallHandler.d.ts +37 -0
- package/dist/handlers/UninstallHandler.js +153 -0
- package/dist/handlers/WebhookHandler.d.ts +54 -0
- package/dist/handlers/WebhookHandler.js +157 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +23 -0
- package/dist/repos/GitHubAppSessionRepo.d.ts +24 -0
- package/dist/repos/GitHubAppSessionRepo.js +32 -0
- package/dist/repos/GitHubInstallationRepo.d.ts +53 -0
- package/dist/repos/GitHubInstallationRepo.js +83 -0
- package/dist/repos/GitHubWebhookEventRepo.d.ts +29 -0
- package/dist/repos/GitHubWebhookEventRepo.js +42 -0
- package/dist/schemas/GitHubAppSession.d.ts +13 -0
- package/dist/schemas/GitHubAppSession.js +2 -0
- package/dist/schemas/GitHubInstallation.d.ts +28 -0
- package/dist/schemas/GitHubInstallation.js +2 -0
- package/dist/schemas/InstallationCallbackRequest.d.ts +10 -0
- package/dist/schemas/InstallationCallbackRequest.js +2 -0
- package/dist/schemas/WebhookEvent.d.ts +16 -0
- package/dist/schemas/WebhookEvent.js +2 -0
- package/dist/schemas/WebhookPayload.d.ts +35 -0
- package/dist/schemas/WebhookPayload.js +2 -0
- package/dist/services/GitHubAPIClient.d.ts +143 -0
- package/dist/services/GitHubAPIClient.js +167 -0
- package/dist/services/GitHubAuthService.d.ts +85 -0
- package/dist/services/GitHubAuthService.js +160 -0
- package/dist/services/WebhookValidator.d.ts +93 -0
- package/dist/services/WebhookValidator.js +123 -0
- package/dist/utils/error-utils.d.ts +67 -0
- package/dist/utils/error-utils.js +121 -0
- package/dist/utils/jwt-utils.d.ts +35 -0
- package/dist/utils/jwt-utils.js +67 -0
- package/dist/utils/state-utils.d.ts +38 -0
- package/dist/utils/state-utils.js +74 -0
- package/dist/utils/token-cache-utils.d.ts +47 -0
- package/dist/utils/token-cache-utils.js +74 -0
- package/dist/utils/webhook-signature-utils.d.ts +22 -0
- package/dist/utils/webhook-signature-utils.js +57 -0
- package/examples/basic-installation.ts +246 -0
- package/examples/create-issue.ts +392 -0
- package/examples/error-handling.ts +396 -0
- package/examples/multi-event-webhook.ts +367 -0
- package/examples/organization-installation.ts +316 -0
- package/examples/repository-access.ts +480 -0
- package/examples/webhook-handling.ts +343 -0
- package/examples/with-jwt-auth.ts +319 -0
- package/package.json +41 -0
- package/spec/core-utilities.spec.ts +243 -0
- package/spec/handlers.spec.ts +216 -0
- package/spec/helpers/reporter.ts +41 -0
- package/spec/integration-and-security.spec.ts +483 -0
- package/spec/plugin-core.spec.ts +258 -0
- package/spec/project-setup.spec.ts +56 -0
- package/spec/repos-and-schemas.spec.ts +288 -0
- package/spec/services.spec.ts +108 -0
- package/spec/support/jasmine.json +7 -0
- package/src/GitHubAppPlugin.ts +411 -0
- package/src/GitHubAppPluginContext.ts +254 -0
- package/src/GitHubAppPluginOptions.ts +412 -0
- package/src/handlers/InstallationCallback.ts +292 -0
- package/src/handlers/WebhookHandler.ts +179 -0
- package/src/index.ts +29 -0
- package/src/repos/GitHubAppSessionRepo.ts +36 -0
- package/src/repos/GitHubInstallationRepo.ts +95 -0
- package/src/repos/GitHubWebhookEventRepo.ts +48 -0
- package/src/schemas/GitHubAppSession.ts +13 -0
- package/src/schemas/GitHubInstallation.ts +28 -0
- package/src/schemas/InstallationCallbackRequest.ts +10 -0
- package/src/schemas/WebhookEvent.ts +16 -0
- package/src/schemas/WebhookPayload.ts +35 -0
- package/src/services/GitHubAPIClient.ts +244 -0
- package/src/services/GitHubAuthService.ts +188 -0
- package/src/services/WebhookValidator.ts +159 -0
- package/src/utils/error-utils.ts +148 -0
- package/src/utils/jwt-utils.ts +64 -0
- package/src/utils/state-utils.ts +72 -0
- package/src/utils/token-cache-utils.ts +89 -0
- package/src/utils/webhook-signature-utils.ts +57 -0
- package/tsconfig.dist.json +4 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub App installation data linked to a user.
|
|
3
|
+
* Stores which repositories the user granted access to and installation metadata.
|
|
4
|
+
*/
|
|
5
|
+
export default interface GitHubInstallation {
|
|
6
|
+
_id?: string;
|
|
7
|
+
userId: string;
|
|
8
|
+
installationId: number;
|
|
9
|
+
accountId: number;
|
|
10
|
+
accountLogin: string;
|
|
11
|
+
accountType: 'User' | 'Organization';
|
|
12
|
+
avatarUrl?: string;
|
|
13
|
+
repositories: {
|
|
14
|
+
id: number;
|
|
15
|
+
name: string;
|
|
16
|
+
fullName: string;
|
|
17
|
+
private: boolean;
|
|
18
|
+
}[];
|
|
19
|
+
permissions: Record<string, string>;
|
|
20
|
+
events: string[];
|
|
21
|
+
suspendedAt?: Date;
|
|
22
|
+
suspendedBy?: {
|
|
23
|
+
id: number;
|
|
24
|
+
login: string;
|
|
25
|
+
};
|
|
26
|
+
createdAt: Date;
|
|
27
|
+
updatedAt: Date;
|
|
28
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query parameters received from GitHub after app installation.
|
|
3
|
+
*/
|
|
4
|
+
export default interface InstallationCallbackRequest {
|
|
5
|
+
installation_id: string;
|
|
6
|
+
setup_action: 'install' | 'update' | 'request';
|
|
7
|
+
state: string;
|
|
8
|
+
code?: string;
|
|
9
|
+
[key: string]: string | undefined;
|
|
10
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional webhook event logging for debugging and auditing.
|
|
3
|
+
* Only stored if logWebhookEvents config option is enabled.
|
|
4
|
+
*/
|
|
5
|
+
export default interface WebhookEvent {
|
|
6
|
+
_id?: string;
|
|
7
|
+
installationId: number;
|
|
8
|
+
event: string;
|
|
9
|
+
action?: string;
|
|
10
|
+
deliveryId: string;
|
|
11
|
+
payload: Record<string, any>;
|
|
12
|
+
processed: boolean;
|
|
13
|
+
processedAt?: Date;
|
|
14
|
+
error?: string;
|
|
15
|
+
createdAt: Date;
|
|
16
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic webhook event payload structure from GitHub.
|
|
3
|
+
* Contains common fields; specific events may have additional fields.
|
|
4
|
+
*/
|
|
5
|
+
export default interface WebhookPayload {
|
|
6
|
+
action?: string;
|
|
7
|
+
installation?: {
|
|
8
|
+
id: number;
|
|
9
|
+
account: {
|
|
10
|
+
id: number;
|
|
11
|
+
login: string;
|
|
12
|
+
type: 'User' | 'Organization';
|
|
13
|
+
avatar_url?: string;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
repositories?: {
|
|
17
|
+
id: number;
|
|
18
|
+
name: string;
|
|
19
|
+
full_name: string;
|
|
20
|
+
private: boolean;
|
|
21
|
+
}[];
|
|
22
|
+
repository?: {
|
|
23
|
+
id: number;
|
|
24
|
+
name: string;
|
|
25
|
+
full_name: string;
|
|
26
|
+
private: boolean;
|
|
27
|
+
owner: {
|
|
28
|
+
login: string;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
sender?: {
|
|
32
|
+
id: number;
|
|
33
|
+
login: string;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub API Client
|
|
3
|
+
*
|
|
4
|
+
* Wrapper for GitHub API calls with automatic token injection,
|
|
5
|
+
* retry logic, and error handling.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { GitHubAuthService } from "./GitHubAuthService";
|
|
9
|
+
import { createGitHubAppError, GitHubAppErrorCodes, handleGitHubAPIError } from "../utils/error-utils";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Repository data structure
|
|
13
|
+
*/
|
|
14
|
+
export interface Repository {
|
|
15
|
+
id: number;
|
|
16
|
+
name: string;
|
|
17
|
+
full_name: string;
|
|
18
|
+
private: boolean;
|
|
19
|
+
owner: {
|
|
20
|
+
login: string;
|
|
21
|
+
id: number;
|
|
22
|
+
};
|
|
23
|
+
html_url: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Content data structure
|
|
29
|
+
*/
|
|
30
|
+
export interface Content {
|
|
31
|
+
name: string;
|
|
32
|
+
path: string;
|
|
33
|
+
sha: string;
|
|
34
|
+
size: number;
|
|
35
|
+
url: string;
|
|
36
|
+
html_url: string;
|
|
37
|
+
git_url: string;
|
|
38
|
+
download_url?: string;
|
|
39
|
+
type: "file" | "dir" | "symlink" | "submodule";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Issue data structure
|
|
44
|
+
*/
|
|
45
|
+
export interface Issue {
|
|
46
|
+
id: number;
|
|
47
|
+
number: number;
|
|
48
|
+
title: string;
|
|
49
|
+
body?: string;
|
|
50
|
+
state: "open" | "closed";
|
|
51
|
+
html_url: string;
|
|
52
|
+
user: {
|
|
53
|
+
login: string;
|
|
54
|
+
id: number;
|
|
55
|
+
};
|
|
56
|
+
created_at: string;
|
|
57
|
+
updated_at: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Create issue parameters
|
|
62
|
+
*/
|
|
63
|
+
export interface CreateIssueParams {
|
|
64
|
+
title: string;
|
|
65
|
+
body?: string;
|
|
66
|
+
assignees?: string[];
|
|
67
|
+
labels?: string[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* GitHub API Client
|
|
72
|
+
*
|
|
73
|
+
* Provides wrapper methods for common GitHub API operations with:
|
|
74
|
+
* - Automatic token injection
|
|
75
|
+
* - Retry logic with exponential backoff for rate limits
|
|
76
|
+
* - Error handling and transformation
|
|
77
|
+
*/
|
|
78
|
+
export class GitHubAPIClient {
|
|
79
|
+
private installationId: number;
|
|
80
|
+
private authService: GitHubAuthService;
|
|
81
|
+
private baseUrl: string;
|
|
82
|
+
private maxRetries: number = 3;
|
|
83
|
+
private retryDelayMs: number = 1000;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Create GitHub API Client
|
|
87
|
+
*
|
|
88
|
+
* @param installationId - GitHub installation ID
|
|
89
|
+
* @param authService - GitHub Auth Service instance
|
|
90
|
+
* @param baseUrl - GitHub API base URL (default: https://api.github.com)
|
|
91
|
+
*/
|
|
92
|
+
constructor(installationId: number, authService: GitHubAuthService, baseUrl: string = "https://api.github.com") {
|
|
93
|
+
this.installationId = installationId;
|
|
94
|
+
this.authService = authService;
|
|
95
|
+
this.baseUrl = baseUrl;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Generic API request method with automatic token injection
|
|
100
|
+
*
|
|
101
|
+
* @param method - HTTP method (GET, POST, PUT, DELETE)
|
|
102
|
+
* @param endpoint - API endpoint (e.g., "/repos/owner/repo")
|
|
103
|
+
* @param data - Request body data (for POST, PUT)
|
|
104
|
+
* @param retryCount - Current retry attempt (internal use)
|
|
105
|
+
* @returns Response data
|
|
106
|
+
* @throws Error if request fails after retries
|
|
107
|
+
*/
|
|
108
|
+
async request<T = any>(method: "GET" | "POST" | "PUT" | "DELETE", endpoint: string, data?: any, retryCount: number = 0): Promise<T> {
|
|
109
|
+
try {
|
|
110
|
+
// Get installation token (cached or fresh)
|
|
111
|
+
const token = await this.authService.getInstallationToken(this.installationId);
|
|
112
|
+
|
|
113
|
+
// Build full URL
|
|
114
|
+
const url = endpoint.startsWith("http") ? endpoint : `${this.baseUrl}${endpoint}`;
|
|
115
|
+
|
|
116
|
+
// Make request
|
|
117
|
+
const response = await fetch(url, {
|
|
118
|
+
method,
|
|
119
|
+
headers: {
|
|
120
|
+
Authorization: `Bearer ${token}`,
|
|
121
|
+
Accept: "application/vnd.github+json",
|
|
122
|
+
"User-Agent": "Flink-GitHub-App-Plugin",
|
|
123
|
+
...(data ? { "Content-Type": "application/json" } : {}),
|
|
124
|
+
},
|
|
125
|
+
...(data ? { body: JSON.stringify(data) } : {}),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Handle rate limiting with retry
|
|
129
|
+
if (response.status === 403) {
|
|
130
|
+
const rateLimitRemaining = response.headers.get("x-ratelimit-remaining");
|
|
131
|
+
if (rateLimitRemaining === "0" && retryCount < this.maxRetries) {
|
|
132
|
+
// Calculate exponential backoff delay
|
|
133
|
+
const delay = this.retryDelayMs * Math.pow(2, retryCount);
|
|
134
|
+
await this.sleep(delay);
|
|
135
|
+
return this.request<T>(method, endpoint, data, retryCount + 1);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Handle non-OK responses
|
|
140
|
+
if (!response.ok) {
|
|
141
|
+
const errorBody = await response.text();
|
|
142
|
+
const error: any = new Error(`GitHub API request failed: ${response.statusText}`);
|
|
143
|
+
error.status = response.status;
|
|
144
|
+
error.message = errorBody;
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Parse and return response
|
|
149
|
+
const responseData = await response.json();
|
|
150
|
+
return responseData as T;
|
|
151
|
+
} catch (error: any) {
|
|
152
|
+
// If we can retry, do so
|
|
153
|
+
if (this.shouldRetry(error) && retryCount < this.maxRetries) {
|
|
154
|
+
const delay = this.retryDelayMs * Math.pow(2, retryCount);
|
|
155
|
+
await this.sleep(delay);
|
|
156
|
+
return this.request<T>(method, endpoint, data, retryCount + 1);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Transform error to standardized format
|
|
160
|
+
throw handleGitHubAPIError(error);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Get repositories accessible by this installation
|
|
166
|
+
*
|
|
167
|
+
* @returns Array of repositories
|
|
168
|
+
*/
|
|
169
|
+
async getRepositories(): Promise<Repository[]> {
|
|
170
|
+
const response = await this.request<{ repositories: Repository[] }>("GET", "/installation/repositories");
|
|
171
|
+
return response.repositories;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Get repository details
|
|
176
|
+
*
|
|
177
|
+
* @param owner - Repository owner (user or org)
|
|
178
|
+
* @param repo - Repository name
|
|
179
|
+
* @returns Repository details
|
|
180
|
+
*/
|
|
181
|
+
async getRepository(owner: string, repo: string): Promise<Repository> {
|
|
182
|
+
return this.request<Repository>("GET", `/repos/${owner}/${repo}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Get repository contents
|
|
187
|
+
*
|
|
188
|
+
* @param owner - Repository owner
|
|
189
|
+
* @param repo - Repository name
|
|
190
|
+
* @param path - File or directory path
|
|
191
|
+
* @returns Content array (for directories) or single content (for files)
|
|
192
|
+
*/
|
|
193
|
+
async getContents(owner: string, repo: string, path: string): Promise<Content | Content[]> {
|
|
194
|
+
return this.request<Content | Content[]>("GET", `/repos/${owner}/${repo}/contents/${path}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Create an issue
|
|
199
|
+
*
|
|
200
|
+
* @param owner - Repository owner
|
|
201
|
+
* @param repo - Repository name
|
|
202
|
+
* @param params - Issue creation parameters
|
|
203
|
+
* @returns Created issue
|
|
204
|
+
*/
|
|
205
|
+
async createIssue(owner: string, repo: string, params: CreateIssueParams): Promise<Issue> {
|
|
206
|
+
return this.request<Issue>("POST", `/repos/${owner}/${repo}/issues`, params);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Sleep utility for retry delays
|
|
211
|
+
*
|
|
212
|
+
* @param ms - Milliseconds to sleep
|
|
213
|
+
* @private
|
|
214
|
+
*/
|
|
215
|
+
private sleep(ms: number): Promise<void> {
|
|
216
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Determine if error should trigger retry
|
|
221
|
+
*
|
|
222
|
+
* @param error - Error to check
|
|
223
|
+
* @returns true if should retry
|
|
224
|
+
* @private
|
|
225
|
+
*/
|
|
226
|
+
private shouldRetry(error: any): boolean {
|
|
227
|
+
// Retry on network errors
|
|
228
|
+
if (error.code === "ENOTFOUND" || error.code === "ECONNREFUSED" || error.code === "ETIMEDOUT") {
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Retry on rate limit (403)
|
|
233
|
+
if (error.status === 403) {
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Retry on server errors (500, 502, 503, 504)
|
|
238
|
+
if (error.status >= 500 && error.status < 600) {
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Authentication Service
|
|
3
|
+
*
|
|
4
|
+
* Handles GitHub App authentication including JWT generation and
|
|
5
|
+
* installation token exchange with automatic caching.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { generateJWT } from "../utils/jwt-utils";
|
|
9
|
+
import { TokenCache } from "../utils/token-cache-utils";
|
|
10
|
+
import { validatePrivateKey, createGitHubAppError, GitHubAppErrorCodes, handleGitHubAPIError } from "../utils/error-utils";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Installation token response from GitHub API
|
|
14
|
+
*/
|
|
15
|
+
interface InstallationTokenResponse {
|
|
16
|
+
token: string;
|
|
17
|
+
expires_at: string;
|
|
18
|
+
permissions?: Record<string, string>;
|
|
19
|
+
repository_selection?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* GitHub Authentication Service
|
|
24
|
+
*
|
|
25
|
+
* Manages GitHub App authentication flow:
|
|
26
|
+
* 1. Generate GitHub App JWT using private key
|
|
27
|
+
* 2. Exchange JWT for installation access token
|
|
28
|
+
* 3. Cache installation tokens with automatic expiration
|
|
29
|
+
* 4. Automatically refresh expired tokens
|
|
30
|
+
*/
|
|
31
|
+
export class GitHubAuthService {
|
|
32
|
+
private appId: string;
|
|
33
|
+
private privateKey: string;
|
|
34
|
+
private baseUrl: string;
|
|
35
|
+
private tokenCache: TokenCache;
|
|
36
|
+
private tokenCacheTTL: number;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Create GitHub Auth Service
|
|
40
|
+
*
|
|
41
|
+
* @param appId - GitHub App ID
|
|
42
|
+
* @param privateKeyBase64 - Base64 encoded RSA private key (will be decoded to PEM format, PKCS#1 or PKCS#8)
|
|
43
|
+
* @param baseUrl - GitHub API base URL (default: https://api.github.com)
|
|
44
|
+
* @param tokenCacheTTL - Token cache TTL in seconds (default: 3300 = 55 minutes)
|
|
45
|
+
* @throws Error if private key is invalid or cannot be decoded
|
|
46
|
+
*/
|
|
47
|
+
constructor(appId: string, privateKeyBase64: string, baseUrl: string = "https://api.github.com", tokenCacheTTL: number = 3300) {
|
|
48
|
+
// Decode base64 private key to PEM format
|
|
49
|
+
let privateKey: string;
|
|
50
|
+
try {
|
|
51
|
+
privateKey = Buffer.from(privateKeyBase64, 'base64').toString('utf-8');
|
|
52
|
+
} catch (error: any) {
|
|
53
|
+
throw createGitHubAppError(
|
|
54
|
+
GitHubAppErrorCodes.INVALID_PRIVATE_KEY,
|
|
55
|
+
`Failed to decode base64 private key: ${error.message}`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Validate private key format after decoding
|
|
60
|
+
validatePrivateKey(privateKey);
|
|
61
|
+
|
|
62
|
+
this.appId = appId;
|
|
63
|
+
this.privateKey = privateKey;
|
|
64
|
+
this.baseUrl = baseUrl;
|
|
65
|
+
this.tokenCache = new TokenCache();
|
|
66
|
+
this.tokenCacheTTL = tokenCacheTTL;
|
|
67
|
+
|
|
68
|
+
// Test JWT signing on startup to catch key errors early
|
|
69
|
+
try {
|
|
70
|
+
this.generateAppJWT();
|
|
71
|
+
} catch (error: any) {
|
|
72
|
+
throw createGitHubAppError(GitHubAppErrorCodes.JWT_SIGNING_FAILED, "Failed to sign JWT with provided private key. Please verify the key format.", {
|
|
73
|
+
error: error.message,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Generate GitHub App JWT
|
|
80
|
+
*
|
|
81
|
+
* Creates a JWT signed with the app's private key. The JWT is valid
|
|
82
|
+
* for 10 minutes and is used to authenticate as the GitHub App itself.
|
|
83
|
+
*
|
|
84
|
+
* @returns Signed JWT token
|
|
85
|
+
* @throws Error if JWT signing fails
|
|
86
|
+
*/
|
|
87
|
+
generateAppJWT(): string {
|
|
88
|
+
try {
|
|
89
|
+
return generateJWT(this.appId, this.privateKey);
|
|
90
|
+
} catch (error: any) {
|
|
91
|
+
throw createGitHubAppError(GitHubAppErrorCodes.JWT_SIGNING_FAILED, "Failed to generate GitHub App JWT", { error: error.message });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Get installation access token
|
|
97
|
+
*
|
|
98
|
+
* Retrieves an installation access token for the specified installation.
|
|
99
|
+
* Tokens are automatically cached and refreshed when expired.
|
|
100
|
+
*
|
|
101
|
+
* Flow:
|
|
102
|
+
* 1. Check cache for valid token
|
|
103
|
+
* 2. If not cached or expired, generate GitHub App JWT
|
|
104
|
+
* 3. Exchange JWT for installation token via GitHub API
|
|
105
|
+
* 4. Cache the new token
|
|
106
|
+
* 5. Return token
|
|
107
|
+
*
|
|
108
|
+
* @param installationId - GitHub installation ID
|
|
109
|
+
* @returns Installation access token
|
|
110
|
+
* @throws Error if token exchange fails
|
|
111
|
+
*/
|
|
112
|
+
async getInstallationToken(installationId: number): Promise<string> {
|
|
113
|
+
// Check cache first
|
|
114
|
+
const cachedToken = this.tokenCache.getToken(installationId);
|
|
115
|
+
if (cachedToken) {
|
|
116
|
+
return cachedToken;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Generate GitHub App JWT
|
|
120
|
+
const jwt = this.generateAppJWT();
|
|
121
|
+
|
|
122
|
+
// Exchange JWT for installation token
|
|
123
|
+
try {
|
|
124
|
+
const response = await this.exchangeJWTForInstallationToken(installationId, jwt);
|
|
125
|
+
|
|
126
|
+
// Cache the token
|
|
127
|
+
this.tokenCache.setToken(installationId, response.token, this.tokenCacheTTL);
|
|
128
|
+
|
|
129
|
+
return response.token;
|
|
130
|
+
} catch (error: any) {
|
|
131
|
+
throw handleGitHubAPIError(error);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Exchange GitHub App JWT for installation access token
|
|
137
|
+
*
|
|
138
|
+
* Calls GitHub API to exchange the app JWT for an installation-specific
|
|
139
|
+
* access token that can be used to make API calls on behalf of the installation.
|
|
140
|
+
*
|
|
141
|
+
* @param installationId - GitHub installation ID
|
|
142
|
+
* @param jwt - GitHub App JWT
|
|
143
|
+
* @returns Installation token response
|
|
144
|
+
* @private
|
|
145
|
+
*/
|
|
146
|
+
private async exchangeJWTForInstallationToken(installationId: number, jwt: string): Promise<InstallationTokenResponse> {
|
|
147
|
+
const url = `${this.baseUrl}/app/installations/${installationId}/access_tokens`;
|
|
148
|
+
|
|
149
|
+
const response = await fetch(url, {
|
|
150
|
+
method: "POST",
|
|
151
|
+
headers: {
|
|
152
|
+
Authorization: `Bearer ${jwt}`,
|
|
153
|
+
Accept: "application/vnd.github+json",
|
|
154
|
+
"User-Agent": "Flink-GitHub-App-Plugin",
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (!response.ok) {
|
|
159
|
+
const errorBody = await response.text();
|
|
160
|
+
const error: any = new Error(`GitHub API request failed: ${response.statusText}`);
|
|
161
|
+
error.status = response.status;
|
|
162
|
+
error.message = errorBody;
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const data = await response.json();
|
|
167
|
+
return data as InstallationTokenResponse;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Clear token cache
|
|
172
|
+
*
|
|
173
|
+
* Removes all cached installation tokens. Useful for testing,
|
|
174
|
+
* forcing token refresh, or plugin shutdown.
|
|
175
|
+
*/
|
|
176
|
+
clearTokenCache(): void {
|
|
177
|
+
this.tokenCache.clearCache();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Delete specific installation token from cache
|
|
182
|
+
*
|
|
183
|
+
* @param installationId - GitHub installation ID
|
|
184
|
+
*/
|
|
185
|
+
deleteInstallationToken(installationId: number): void {
|
|
186
|
+
this.tokenCache.deleteToken(installationId);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook Validator Service
|
|
3
|
+
*
|
|
4
|
+
* Validates GitHub webhook signatures and parses webhook payloads.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { validateWebhookSignature } from "../utils/webhook-signature-utils";
|
|
8
|
+
import { createGitHubAppError, GitHubAppErrorCodes } from "../utils/error-utils";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parsed webhook payload data
|
|
12
|
+
*/
|
|
13
|
+
export interface WebhookPayload {
|
|
14
|
+
action?: string;
|
|
15
|
+
installation?: {
|
|
16
|
+
id: number;
|
|
17
|
+
[key: string]: any;
|
|
18
|
+
};
|
|
19
|
+
[key: string]: any;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Webhook validation result
|
|
24
|
+
*/
|
|
25
|
+
export interface WebhookValidationResult {
|
|
26
|
+
isValid: boolean;
|
|
27
|
+
payload?: WebhookPayload;
|
|
28
|
+
error?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Webhook Validator
|
|
33
|
+
*
|
|
34
|
+
* Handles webhook signature validation and payload parsing for GitHub webhooks.
|
|
35
|
+
* Uses constant-time comparison to prevent timing attacks.
|
|
36
|
+
*/
|
|
37
|
+
export class WebhookValidator {
|
|
38
|
+
private secret: string;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Create Webhook Validator
|
|
42
|
+
*
|
|
43
|
+
* @param secret - Webhook secret configured in GitHub App
|
|
44
|
+
*/
|
|
45
|
+
constructor(secret: string) {
|
|
46
|
+
if (!secret || typeof secret !== "string") {
|
|
47
|
+
throw createGitHubAppError(
|
|
48
|
+
GitHubAppErrorCodes.WEBHOOK_SIGNATURE_INVALID,
|
|
49
|
+
"Webhook secret is required and must be a string"
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
this.secret = secret;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Validate webhook signature
|
|
58
|
+
*
|
|
59
|
+
* Verifies the X-Hub-Signature-256 header using HMAC-SHA256 with
|
|
60
|
+
* constant-time comparison to prevent timing attacks.
|
|
61
|
+
*
|
|
62
|
+
* @param rawBody - Raw request body (string or Buffer)
|
|
63
|
+
* @param signature - X-Hub-Signature-256 header value
|
|
64
|
+
* @returns true if signature is valid, false otherwise
|
|
65
|
+
*/
|
|
66
|
+
validateSignature(rawBody: string | Buffer, signature: string): boolean {
|
|
67
|
+
if (!rawBody || !signature) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return validateWebhookSignature(rawBody, signature, this.secret);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Parse webhook payload
|
|
76
|
+
*
|
|
77
|
+
* Parses the JSON payload from a webhook request.
|
|
78
|
+
* Extracts common fields like action and installationId.
|
|
79
|
+
*
|
|
80
|
+
* @param body - Raw request body (string)
|
|
81
|
+
* @returns Parsed webhook payload
|
|
82
|
+
* @throws Error if payload is malformed JSON
|
|
83
|
+
*/
|
|
84
|
+
parsePayload(body: string): WebhookPayload {
|
|
85
|
+
try {
|
|
86
|
+
const payload = JSON.parse(body);
|
|
87
|
+
return payload as WebhookPayload;
|
|
88
|
+
} catch (error: any) {
|
|
89
|
+
throw createGitHubAppError(
|
|
90
|
+
GitHubAppErrorCodes.WEBHOOK_PAYLOAD_INVALID,
|
|
91
|
+
"Failed to parse webhook payload as JSON",
|
|
92
|
+
{ error: error.message }
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Extract event type from payload
|
|
99
|
+
*
|
|
100
|
+
* Gets the event type from the parsed payload.
|
|
101
|
+
* For installation webhooks, this is typically found in the 'action' field.
|
|
102
|
+
*
|
|
103
|
+
* @param payload - Parsed webhook payload
|
|
104
|
+
* @returns Event type (e.g., 'created', 'deleted')
|
|
105
|
+
*/
|
|
106
|
+
extractEventType(payload: WebhookPayload): string | undefined {
|
|
107
|
+
return payload.action;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Extract installation ID from payload
|
|
112
|
+
*
|
|
113
|
+
* Gets the installation ID from the parsed payload.
|
|
114
|
+
*
|
|
115
|
+
* @param payload - Parsed webhook payload
|
|
116
|
+
* @returns Installation ID, or undefined if not present
|
|
117
|
+
*/
|
|
118
|
+
extractInstallationId(payload: WebhookPayload): number | undefined {
|
|
119
|
+
return payload.installation?.id;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Validate and parse webhook in one step
|
|
124
|
+
*
|
|
125
|
+
* Convenience method that validates signature and parses payload
|
|
126
|
+
* in a single call.
|
|
127
|
+
*
|
|
128
|
+
* @param rawBody - Raw request body
|
|
129
|
+
* @param signature - X-Hub-Signature-256 header
|
|
130
|
+
* @returns Validation result with parsed payload if valid
|
|
131
|
+
*/
|
|
132
|
+
validateAndParse(rawBody: string | Buffer, signature: string): WebhookValidationResult {
|
|
133
|
+
// Validate signature first
|
|
134
|
+
const isValid = this.validateSignature(rawBody, signature);
|
|
135
|
+
|
|
136
|
+
if (!isValid) {
|
|
137
|
+
return {
|
|
138
|
+
isValid: false,
|
|
139
|
+
error: "Invalid webhook signature",
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Parse payload
|
|
144
|
+
try {
|
|
145
|
+
const bodyString = typeof rawBody === "string" ? rawBody : rawBody.toString("utf8");
|
|
146
|
+
const payload = this.parsePayload(bodyString);
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
isValid: true,
|
|
150
|
+
payload,
|
|
151
|
+
};
|
|
152
|
+
} catch (error: any) {
|
|
153
|
+
return {
|
|
154
|
+
isValid: false,
|
|
155
|
+
error: error.message || "Failed to parse webhook payload",
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|