/**
* GitHub Identity Provider
*
* Implements Layer 1 authentication using GitHub OAuth 2.0.
* Provides user identity (email, name, avatar) for MCP client authentication.
*
* Key differences from other providers:
* - Tokens don't expire (no refresh token flow)
* - Email may be private and require separate /user/emails call
* - Token endpoint returns form-encoded by default (need Accept: application/json)
*
* Usage:
* import { buildGitHubAuthUrl, fetchGitHubUserInfo, ... } from '../auth/identity/github';
*/
import type { IdentityTokens, UserIdentity } from './types';
import { GITHUB_CONFIG } from './types';
// =============================================================================
// Configuration
// =============================================================================
/**
* Default scopes for identity access
* - user:email: Access user email addresses (including private email)
* - read:user: Read user profile data
*/
export const GITHUB_IDENTITY_SCOPES = 'user:email read:user';
// =============================================================================
// Authorization URL
// =============================================================================
/**
* Constructs a GitHub OAuth authorization URL
*
* @param params - OAuth parameters
* @returns Full authorization URL
*/
export function buildGitHubAuthUrl(params: {
clientId: string;
redirectUri: string;
scope: string;
state: string;
allowSignup?: boolean; // Allow users to sign up for GitHub during OAuth
}): string {
const url = new URL(GITHUB_CONFIG.authorizationEndpoint);
url.searchParams.set('client_id', params.clientId);
url.searchParams.set('redirect_uri', params.redirectUri);
url.searchParams.set('scope', params.scope);
url.searchParams.set('state', params.state);
// By default, GitHub shows sign-up option. Set to false to hide it.
if (params.allowSignup === false) {
url.searchParams.set('allow_signup', 'false');
}
return url.href;
}
// =============================================================================
// Token Exchange
// =============================================================================
/**
* Exchange authorization code for tokens
*
* Note: GitHub tokens don't expire, so there's no refresh token.
*
* @param params - Token exchange parameters
* @returns Tokens or error response
*/
export async function exchangeGitHubCode(params: {
clientId: string;
clientSecret: string;
code: string;
redirectUri: string;
}): Promise<[IdentityTokens, null] | [null, Response]> {
const resp = await fetch(GITHUB_CONFIG.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// CRITICAL: GitHub returns form-encoded by default without this header
Accept: 'application/json',
},
body: JSON.stringify({
client_id: params.clientId,
client_secret: params.clientSecret,
code: params.code,
redirect_uri: params.redirectUri,
}),
});
if (!resp.ok) {
const errorText = await resp.text();
console.error('GitHub token exchange failed:', errorText);
return [null, new Response('Failed to fetch access token', { status: 500 })];
}
const body = (await resp.json()) as {
access_token?: string;
token_type?: string;
scope?: string;
error?: string;
error_description?: string;
error_uri?: string;
};
if (body.error) {
console.error('GitHub OAuth error:', body.error, body.error_description);
return [null, new Response(`OAuth error: ${body.error}`, { status: 400 })];
}
if (!body.access_token) {
return [null, new Response('Missing access token', { status: 400 })];
}
// GitHub tokens don't expire, no refresh token
console.log('OAuth: GitHub access token obtained (no expiry)');
return [
{
accessToken: body.access_token,
// No refresh token - GitHub tokens don't expire
refreshToken: undefined,
// No expiry - GitHub tokens are long-lived
expiresAt: undefined,
scope: body.scope,
},
null,
];
}
// =============================================================================
// User Info
// =============================================================================
/**
* Fetch user info from GitHub API
*
* Note: If user has private email, we need to fetch from /user/emails endpoint.
*
* @param accessToken - Valid access token
* @returns Normalized user identity or null on error
*/
export async function fetchGitHubUserInfo(
accessToken: string
): Promise<UserIdentity | null> {
console.log('Fetching GitHub user info from:', GITHUB_CONFIG.userInfoEndpoint);
const resp = await fetch(GITHUB_CONFIG.userInfoEndpoint, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'MCP-Server/1.0', // GitHub API requires User-Agent
},
});
if (!resp.ok) {
const errorText = await resp.text();
console.error('Failed to fetch GitHub user info:', resp.status, errorText);
return null;
}
console.log('GitHub /user response OK');
const data = (await resp.json()) as {
id: number;
login: string; // GitHub username
name?: string; // Display name (can be null)
email?: string; // Public email (can be null if private)
avatar_url?: string;
html_url: string;
blog?: string;
company?: string;
location?: string;
};
// Get email - may need fallback to /user/emails if private
let email = data.email;
console.log('GitHub user data:', { id: data.id, login: data.login, email: data.email || '(private)' });
if (!email) {
console.log('Email is private, fetching from /user/emails...');
email = await fetchGitHubPrimaryEmail(accessToken);
}
if (!email) {
console.error('GitHub user info: no email found (even from /user/emails)');
return null;
}
console.log('GitHub user info fetched:', { email, name: data.name || data.login });
return {
id: String(data.id),
email,
name: data.name || data.login, // Fallback to username if no display name
picture: data.avatar_url,
provider: 'github',
emailVerified: true, // GitHub requires email verification
raw: data,
};
}
// =============================================================================
// Email Fallback (for private emails)
// =============================================================================
/**
* Fetch primary verified email from /user/emails endpoint
*
* Used when user has a private email (not visible in /user endpoint).
* Requires 'user:email' scope.
*
* @param accessToken - Valid access token
* @returns Primary verified email or undefined
*/
export async function fetchGitHubPrimaryEmail(
accessToken: string
): Promise<string | undefined> {
try {
const resp = await fetch('https://api.github.com/user/emails', {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'MCP-Server/1.0', // GitHub API requires User-Agent
},
});
if (!resp.ok) {
console.warn('Could not fetch GitHub emails:', resp.status);
return undefined;
}
const emails = (await resp.json()) as Array<{
email: string;
primary: boolean;
verified: boolean;
visibility?: string;
}>;
// Find primary verified email
const primaryEmail = emails.find((e) => e.primary && e.verified);
if (primaryEmail) {
return primaryEmail.email;
}
// Fallback: any verified email
const verifiedEmail = emails.find((e) => e.verified);
if (verifiedEmail) {
console.log('Using non-primary verified email:', verifiedEmail.email);
return verifiedEmail.email;
}
return undefined;
} catch (error) {
console.error('Error fetching GitHub emails:', error);
return undefined;
}
}
// =============================================================================
// No Token Refresh (GitHub tokens don't expire)
// =============================================================================
/**
* GitHub tokens don't expire, so no refresh function is needed.
*
* If a token becomes invalid (e.g., user revoked access), the user
* must re-authenticate through the full OAuth flow.
*/
// =============================================================================
// Re-export config
// =============================================================================
export { GITHUB_CONFIG };