Skip to content

HarperFast/github-app

Harper GitHub App Plugin

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.

Features

  • 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.)

Architecture

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
Loading

Key Components

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

Token Lifecycle

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
Loading

Installation

npm install @harperfast/github-app

Setup

1. Create a GitHub App

Before using this plugin, you need to create a GitHub App:

  1. Go to GitHub Developer Settings (or your organization's settings)

  2. Click "New GitHub App"

  3. 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
  4. After creation, note your App ID (shown at the top of the settings page)

  5. Generate and download a private key (scroll down to "Private keys" section)

  6. Note your App slug from the public link (e.g., my-app from github.com/apps/my-app)

For detailed instructions, see GitHub's Creating a GitHub App guide.

2. Configure Environment Variables

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.

Configuration

'@harperfast/github-app':
  package: '@harperfast/github-app'
  appId: ${GITHUB_APP_ID}
  privateKey: ${GITHUB_APP_PRIVATE_KEY}
  appSlug: my-app

Configuration Options

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)

Usage

Metadata & State Management

The plugin uses a metadata property bag to preserve application-specific data across the OAuth redirect flow:

How it works:

  1. Your app directs users to /github/install with query parameters (e.g., ?userId=123&orgId=456)
  2. Plugin stores ALL query parameters as metadata in a secure state token
  3. User completes GitHub OAuth flow
  4. Plugin passes metadata to your beforeInstall and afterCallback hooks

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.

Installation Flow

Users install your GitHub App to grant repository access:

  1. Initiate installation - Direct users to /github/install with query parameters for metadata
  2. GitHub authorization - User authorizes app for their account/organization
  3. Callback handling - Plugin receives callback with installation ID
  4. Save installation - Use afterCallback hook 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,
		});
	},
});

Programmatic Usage

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);

Token Generation

// 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);

URL Utilities

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
}

Database Schema

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

How It Works

Token Generation Flow

  1. Check cache - Look for existing token with key ${appId}:${installationId}
  2. Generate JWT - Sign JWT with app private key (valid 10 minutes)
  3. Exchange JWT - Call GitHub API to get installation access token
  4. Cache token - Store token for 55 minutes
  5. Return token - Use for git operations

Error Handling

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"

Security

Security Features

  • 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

Hook-Based Security

Authentication and authorization are handled via hooks (see Hook System):

  • beforeInstall - Validate user authentication and permissions
  • afterCallback - 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!

Development

Build

npm install
npm run build

Watch Mode

npm run dev

Manual Testing with Debug Endpoints

Enable 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: true

Start your Harper application and visit:

Available debug endpoints:

  • GET /github/test - Test page with forms
  • GET /github/token/:installationId - Generate installation token
  • GET /github/installation/:installationId - Get installation details
  • GET /github/parse-url?url=... - Parse GitHub URL
  • GET /github/inject-token?url=...&token=... - Inject token into URL
  • GET /github/cache/:installationId - Check token cache status

⚠️ Security:

  • Debug endpoints default to localhost only (127.0.0.1, ::1)
  • Set DEBUG_ALLOWED_IPS environment variable to allow other IPs
  • Never enable debug mode in production

Testing

# 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

Linting

# Type checking
npm run lint

API Reference

GitHubAppPlugin

Main plugin class for managing GitHub Apps.

Constructor

new GitHubAppPlugin(config: PluginConfig, logger?: any)

Methods

  • getClient(appId: string): GitHubAppClient - Get client for specific app
  • findAppForRepo(repoUrl: string) - Find matching app for repository
  • isGitHubUrl(url: string): boolean - Check if URL is supported
  • getHosts(): string[] - Get configured GitHub hosts
  • getAppIds(): string[] - Get list of app IDs

GitHubAppClient

Client for interacting with GitHub API.

Methods

  • getInstallationToken(installationId: string): Promise<string> - Get cached or generate new token
  • getInstallation(installationId: string): Promise<GitHubInstallation> - Get installation details

Utility Functions

  • isGitHubUrl(url: string, hosts?: string[]): boolean - Check if URL is a GitHub URL
  • parseGitHubUrl(url: string): GitHubRepoInfo | null - Parse owner/repo/ref from URL
  • injectToken(url: string, token: string): string - Inject token into URL
  • maskTokenInUrl(url: string): string - Mask token for safe logging

Error Classes

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.

Hook System

The plugin provides lifecycle hooks for custom logic during the installation flow:

beforeInstall

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;
};

afterCallback

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,
	});
};

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

License

This project is licensed under the Apache License 2.0 - see the LICENSE file for details.

Copyright 2025 HarperDB, Inc.

About

GitHub App integration plugin for Harper

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published