Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,8 @@ docker compose run --rm sync

The sync service downloads all available historical data on first run. Subsequent runs (or the daily schedule) only sync the latest day.

> **Alternative:** Instead of `NUXT_GITHUB_TOKEN`, you can use GitHub App authentication with `NUXT_GITHUB_APP_ID` and `NUXT_GITHUB_APP_PRIVATE_KEY`. This is recommended when using OAuth/external auth providers (Google, Microsoft, Auth0, Keycloak) since it decouples API access from individual user accounts.

### Enterprise Scope

```bash
Expand Down Expand Up @@ -299,7 +301,12 @@ These endpoints respond in ~200ms without making external API calls and do not r

### Admin Sync API

When running in Historical mode, the web app exposes a manual sync endpoint for backfilling or repairing data. If the app is configured with `NUXT_GITHUB_TOKEN`, the Authorization header is optional (the server uses its own token).
When running in Historical mode, the web app exposes a manual sync endpoint for backfilling or repairing data.

**Authentication:** The endpoint supports three authentication modes:
1. **Server credentials** — If the app is configured with `NUXT_GITHUB_TOKEN` (PAT) or `NUXT_GITHUB_APP_ID` + `NUXT_GITHUB_APP_PRIVATE_KEY` (GitHub App), the Authorization header is optional (the server uses its own credentials).
2. **Pass-through auth** — Even when OAuth/external auth is enabled, you can pass a GitHub token directly via the `Authorization: ******` header, bypassing the user session requirement.
3. **User session** — When logged in via OAuth, the endpoint uses the authenticated user's GitHub access token automatically.

> **Note:** The GitHub Copilot Metrics API provides historical data well beyond the 28-day rolling window. The 1-day endpoint supports dates going back many months, so `sync-date`, `sync-range`, and `sync-gaps` can all backfill historical data. The 28-day limit only applies to `sync-last-28` (which uses the bulk download endpoint).

Expand Down
16 changes: 16 additions & 0 deletions server/middleware/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,24 @@ export default defineEventHandler(async (event) => {
}

// When OAuth mode is enabled, require a valid user session for all API calls
// EXCEPT for /api/admin/sync which supports pass-through authentication
const requireAuth = config.public.requireAuth || config.public.usingGithubAuth || config.public.isPublicApp || !!config.public.authProviders;
if (requireAuth) {
// Allow /api/admin/sync to bypass session check if it has an Authorization header
const authHeader = event.node.req.headers['authorization'];
const isAdminSync = url.startsWith('/api/admin/sync');

Comment on lines +34 to +37
if (isAdminSync && authHeader) {
// Pass-through auth: use the provided Authorization header directly
event.context.headers = new Headers({
'Authorization': authHeader,
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28'
});
return;
}

// For all other routes, require a valid user session
const session = await getUserSession(event).catch(() => null);
if (!session?.user) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
Expand Down
26 changes: 13 additions & 13 deletions server/sync-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
* - NUXT_PUBLIC_SCOPE: organization or enterprise
* - NUXT_PUBLIC_GITHUB_ORG: GitHub organization slug
* - NUXT_PUBLIC_GITHUB_ENT: GitHub enterprise slug
* - NUXT_GITHUB_TOKEN: GitHub personal access token
* - NUXT_GITHUB_TOKEN: GitHub personal access token (alternative to GitHub App)
* - NUXT_GITHUB_APP_ID: GitHub App ID (alternative to PAT)
* - NUXT_GITHUB_APP_PRIVATE_KEY: GitHub App private key (alternative to PAT)
* - NUXT_GITHUB_API_BASE_URL: Optional API base URL override for GHE.com (e.g. https://api.SUBDOMAIN.ghe.com)
* - SYNC_DAYS_BACK: Number of days to sync (default: 28, uses bulk download)
* - DATABASE_URL: PostgreSQL connection string (or use PG* env vars)
Expand All @@ -24,6 +26,7 @@ initializeProxyAgent(true /* exitOnError */);
import { syncBulk } from './services/sync-service';
import { initSchema } from './storage/db';
import { closePool } from './storage/db';
import { getSyncAuthHeaders } from './utils/sync-auth';

export async function runSync() {
const logger = console;
Expand All @@ -35,27 +38,24 @@ export async function runSync() {
: rawScope) as 'organization' | 'enterprise';
const githubOrg = process.env.NUXT_PUBLIC_GITHUB_ORG;
const githubEnt = process.env.NUXT_PUBLIC_GITHUB_ENT;
const githubToken = process.env.NUXT_GITHUB_TOKEN;
const daysBack = parseInt(process.env.SYNC_DAYS_BACK || '28', 10);

if (!githubToken) {
logger.error('NUXT_GITHUB_TOKEN environment variable is required');
process.exit(1);
return; // guard: allows tests to mock process.exit without continuing
}

const identifier = githubOrg || githubEnt || '';
if (!identifier) {
logger.error('NUXT_PUBLIC_GITHUB_ORG or NUXT_PUBLIC_GITHUB_ENT must be set');
process.exit(1);
return; // guard: allows tests to mock process.exit without continuing
}

const headers = {
'Authorization': `Bearer ${githubToken}`,
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28'
};
// Get authentication headers (supports both PAT and GitHub App)
let headers: Headers;
try {
headers = await getSyncAuthHeaders(logger, identifier);
} catch (error) {
logger.error(error instanceof Error ? error.message : String(error));
process.exit(1);
return; // guard: allows tests to mock process.exit without continuing
}

try {
// Initialize database schema
Expand Down
117 changes: 117 additions & 0 deletions server/utils/sync-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* Authentication utilities for standalone sync job.
* This module provides authentication without requiring an H3Event context.
*/

import { createPrivateKey, createSign } from 'node:crypto';
import { listAppInstallations } from '../modules/github-app-auth';

Comment on lines +6 to +8
/**
* Build a short-lived JWT for GitHub App API calls.
*/
function buildAppJwt(appId: string, privateKey: string): string {
function b64url(buf: Buffer): string {
return buf.toString('base64url');
}

function normalisePem(pem: string): string {
return pem.replace(/\\n/g, '\n');
}

function signJWT(payload: Record<string, unknown>, privateKeyPem: string): string {
const header = b64url(Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })));
const body = b64url(Buffer.from(JSON.stringify(payload)));
const signingInput = `${header}.${body}`;

const key = createPrivateKey({ key: normalisePem(privateKeyPem), format: 'pem' });
const sign = createSign('RSA-SHA256');
sign.update(signingInput);
const sig = sign.sign(key);
return `${signingInput}.${b64url(sig)}`;
}

const now = Math.floor(Date.now() / 1000);
try {
return signJWT({ iss: appId, iat: now - 10, exp: now + 600 }, privateKey);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(
`Failed to sign GitHub App JWT: ${msg}. ` +
'Check NUXT_GITHUB_APP_PRIVATE_KEY is a valid PEM-encoded RSA private key.'
);
}
}

/**
* Get a GitHub App installation token for the specified org/enterprise.
*/
async function getGitHubAppTokenForSync(appId: string, privateKey: string, targetOrg: string): Promise<string> {
const installations = await listAppInstallations(appId, privateKey);

const installation = installations.find(i => i.login.toLowerCase() === targetOrg.toLowerCase());
if (!installation) {
throw new Error(
`GitHub App is not installed on org/enterprise "${targetOrg}". ` +
`Available: ${installations.map(i => i.login).join(', ')}`
);
}

const jwt = buildAppJwt(appId, privateKey);
const apiBaseUrl = process.env.NUXT_GITHUB_API_BASE_URL || 'https://api.github.com';

const response = await $fetch<{ token: string; expires_at: string }>(
`${apiBaseUrl}/app/installations/${installation.id}/access_tokens`,
{
method: 'POST',
headers: {
Accept: 'application/vnd.github+json',
Authorization: 'Bearer ' + jwt,
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'copilot-metrics-viewer'
}
}
);

return response.token;
}

/**
* Get authentication headers for GitHub API.
* Supports PAT and GitHub App authentication.
*
* @param logger - Logger instance for output
* @param identifier - GitHub org or enterprise slug
* @returns GitHub API headers with authentication
*/
export async function getSyncAuthHeaders(logger: Console, identifier: string): Promise<Headers> {
const githubToken = process.env.NUXT_GITHUB_TOKEN;
const githubAppId = process.env.NUXT_GITHUB_APP_ID;
const githubAppPrivateKey = process.env.NUXT_GITHUB_APP_PRIVATE_KEY;

// Try GitHub App authentication first (preferred)
if (githubAppId && githubAppPrivateKey) {
logger.info('Using GitHub App authentication');
const token = await getGitHubAppTokenForSync(githubAppId, githubAppPrivateKey, identifier);
return new Headers({
'Authorization': 'Bearer ' + token,
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28'
});
}

// Fall back to PAT
if (githubToken) {
logger.info('Using Personal Access Token authentication');
return new Headers({
'Authorization': 'Bearer ' + githubToken,
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28'
});
}

throw new Error(
'Authentication required. Configure one of:\n' +
' 1. GitHub App: set NUXT_GITHUB_APP_ID + NUXT_GITHUB_APP_PRIVATE_KEY\n' +
' 2. PAT: set NUXT_GITHUB_TOKEN'
);
}
Comment on lines +86 to +117
39 changes: 33 additions & 6 deletions tests/sync-entry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@
* - Can be imported without crashing (catches import-chain breakage like
* the original ERR_MODULE_NOT_FOUND from static mock JSON imports)
* - Calls syncBulk with the correct scope/identifier from env vars
* - Handles missing NUXT_GITHUB_TOKEN gracefully (exits with code 1)
* - Handles missing authentication credentials gracefully (exits with code 1)
* - Handles missing org/enterprise identifier gracefully (exits with code 1)
* - Calls initSchema and closePool on the database connection
* - Sets exitCode=1 when syncBulk throws
* - Supports both PAT and GitHub App authentication
*
* All external dependencies (proxy-agent, sync-service, db) are mocked so
* All external dependencies (proxy-agent, sync-service, db, sync-auth) are mocked so
* these tests run without a real GitHub token or PostgreSQL database.
*/

Expand All @@ -26,6 +27,7 @@ const {
mockSyncBulk,
mockInitSchema,
mockClosePool,
mockGetSyncAuthHeaders,
} = vi.hoisted(() => ({
mockInitializeProxyAgent: vi.fn().mockReturnValue(null),
mockSyncBulk: vi.fn().mockResolvedValue({
Expand All @@ -37,6 +39,11 @@ const {
}),
mockInitSchema: vi.fn().mockResolvedValue(undefined),
mockClosePool: vi.fn().mockResolvedValue(undefined),
mockGetSyncAuthHeaders: vi.fn().mockResolvedValue(new Headers({
'Authorization': '******',
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28'
})),
}));

vi.mock('../server/utils/proxy-agent', () => ({
Expand All @@ -52,6 +59,10 @@ vi.mock('../server/storage/db', () => ({
closePool: mockClosePool,
}));

vi.mock('../server/utils/sync-auth', () => ({
getSyncAuthHeaders: mockGetSyncAuthHeaders,
}));

// Import runSync after mocks are registered
import { runSync } from '../server/sync-entry';

Expand All @@ -60,6 +71,8 @@ import { runSync } from '../server/sync-entry';
/** The set of env vars managed by these tests. */
const MANAGED_VARS = [
'NUXT_GITHUB_TOKEN',
'NUXT_GITHUB_APP_ID',
'NUXT_GITHUB_APP_PRIVATE_KEY',
'NUXT_PUBLIC_GITHUB_ORG',
'NUXT_PUBLIC_GITHUB_ENT',
'NUXT_PUBLIC_SCOPE',
Expand Down Expand Up @@ -134,6 +147,11 @@ describe('sync-entry: happy path', () => {
skippedDays: 0,
errors: [],
});
mockGetSyncAuthHeaders.mockResolvedValue(new Headers({
'Authorization': '******',
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28'
}));
});

it('calls initSchema before syncing', withEnv(BASE_ENV, async () => {
Expand Down Expand Up @@ -180,10 +198,12 @@ describe('sync-entry: happy path', () => {
expect(scope).toBe('enterprise');
}));

it('passes Authorization header containing the GitHub token to syncBulk', withEnv(BASE_ENV, async () => {
it('calls getSyncAuthHeaders with logger and identifier', withEnv(BASE_ENV, async () => {
await runSync();
const [_scope, _identifier, headers] = mockSyncBulk.mock.calls[0] as [string, string, Record<string, string>];
expect(headers['Authorization']).toContain('test-token-abc');
expect(mockGetSyncAuthHeaders).toHaveBeenCalledOnce();
const [logger, identifier] = mockGetSyncAuthHeaders.mock.calls[0]!;
expect(logger).toBe(console);
expect(identifier).toBe('test-org');
}));

it('always calls closePool in the finally block', withEnv(BASE_ENV, async () => {
Expand Down Expand Up @@ -217,10 +237,12 @@ describe('sync-entry: missing env vars', () => {
exitSpy.mockRestore();
});

it('exits with code 1 when NUXT_GITHUB_TOKEN is not set', withEnv({
it('exits with code 1 when neither PAT nor GitHub App credentials are set', withEnv({
NUXT_PUBLIC_GITHUB_ORG: 'test-org',
NUXT_PUBLIC_SCOPE: 'organization',
}, async () => {
// Mock getSyncAuthHeaders to throw an error when no auth is available
mockGetSyncAuthHeaders.mockRejectedValue(new Error('Authentication required'));
await runSync();
expect(exitSpy).toHaveBeenCalledWith(1);
expect(mockSyncBulk).not.toHaveBeenCalled();
Expand All @@ -239,6 +261,11 @@ describe('sync-entry: missing env vars', () => {
describe('sync-entry: syncBulk failure', () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetSyncAuthHeaders.mockResolvedValue(new Headers({
'Authorization': '******',
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28'
}));
});

it('sets process.exitCode=1 when syncBulk throws', withEnv(BASE_ENV, async () => {
Expand Down
Loading