GitHub App plugin for Harper that enables private repository access via GitHub App installation tokens. Supports multiple GitHub Apps with path-based routing, automatic token caching (55 minutes), and OAuth-like installation flow with lifecycle hooks.
- Installation flow - OAuth-like installation flow with callback handling and lifecycle hooks
- Installation token generation - Authenticate with GitHub as a GitHub App
- Automatic token caching - 55-minute cache to maximize efficiency within GitHub's 60-minute token lifetime
- Multiple app support - Support multiple GitHub Apps with path-based routing
- CSRF protection - State token validation for installation flow security
- Lifecycle hooks - Extensible hooks for custom logic (beforeInstall, afterCallback)
- URL utilities - Parse, validate, and inject tokens into GitHub URLs
- Rate limit monitoring - Track GitHub API rate limits and warn when getting low
- Error handling - Clear error messages for common scenarios (revoked installations, rate limits, etc.)
The following diagram illustrates the OAuth-like installation flow and token management:
sequenceDiagram
participant User
participant App as Your Application
participant Plugin
participant Storage as State Storage
participant Cache as Token Cache
participant GitHub
Note over User,GitHub: Installation Flow (One-Time Setup)
User->>App: Access feature requiring GitHub
App->>Plugin: Initiate installation with metadata
Plugin->>Plugin: beforeInstall hook<br/>(validate & authorize)
Plugin->>Storage: Store state token + metadata
Storage-->>Plugin: State token
Plugin-->>User: Redirect to GitHub
User->>GitHub: Authorize app installation
GitHub-->>Plugin: Callback with installation ID + state
Plugin->>Storage: Verify & consume state token
Storage-->>Plugin: Original metadata
Plugin->>GitHub: Fetch installation details
GitHub-->>Plugin: Installation data
Plugin->>Plugin: afterCallback hook<br/>(persist installation)
Plugin->>App: Store installation ID
Plugin-->>User: Redirect to application
Note over User,GitHub: Token Generation & Usage (Per Request)
User->>App: Request private repository access
App->>Plugin: Request installation token
Plugin->>Cache: Check for cached token
alt Token cached
Cache-->>Plugin: Valid token
else Token missing or expired
Plugin->>Plugin: Generate JWT
Plugin->>GitHub: Exchange JWT for token
GitHub-->>Plugin: Installation token
Plugin->>Cache: Cache token with TTL
Cache-->>Plugin: OK
end
Plugin-->>App: Installation token
App->>App: Inject token into URL
App->>GitHub: Access private repository
GitHub-->>App: Repository contents
| Component | Responsibility | Security Features |
|---|---|---|
| Plugin Manager | Lifecycle & multi-app routing | Private key validation, credential protection |
| API Client | JWT generation & token exchange | Cryptographic signing, rate limit monitoring |
| State Manager | CSRF protection for OAuth flow | Random tokens, one-time use, time-based expiry |
| Resource Handler | HTTP endpoint routing | Route allowlist, IP-based access control |
| Hook System | Extensibility via callbacks | Structured error propagation |
| Token Cache | Performance optimization | Configurable storage backend, automatic expiry |
flowchart TD
Start([Application Requests Token]) --> CheckCache{Check Cache}
CheckCache -->|Hit| Return1[Return Cached Token]
CheckCache -->|Miss| GenJWT[Generate JWT]
GenJWT --> Exchange[Exchange JWT with GitHub]
Exchange --> Receive[Receive Installation Token]
Receive --> Store[Cache Token with TTL]
Store --> Return2[Return New Token]
Return1 --> End([Use Token])
Return2 --> End
style Start fill:#e1f5ff
style End fill:#e1f5ff
style CheckCache fill:#fff4e1
style Store fill:#f0e1ff
npm install @harperfast/github-appBefore using this plugin, you need to create a GitHub App:
-
Go to GitHub Developer Settings (or your organization's settings)
-
Click "New GitHub App"
-
Configure your app:
- GitHub App name: Your application name (e.g., "My Harper App")
- Homepage URL: Your application URL
- Callback URL:
https://your-domain.com/github/callback - Webhook: Disable unless needed for your use case
- Permissions:
- Repository permissions > Contents: Read-only (minimum for private repo access)
- Add other permissions as needed for your use case
- Where can this GitHub App be installed?: Choose based on your needs
-
After creation, note your App ID (shown at the top of the settings page)
-
Generate and download a private key (scroll down to "Private keys" section)
-
Note your App slug from the public link (e.g.,
my-appfromgithub.com/apps/my-app)
For detailed instructions, see GitHub's Creating a GitHub App guide.
Store your credentials securely:
export GITHUB_APP_ID="123456"
export GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
...
-----END RSA PRIVATE KEY-----"
export GITHUB_APP_SLUG="my-app"See .env.example for a complete template.
'@harperfast/github-app':
package: '@harperfast/github-app'
appId: ${GITHUB_APP_ID}
privateKey: ${GITHUB_APP_PRIVATE_KEY}
appSlug: my-app| Option | Type | Required | Description |
|---|---|---|---|
appId |
string | Yes | GitHub App ID |
privateKey |
string | Yes | GitHub App private key (PEM format) |
appSlug |
string | Yes | GitHub App slug (e.g., my-app from github.com/apps/my-app) used for installation URL |
hosts |
string[] | No | GitHub hosts to support (default: ['github.com']) |
debug |
boolean | No | Enable debug endpoints (default: false, localhost only) |
The plugin uses a metadata property bag to preserve application-specific data across the OAuth redirect flow:
How it works:
- Your app directs users to
/github/installwith query parameters (e.g.,?userId=123&orgId=456) - Plugin stores ALL query parameters as metadata in a secure state token
- User completes GitHub OAuth flow
- Plugin passes metadata to your
beforeInstallandafterCallbackhooks
Example:
// Your app initiates installation with custom metadata
window.location.href = '/github/install?userId=123&orgId=456&returnUrl=/settings';
// beforeInstall hook receives metadata and can add more data
beforeInstall: async (metadata, request) => {
// metadata = { userId: '123', orgId: '456', returnUrl: '/settings' }
metadata.sessionId = request.session.id; // Add session data
},
// afterCallback hook receives the same metadata
afterCallback: async (installation, metadata, request) => {
// metadata = { userId: '123', orgId: '456', returnUrl: '/settings', sessionId: '...' }
// Associate GitHub installation with your user/org
await User.update(metadata.userId, {
githubInstallationId: installation.installationId
});
}Important: The plugin does not prescribe metadata field names. Use any fields your application needs (userId, teamId, customerId, etc.). Validate metadata in your hooks.
Users install your GitHub App to grant repository access:
- Initiate installation - Direct users to
/github/installwith query parameters for metadata - GitHub authorization - User authorizes app for their account/organization
- Callback handling - Plugin receives callback with installation ID
- Save installation - Use
afterCallbackhook to save installation data
// resources.ts
import { registerHooks, UnauthorizedError } from '@harperfast/github-app';
registerHooks({
beforeInstall: async (metadata, request) => {
// Validate user is authenticated
if (!request.session?.user) {
throw new UnauthorizedError('Authentication required');
}
// Add user ID to metadata (will be passed to afterCallback via state token)
metadata.userId = request.session.user.id;
},
afterCallback: async (installation, metadata, request) => {
// Save installation data to your database
const { User } = tables;
await User.update(metadata.userId, {
githubInstallationId: installation.installationId,
githubAccount: installation.account.login,
});
},
});import { injectToken } from '@harperfast/github-app';
// Get the installation ID for this customer/organization
// (saved during installation via afterCallback hook)
const installationId = organization.githubInstallationId;
// Generate installation token (automatically cached for 55 minutes)
const token = await client.getInstallationToken(installationId);
// Inject token into repository URL
const repoUrl = 'https://github.com/myorg/private-repo';
const urlWithToken = injectToken(repoUrl, token);
// Use authenticated URL for git operations
await installPackage(urlWithToken);// Get client for specific app
const client = githubApp.getClient(appId);
// Generate installation token (cached for 55 minutes)
const token = await client.getInstallationToken(installationId);
// Get installation details
const installation = await client.getInstallation(installationId);import { isGitHubUrl, parseGitHubUrl, injectToken, maskTokenInUrl } from '@harperfast/github-app';
// Check if URL is a GitHub URL
if (isGitHubUrl(url)) {
// Parse URL
const info = parseGitHubUrl(url);
// { owner: 'myorg', repo: 'myrepo', ref: 'main' }
// Inject token
const urlWithToken = injectToken(url, token);
// git+https://x-access-token:TOKEN@github.com/myorg/myrepo.git#main
// Mask token for logging
const masked = maskTokenInUrl(urlWithToken);
// git+https://x-access-token:****@github.com/myorg/myrepo.git#main
}The plugin creates a GitHubToken table for caching:
type GitHubToken @table(expiration: 3300) {
key: String! @primaryKey # "${appId}:${installationId}"
appId: String! @indexed
installationId: String!
token: String!
createdAt: Date @createdTime
}Cache Duration: 55 minutes (3300 seconds)
- Tokens live for 60 minutes (GitHub enforced)
- Cached for 55 minutes to maximize reuse while ensuring refresh before expiration
- Check cache - Look for existing token with key
${appId}:${installationId} - Generate JWT - Sign JWT with app private key (valid 10 minutes)
- Exchange JWT - Call GitHub API to get installation access token
- Cache token - Store token for 55 minutes
- Return token - Use for git operations
The plugin provides clear error messages for common scenarios:
| Error | Cause | Message |
|---|---|---|
| Installation not found | 404 from GitHub | "GitHub App installation not found or was uninstalled. Please reinstall the GitHub App." |
| Rate limit exceeded | 403 with rate limit | "GitHub API rate limit exceeded. Resets at {timestamp}" |
| Invalid configuration | Missing credentials | "GitHub App plugin requires either appId/privateKey or apps configuration" |
- Short-lived tokens - Installation tokens cached for 55 minutes, expire at 60 minutes (GitHub enforced)
- Private key validation - Validates PEM format on startup, prevents placeholder values
- State token protection - CSRF-protected installation flow with one-time use tokens
- Database-backed state - Multi-instance safe state token storage (with fallback to in-memory)
- Open redirect prevention - Same-origin validation for returnUrl parameters
- Debug endpoint protection - IP allowlist (defaults to localhost only)
- Token masking - Use
maskTokenInUrl()before logging URLs - Rate limit monitoring - Warns when API rate limit < 1000 requests remaining
Authentication and authorization are handled via hooks (see Hook System):
beforeInstall- Validate user authentication and permissionsafterCallback- Save installation data securely
Security best practices:
// ✅ DO: Mask tokens before logging
logger.info('Deploying component', {
url: maskTokenInUrl(urlWithToken),
});
// ❌ DON'T: Log tokens directly
logger.info('Deploying component', { url: urlWithToken });
// ✅ DO: Delete tokens from logged data
delete operationData.githubToken;
await ProxiedRequestLog.put(operationData);
// ❌ DON'T: Log operations containing tokens
await ProxiedRequestLog.put(operationData); // Still has githubToken!npm install
npm run buildnpm run devEnable debug mode in your Harper application's config.yaml:
'@harperfast/github-app':
package: '@harperfast/github-app'
appId: ${GITHUB_APP_ID}
privateKey: ${GITHUB_APP_PRIVATE_KEY}
appSlug: my-app
debug: trueStart your Harper application and visit:
- http://localhost:9926/github-app/test - Interactive test page
Available debug endpoints:
GET /github/test- Test page with formsGET /github/token/:installationId- Generate installation tokenGET /github/installation/:installationId- Get installation detailsGET /github/parse-url?url=...- Parse GitHub URLGET /github/inject-token?url=...&token=...- Inject token into URLGET /github/cache/:installationId- Check token cache status
- Debug endpoints default to localhost only (127.0.0.1, ::1)
- Set
DEBUG_ALLOWED_IPSenvironment variable to allow other IPs - Never enable debug mode in production
# Run tests (uses Node.js built-in test runner)
npm test
# Run tests with coverage
npm run test:coverage
# Run specific test file
node --test test/utils.test.js# Type checking
npm run lintMain plugin class for managing GitHub Apps.
new GitHubAppPlugin(config: PluginConfig, logger?: any)getClient(appId: string): GitHubAppClient- Get client for specific appfindAppForRepo(repoUrl: string)- Find matching app for repositoryisGitHubUrl(url: string): boolean- Check if URL is supportedgetHosts(): string[]- Get configured GitHub hostsgetAppIds(): string[]- Get list of app IDs
Client for interacting with GitHub API.
getInstallationToken(installationId: string): Promise<string>- Get cached or generate new tokengetInstallation(installationId: string): Promise<GitHubInstallation>- Get installation details
isGitHubUrl(url: string, hosts?: string[]): boolean- Check if URL is a GitHub URLparseGitHubUrl(url: string): GitHubRepoInfo | null- Parse owner/repo/ref from URLinjectToken(url: string, token: string): string- Inject token into URLmaskTokenInUrl(url: string): string- Mask token for safe logging
The plugin exports Harper-compatible error classes for proper error handling:
import {
ConfigurationError,
UnauthorizedError,
ForbiddenError,
GitHubApiError,
InstallationNotFoundError,
RateLimitError,
TokenGenerationError,
} from '@harperfast/github-app';Base Classes:
ClientError- Base for 4xx errors (client-side issues)ServerError- Base for 5xx errors (server-side issues)
Specific Errors:
ConfigurationError extends ClientError- Invalid plugin configuration (400)InvalidRequestError extends ClientError- Invalid request parameters (400)UnauthorizedError extends ClientError- Authentication required (401)ForbiddenError extends ClientError- Authenticated but not authorized (403)GitHubApiError extends ServerError- GitHub API errors (500)InstallationNotFoundError extends GitHubApiError- Installation not found (404)RateLimitError extends GitHubApiError- API rate limit exceeded (429)TokenGenerationError extends ServerError- Token generation failed (500)
Usage:
// In hooks - throw errors to abort operations
if (!request.session?.user) {
throw new UnauthorizedError('Please log in');
}
// Handling errors from plugin methods
try {
const token = await client.getInstallationToken(installationId);
} catch (error) {
if (error instanceof InstallationNotFoundError) {
// Handle uninstalled app
} else if (error instanceof RateLimitError) {
// Handle rate limit (error.resetAt has Date)
}
}All error classes include a statusCode property for HTTP compatibility.
The plugin provides lifecycle hooks for custom logic during the installation flow:
Called before redirecting to GitHub for app installation. Use to validate user authentication or add data to metadata.
Signature:
beforeInstall?: (
metadata: Record<string, any>,
request: any,
appId?: string
) => Promise<void>;Example:
import { UnauthorizedError } from '@harperfast/github-app';
beforeInstall: async (metadata, request, appId) => {
// Validate authentication - throw error to abort installation
if (!request.session?.user) {
throw new UnauthorizedError('Authentication required');
}
// Add data to metadata (will be available in afterCallback)
metadata.userId = request.session.user.id;
metadata.email = request.session.user.email;
};Called after successful GitHub installation. Use to save installation data to your database.
Signature:
afterCallback?: (
installation: {
installationId: string;
setupAction?: string;
account: {
id: number;
login: string;
type: string;
};
repositorySelection: string;
repositories?: Array<{ name: string; full_name: string }>;
},
metadata: Record<string, any>,
request: any
) => Promise<void>;Example:
afterCallback: async (installation, metadata, request) => {
const { User } = tables;
await User.update(metadata.userId, {
githubInstallationId: installation.installationId,
githubAccount: installation.account.login,
});
};Contributions are welcome! Please feel free to submit a Pull Request.
This project is licensed under the Apache License 2.0 - see the LICENSE file for details.
Copyright 2025 HarperDB, Inc.