@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.
Files changed (96) hide show
  1. package/CHANGELOG.md +209 -0
  2. package/LICENSE +21 -0
  3. package/README.md +667 -0
  4. package/SECURITY.md +498 -0
  5. package/dist/GitHubAppInternalContext.d.ts +44 -0
  6. package/dist/GitHubAppInternalContext.js +2 -0
  7. package/dist/GitHubAppPlugin.d.ts +45 -0
  8. package/dist/GitHubAppPlugin.js +367 -0
  9. package/dist/GitHubAppPluginContext.d.ts +242 -0
  10. package/dist/GitHubAppPluginContext.js +2 -0
  11. package/dist/GitHubAppPluginOptions.d.ts +369 -0
  12. package/dist/GitHubAppPluginOptions.js +2 -0
  13. package/dist/handlers/InitiateInstallation.d.ts +32 -0
  14. package/dist/handlers/InitiateInstallation.js +66 -0
  15. package/dist/handlers/InstallationCallback.d.ts +42 -0
  16. package/dist/handlers/InstallationCallback.js +248 -0
  17. package/dist/handlers/UninstallHandler.d.ts +37 -0
  18. package/dist/handlers/UninstallHandler.js +153 -0
  19. package/dist/handlers/WebhookHandler.d.ts +54 -0
  20. package/dist/handlers/WebhookHandler.js +157 -0
  21. package/dist/index.d.ts +19 -0
  22. package/dist/index.js +23 -0
  23. package/dist/repos/GitHubAppSessionRepo.d.ts +24 -0
  24. package/dist/repos/GitHubAppSessionRepo.js +32 -0
  25. package/dist/repos/GitHubInstallationRepo.d.ts +53 -0
  26. package/dist/repos/GitHubInstallationRepo.js +83 -0
  27. package/dist/repos/GitHubWebhookEventRepo.d.ts +29 -0
  28. package/dist/repos/GitHubWebhookEventRepo.js +42 -0
  29. package/dist/schemas/GitHubAppSession.d.ts +13 -0
  30. package/dist/schemas/GitHubAppSession.js +2 -0
  31. package/dist/schemas/GitHubInstallation.d.ts +28 -0
  32. package/dist/schemas/GitHubInstallation.js +2 -0
  33. package/dist/schemas/InstallationCallbackRequest.d.ts +10 -0
  34. package/dist/schemas/InstallationCallbackRequest.js +2 -0
  35. package/dist/schemas/WebhookEvent.d.ts +16 -0
  36. package/dist/schemas/WebhookEvent.js +2 -0
  37. package/dist/schemas/WebhookPayload.d.ts +35 -0
  38. package/dist/schemas/WebhookPayload.js +2 -0
  39. package/dist/services/GitHubAPIClient.d.ts +143 -0
  40. package/dist/services/GitHubAPIClient.js +167 -0
  41. package/dist/services/GitHubAuthService.d.ts +85 -0
  42. package/dist/services/GitHubAuthService.js +160 -0
  43. package/dist/services/WebhookValidator.d.ts +93 -0
  44. package/dist/services/WebhookValidator.js +123 -0
  45. package/dist/utils/error-utils.d.ts +67 -0
  46. package/dist/utils/error-utils.js +121 -0
  47. package/dist/utils/jwt-utils.d.ts +35 -0
  48. package/dist/utils/jwt-utils.js +67 -0
  49. package/dist/utils/state-utils.d.ts +38 -0
  50. package/dist/utils/state-utils.js +74 -0
  51. package/dist/utils/token-cache-utils.d.ts +47 -0
  52. package/dist/utils/token-cache-utils.js +74 -0
  53. package/dist/utils/webhook-signature-utils.d.ts +22 -0
  54. package/dist/utils/webhook-signature-utils.js +57 -0
  55. package/examples/basic-installation.ts +246 -0
  56. package/examples/create-issue.ts +392 -0
  57. package/examples/error-handling.ts +396 -0
  58. package/examples/multi-event-webhook.ts +367 -0
  59. package/examples/organization-installation.ts +316 -0
  60. package/examples/repository-access.ts +480 -0
  61. package/examples/webhook-handling.ts +343 -0
  62. package/examples/with-jwt-auth.ts +319 -0
  63. package/package.json +41 -0
  64. package/spec/core-utilities.spec.ts +243 -0
  65. package/spec/handlers.spec.ts +216 -0
  66. package/spec/helpers/reporter.ts +41 -0
  67. package/spec/integration-and-security.spec.ts +483 -0
  68. package/spec/plugin-core.spec.ts +258 -0
  69. package/spec/project-setup.spec.ts +56 -0
  70. package/spec/repos-and-schemas.spec.ts +288 -0
  71. package/spec/services.spec.ts +108 -0
  72. package/spec/support/jasmine.json +7 -0
  73. package/src/GitHubAppPlugin.ts +411 -0
  74. package/src/GitHubAppPluginContext.ts +254 -0
  75. package/src/GitHubAppPluginOptions.ts +412 -0
  76. package/src/handlers/InstallationCallback.ts +292 -0
  77. package/src/handlers/WebhookHandler.ts +179 -0
  78. package/src/index.ts +29 -0
  79. package/src/repos/GitHubAppSessionRepo.ts +36 -0
  80. package/src/repos/GitHubInstallationRepo.ts +95 -0
  81. package/src/repos/GitHubWebhookEventRepo.ts +48 -0
  82. package/src/schemas/GitHubAppSession.ts +13 -0
  83. package/src/schemas/GitHubInstallation.ts +28 -0
  84. package/src/schemas/InstallationCallbackRequest.ts +10 -0
  85. package/src/schemas/WebhookEvent.ts +16 -0
  86. package/src/schemas/WebhookPayload.ts +35 -0
  87. package/src/services/GitHubAPIClient.ts +244 -0
  88. package/src/services/GitHubAuthService.ts +188 -0
  89. package/src/services/WebhookValidator.ts +159 -0
  90. package/src/utils/error-utils.ts +148 -0
  91. package/src/utils/jwt-utils.ts +64 -0
  92. package/src/utils/state-utils.ts +72 -0
  93. package/src/utils/token-cache-utils.ts +89 -0
  94. package/src/utils/webhook-signature-utils.ts +57 -0
  95. package/tsconfig.dist.json +4 -0
  96. package/tsconfig.json +24 -0
@@ -0,0 +1,148 @@
1
+ /**
2
+ * GitHub App Error Interface and Utilities
3
+ *
4
+ * Provides standardized error handling for GitHub App flows including
5
+ * user-friendly messages and error code mapping.
6
+ *
7
+ * Error codes use kebab-case for frontend translation consistency.
8
+ */
9
+
10
+ /**
11
+ * Standardized GitHub App error structure
12
+ */
13
+ export interface GitHubAppError {
14
+ /** Error code for programmatic handling (kebab-case) */
15
+ code: string;
16
+
17
+ /** User-friendly error message */
18
+ message: string;
19
+
20
+ /** Additional error details (for logging, not user display) */
21
+ details?: any;
22
+ }
23
+
24
+ /**
25
+ * GitHub App error codes
26
+ *
27
+ * All codes use kebab-case for frontend translation.
28
+ */
29
+ export const GitHubAppErrorCodes = {
30
+ // Installation Flow Errors
31
+ INVALID_STATE: "invalid-state",
32
+ SESSION_EXPIRED: "session-expired",
33
+ INSTALLATION_NOT_FOUND: "installation-not-found",
34
+
35
+ // Authentication Errors
36
+ INVALID_PRIVATE_KEY: "invalid-private-key",
37
+ JWT_SIGNING_FAILED: "jwt-signing-failed",
38
+ TOKEN_EXCHANGE_FAILED: "token-exchange-failed",
39
+
40
+ // Webhook Errors
41
+ WEBHOOK_SIGNATURE_INVALID: "webhook-signature-invalid",
42
+ WEBHOOK_PAYLOAD_INVALID: "webhook-payload-invalid",
43
+
44
+ // Access Errors
45
+ REPOSITORY_NOT_ACCESSIBLE: "repository-not-accessible",
46
+ INSTALLATION_SUSPENDED: "installation-suspended",
47
+ INSTALLATION_NOT_OWNED: "installation-not-owned",
48
+
49
+ // API Errors
50
+ API_RATE_LIMIT: "api-rate-limit",
51
+ NETWORK_ERROR: "network-error",
52
+ SERVER_ERROR: "server-error",
53
+ } as const;
54
+
55
+ /**
56
+ * Create a standardized GitHub App error
57
+ *
58
+ * @param code - Error code from GitHubAppErrorCodes
59
+ * @param message - User-friendly error message
60
+ * @param details - Additional error details for logging
61
+ * @returns Standardized GitHubAppError object
62
+ */
63
+ export function createGitHubAppError(code: string, message: string, details?: any): GitHubAppError {
64
+ return {
65
+ code,
66
+ message,
67
+ details,
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Validate private key format
73
+ *
74
+ * @param privateKey - Private key to validate
75
+ * @returns true if valid
76
+ * @throws GitHubAppError if invalid
77
+ */
78
+ export function validatePrivateKey(privateKey: string): boolean {
79
+ if (!privateKey || typeof privateKey !== "string") {
80
+ throw createGitHubAppError(GitHubAppErrorCodes.INVALID_PRIVATE_KEY, "Private key is required and must be a string", {
81
+ provided: typeof privateKey,
82
+ });
83
+ }
84
+
85
+ if (!privateKey.includes("BEGIN") || !privateKey.includes("PRIVATE KEY")) {
86
+ throw createGitHubAppError(GitHubAppErrorCodes.INVALID_PRIVATE_KEY, "Private key must be in PEM format (PKCS#1 or PKCS#8)", {
87
+ hint: "Expected format: -----BEGIN RSA PRIVATE KEY----- or -----BEGIN PRIVATE KEY-----",
88
+ });
89
+ }
90
+
91
+ return true;
92
+ }
93
+
94
+ /**
95
+ * Map GitHub API errors to standardized errors
96
+ *
97
+ * Converts GitHub API error responses into user-friendly
98
+ * standardized errors while sanitizing sensitive data.
99
+ *
100
+ * @param error - Error from GitHub API or internal error
101
+ * @returns Standardized GitHubAppError
102
+ */
103
+ export function handleGitHubAPIError(error: any): GitHubAppError {
104
+ // Handle network errors
105
+ if (error.code === "ENOTFOUND" || error.code === "ECONNREFUSED" || error.code === "ETIMEDOUT") {
106
+ return createGitHubAppError(GitHubAppErrorCodes.NETWORK_ERROR, "Unable to connect to GitHub API. Please check your connection and try again.", {
107
+ networkError: error.code,
108
+ });
109
+ }
110
+
111
+ // Handle rate limit errors
112
+ if (error.status === 403 && error.message?.includes("rate limit")) {
113
+ return createGitHubAppError(GitHubAppErrorCodes.API_RATE_LIMIT, "GitHub API rate limit exceeded. Please try again later.", {
114
+ status: error.status,
115
+ });
116
+ }
117
+
118
+ // Handle installation suspended
119
+ if (error.status === 403 && error.message?.includes("suspended")) {
120
+ return createGitHubAppError(
121
+ GitHubAppErrorCodes.INSTALLATION_SUSPENDED,
122
+ "This GitHub App installation has been suspended. Please contact the repository owner.",
123
+ {
124
+ status: error.status,
125
+ }
126
+ );
127
+ }
128
+
129
+ // Handle installation not found
130
+ if (error.status === 404) {
131
+ return createGitHubAppError(GitHubAppErrorCodes.INSTALLATION_NOT_FOUND, "GitHub App installation not found.", {
132
+ status: error.status,
133
+ });
134
+ }
135
+
136
+ // Handle unauthorized (invalid JWT or token)
137
+ if (error.status === 401) {
138
+ return createGitHubAppError(GitHubAppErrorCodes.TOKEN_EXCHANGE_FAILED, "Failed to authenticate with GitHub. Please try reinstalling the app.", {
139
+ status: error.status,
140
+ });
141
+ }
142
+
143
+ // Generic error fallback
144
+ return createGitHubAppError(GitHubAppErrorCodes.SERVER_ERROR, "An unexpected error occurred with GitHub API. Please try again.", {
145
+ originalError: error.message || "Unknown error",
146
+ status: error.status,
147
+ });
148
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * JWT Utilities for GitHub App Authentication
3
+ *
4
+ * Provides JWT generation with RS256 algorithm for GitHub App authentication.
5
+ * Supports both PKCS#1 and PKCS#8 private key formats.
6
+ */
7
+
8
+ import jwt from "jsonwebtoken";
9
+
10
+ /**
11
+ * Detect PEM format (PKCS#1 or PKCS#8)
12
+ *
13
+ * GitHub Apps can use either PKCS#1 (BEGIN RSA PRIVATE KEY) or
14
+ * PKCS#8 (BEGIN PRIVATE KEY) format. This function auto-detects
15
+ * the format for proper handling.
16
+ *
17
+ * @param privateKey - PEM-encoded private key
18
+ * @returns Format type: 'pkcs1' or 'pkcs8'
19
+ * @throws Error if format is not recognized
20
+ */
21
+ export function detectPEMFormat(privateKey: string): "pkcs1" | "pkcs8" {
22
+ if (privateKey.includes("BEGIN RSA PRIVATE KEY")) {
23
+ return "pkcs1";
24
+ } else if (privateKey.includes("BEGIN PRIVATE KEY")) {
25
+ return "pkcs8";
26
+ }
27
+
28
+ throw new Error("Invalid PEM format. Expected PKCS#1 (BEGIN RSA PRIVATE KEY) or PKCS#8 (BEGIN PRIVATE KEY).");
29
+ }
30
+
31
+ /**
32
+ * Generate GitHub App JWT
33
+ *
34
+ * Creates a JWT signed with the GitHub App's private key using RS256 algorithm.
35
+ * The JWT is valid for 10 minutes (600 seconds) as per GitHub's requirements.
36
+ *
37
+ * JWT Payload:
38
+ * - iat: Issued at timestamp (now)
39
+ * - exp: Expiration timestamp (now + 600 seconds)
40
+ * - iss: GitHub App ID
41
+ *
42
+ * @param appId - GitHub App ID
43
+ * @param privateKey - PEM-encoded RSA private key (PKCS#1 or PKCS#8)
44
+ * @returns Signed JWT token
45
+ * @throws Error if key is invalid or signing fails
46
+ */
47
+ export function generateJWT(appId: string, privateKey: string): string {
48
+ // Validate private key format
49
+ detectPEMFormat(privateKey);
50
+
51
+ const now = Math.floor(Date.now() / 1000);
52
+
53
+ const payload = {
54
+ iat: now,
55
+ exp: now + 600, // 10 minutes from now
56
+ iss: appId,
57
+ };
58
+
59
+ try {
60
+ return jwt.sign(payload, privateKey, { algorithm: "RS256" });
61
+ } catch (error: any) {
62
+ throw new Error(`Failed to sign JWT: ${error.message}`);
63
+ }
64
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * State Parameter Utilities for CSRF Protection
3
+ *
4
+ * Provides cryptographically secure state parameter generation
5
+ * and constant-time comparison for GitHub App installation CSRF protection.
6
+ *
7
+ * Reuses pattern from OAuth Plugin for consistency.
8
+ */
9
+
10
+ import crypto from "crypto";
11
+
12
+ /**
13
+ * Generate a cryptographically secure state parameter
14
+ *
15
+ * Creates a 32-byte random state parameter for CSRF protection
16
+ * in GitHub App installation flows. This state is stored in the session
17
+ * and must match when GitHub redirects back after installation.
18
+ *
19
+ * @returns Hex-encoded 32-byte random string (64 characters)
20
+ */
21
+ export function generateState(): string {
22
+ return crypto.randomBytes(32).toString("hex");
23
+ }
24
+
25
+ /**
26
+ * Validate state parameter using constant-time comparison
27
+ *
28
+ * Compares the provided state with the stored state using a
29
+ * constant-time algorithm to prevent timing attacks.
30
+ *
31
+ * @param provided - State parameter from GitHub callback
32
+ * @param stored - State parameter stored in session
33
+ * @returns true if states match, false otherwise
34
+ */
35
+ export function validateState(provided: string, stored: string): boolean {
36
+ if (!provided || !stored) {
37
+ return false;
38
+ }
39
+
40
+ // Ensure both strings are the same length to prevent timing attacks
41
+ if (provided.length !== stored.length) {
42
+ return false;
43
+ }
44
+
45
+ try {
46
+ // Use Node.js crypto.timingSafeEqual for constant-time comparison
47
+ const providedBuffer = Buffer.from(provided, "utf8");
48
+ const storedBuffer = Buffer.from(stored, "utf8");
49
+
50
+ // Both buffers must be same length for timingSafeEqual
51
+ if (providedBuffer.length !== storedBuffer.length) {
52
+ return false;
53
+ }
54
+
55
+ return crypto.timingSafeEqual(providedBuffer, storedBuffer);
56
+ } catch (error) {
57
+ // If comparison fails for any reason, return false
58
+ return false;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Generate a session ID for installation flow tracking
64
+ *
65
+ * Creates a unique session identifier for correlating installation
66
+ * initiation with callback.
67
+ *
68
+ * @returns Hex-encoded 16-byte random string (32 characters)
69
+ */
70
+ export function generateSessionId(): string {
71
+ return crypto.randomBytes(16).toString("hex");
72
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Token Cache Utilities
3
+ *
4
+ * In-memory cache for GitHub App installation access tokens.
5
+ * Tokens are cached with TTL to reduce GitHub API calls.
6
+ */
7
+
8
+ /**
9
+ * Cache entry structure
10
+ */
11
+ interface CacheEntry {
12
+ token: string;
13
+ expiresAt: number; // Unix timestamp in milliseconds
14
+ }
15
+
16
+ /**
17
+ * In-memory token cache
18
+ *
19
+ * Caches GitHub App installation access tokens with automatic expiration.
20
+ * Tokens are stored in memory only (never in database) for security.
21
+ */
22
+ export class TokenCache {
23
+ private cache: Map<number, CacheEntry>;
24
+
25
+ constructor() {
26
+ this.cache = new Map();
27
+ }
28
+
29
+ /**
30
+ * Get cached token for an installation
31
+ *
32
+ * Returns the cached token if it exists and hasn't expired.
33
+ * Returns null if token is not cached or has expired.
34
+ *
35
+ * @param installationId - GitHub installation ID
36
+ * @returns Cached token or null if not found/expired
37
+ */
38
+ getToken(installationId: number): string | null {
39
+ const entry = this.cache.get(installationId);
40
+
41
+ if (!entry) {
42
+ return null;
43
+ }
44
+
45
+ // Check if token has expired
46
+ if (Date.now() >= entry.expiresAt) {
47
+ // Remove expired token from cache
48
+ this.cache.delete(installationId);
49
+ return null;
50
+ }
51
+
52
+ return entry.token;
53
+ }
54
+
55
+ /**
56
+ * Store token in cache with TTL
57
+ *
58
+ * @param installationId - GitHub installation ID
59
+ * @param token - Installation access token
60
+ * @param ttl - Time to live in seconds (default: 3300 = 55 minutes)
61
+ */
62
+ setToken(installationId: number, token: string, ttl: number = 3300): void {
63
+ const expiresAt = Date.now() + ttl * 1000; // Convert seconds to milliseconds
64
+
65
+ this.cache.set(installationId, {
66
+ token,
67
+ expiresAt,
68
+ });
69
+ }
70
+
71
+ /**
72
+ * Clear all cached tokens
73
+ *
74
+ * Removes all tokens from the cache. Useful for testing,
75
+ * plugin shutdown, or when forcing token refresh.
76
+ */
77
+ clearCache(): void {
78
+ this.cache.clear();
79
+ }
80
+
81
+ /**
82
+ * Remove specific installation token from cache
83
+ *
84
+ * @param installationId - GitHub installation ID
85
+ */
86
+ deleteToken(installationId: number): void {
87
+ this.cache.delete(installationId);
88
+ }
89
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Webhook Signature Validation Utilities
3
+ *
4
+ * Validates GitHub webhook signatures using HMAC-SHA256 with
5
+ * constant-time comparison to prevent timing attacks.
6
+ */
7
+
8
+ import crypto from "crypto";
9
+
10
+ /**
11
+ * Validate GitHub webhook signature
12
+ *
13
+ * GitHub signs webhook payloads with HMAC-SHA256 using the webhook secret.
14
+ * The signature is sent in the X-Hub-Signature-256 header as "sha256=<hex>".
15
+ *
16
+ * This function uses constant-time comparison to prevent timing attacks.
17
+ *
18
+ * @param payload - Raw request body (string or Buffer)
19
+ * @param signature - X-Hub-Signature-256 header value
20
+ * @param secret - Webhook secret configured in GitHub App
21
+ * @returns true if signature is valid, false otherwise
22
+ */
23
+ export function validateWebhookSignature(payload: string | Buffer, signature: string, secret: string): boolean {
24
+ if (!payload || !signature || !secret) {
25
+ return false;
26
+ }
27
+
28
+ // Ensure signature has correct format (sha256=<hex>)
29
+ if (!signature.startsWith("sha256=")) {
30
+ return false;
31
+ }
32
+
33
+ try {
34
+ // Extract hex signature (remove "sha256=" prefix)
35
+ const providedSignature = signature.substring(7);
36
+
37
+ // Compute expected signature
38
+ const hmac = crypto.createHmac("sha256", secret);
39
+ const payloadBuffer = typeof payload === "string" ? Buffer.from(payload, "utf8") : payload;
40
+ hmac.update(payloadBuffer);
41
+ const expectedSignature = hmac.digest("hex");
42
+
43
+ // Use constant-time comparison to prevent timing attacks
44
+ const providedBuffer = Buffer.from(providedSignature, "hex");
45
+ const expectedBuffer = Buffer.from(expectedSignature, "hex");
46
+
47
+ // Both buffers must be same length for timingSafeEqual
48
+ if (providedBuffer.length !== expectedBuffer.length) {
49
+ return false;
50
+ }
51
+
52
+ return crypto.timingSafeEqual(providedBuffer, expectedBuffer);
53
+ } catch (error) {
54
+ // If comparison fails for any reason, return false
55
+ return false;
56
+ }
57
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "./tsconfig",
3
+ "exclude": ["spec/**/*.ts"]
4
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "lib": ["ES2020"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "esModuleInterop": true,
8
+ "allowSyntheticDefaultImports": true,
9
+ "strict": true,
10
+ "forceConsistentCasingInFileNames": true,
11
+ "module": "commonjs",
12
+ "moduleResolution": "node",
13
+ "resolveJsonModule": true,
14
+ "isolatedModules": true,
15
+ "noEmit": false,
16
+ "declaration": true,
17
+ "experimentalDecorators": true,
18
+ "checkJs": true,
19
+ "outDir": "dist",
20
+ "typeRoots": ["./node_modules/@types"]
21
+ },
22
+ "include": ["./src/*", "./spec/*"],
23
+ "exclude": ["./node_modules/*"]
24
+ }