@remcostoeten/fync
Version:
Unified TypeScript library for 9 popular APIs with consistent functional architecture
365 lines (364 loc) • 10.5 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.GitHubOAuth = void 0;
exports.createGitHubOAuth = createGitHubOAuth;
exports.gitHubOAuth = gitHubOAuth;
var _apiFactory = require("../core/api-factory");
const GITHUB_OAUTH_BASE_URL = "https://github.com";
const GITHUB_API_BASE_URL = "https://api.github.com";
/**
* GitHub OAuth2 Authentication Client
*
* Provides a chainable API for GitHub OAuth2 authentication flow.
* Supports both standard OAuth2 and PKCE (Proof Key for Code Exchange) flows.
*
* @example
* ```typescript
* const githubAuth = GitHubOAuth({
* clientId: 'your-client-id',
* clientSecret: 'your-client-secret',
* redirectUri: 'http://localhost:3000/auth/github/callback'
* });
*
* // Generate authorization URL
* const authUrl = githubAuth.getAuthorizationUrl({
* scope: ['user:email', 'repo'],
* state: 'random-state-string'
* });
*
* // Exchange code for token
* const tokens = await githubAuth.exchangeCodeForToken(code, state);
*
* // Get user information
* const user = await githubAuth.withToken(tokens.access_token).getUser();
* ```
*/
class GitHubOAuth {
constructor(config) {
this.config = config;
}
/**
* Set the access token for authenticated requests
*/
withToken(token) {
const instance = new GitHubOAuth(this.config);
instance.token = token;
return instance;
}
/**
* Generate PKCE code verifier and challenge
*/
generatePKCE() {
// Generate a random 43-128 character string for code_verifier
const array = new Uint8Array(32);
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
crypto.getRandomValues(array);
} else {
// Fallback for Node.js environments
const cryptoNode = require('crypto');
const bytes = cryptoNode.randomBytes(32);
for (let i = 0; i < 32; i++) {
array[i] = bytes[i];
}
}
const codeVerifier = btoa(String.fromCharCode(...array)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
// Create code_challenge using SHA256
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
let codeChallenge;
if (typeof crypto !== 'undefined' && crypto.subtle) {
// Browser environment
crypto.subtle.digest('SHA-256', data).then(hash => {
const hashArray = new Uint8Array(hash);
codeChallenge = btoa(String.fromCharCode(...hashArray)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
});
} else {
// Node.js environment
const cryptoNode = require('crypto');
const hash = cryptoNode.createHash('sha256').update(codeVerifier).digest();
codeChallenge = hash.toString('base64url');
}
return {
codeVerifier,
codeChallenge: codeChallenge
};
}
/**
* Generate the OAuth2 authorization URL
*
* @param options Configuration options for the authorization request
* @param options.scope Array of scopes to request
* @param options.state Random state parameter for CSRF protection
* @param options.allowSignup Whether to allow new user signups
* @param options.login Suggested username for login
* @param options.pkce Whether to use PKCE flow (returns code_verifier if true)
*/
getAuthorizationUrl(options = {}) {
const params = {
client_id: this.config.clientId,
redirect_uri: this.config.redirectUri
};
if (options.scope && options.scope.length > 0) {
params.scope = options.scope.join(' ');
} else if (this.config.scope && this.config.scope.length > 0) {
params.scope = this.config.scope.join(' ');
}
if (options.state) {
params.state = options.state;
}
if (options.allowSignup !== undefined) {
params.allow_signup = options.allowSignup;
}
if (options.login) {
params.login = options.login;
}
let codeVerifier;
if (options.pkce) {
const pkce = this.generatePKCE();
params.code_challenge = pkce.codeChallenge;
params.code_challenge_method = 'S256';
codeVerifier = pkce.codeVerifier;
}
const url = new URL('/login/oauth/authorize', GITHUB_OAUTH_BASE_URL);
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
url.searchParams.append(key, String(value));
}
});
return {
url: url.toString(),
codeVerifier
};
}
/**
* Exchange authorization code for access token
*
* @param code Authorization code received from GitHub
* @param state State parameter (should match the one sent in authorization request)
* @param codeVerifier Code verifier for PKCE flow
*/
async exchangeCodeForToken(code, state, codeVerifier) {
const api = (0, _apiFactory.createFyncApi)({
baseUrl: GITHUB_OAUTH_BASE_URL
});
const body = {
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
code,
redirect_uri: this.config.redirectUri
};
if (state) {
body.state = state;
}
if (codeVerifier) {
body.code_verifier = codeVerifier;
}
try {
const response = await api.post('/login/oauth/access_token', body, {
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
}
});
return response;
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to exchange code for token: ${error.message}`);
}
throw error;
}
}
/**
* Refresh an existing access token (if refresh tokens are supported)
*/
async refreshToken(refreshToken) {
const api = (0, _apiFactory.createFyncApi)({
baseUrl: GITHUB_OAUTH_BASE_URL
});
const body = {
refresh_token: refreshToken,
grant_type: 'refresh_token',
client_id: this.config.clientId,
client_secret: this.config.clientSecret
};
try {
const response = await api.post('/login/oauth/access_token', body, {
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
}
});
return response;
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to refresh token: ${error.message}`);
}
throw error;
}
}
/**
* Revoke an access token
*/
async revokeToken(accessToken) {
const tokenToRevoke = accessToken || this.token;
if (!tokenToRevoke) {
throw new Error('No access token provided');
}
const api = (0, _apiFactory.createFyncApi)({
baseUrl: GITHUB_OAUTH_BASE_URL
});
const body = {
access_token: tokenToRevoke,
client_id: this.config.clientId,
client_secret: this.config.clientSecret
};
try {
await api.post('/applications/revoke', body, {
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
}
});
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to revoke token: ${error.message}`);
}
throw error;
}
}
/**
* Get authenticated user information
*/
async getUser() {
if (!this.token) {
throw new Error('Access token required. Use withToken() method first.');
}
const api = (0, _apiFactory.createFyncApi)({
baseUrl: GITHUB_API_BASE_URL,
auth: {
type: 'bearer',
token: this.token
}
});
try {
const user = await api.get('/user');
return user;
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to get user info: ${error.message}`);
}
throw error;
}
}
/**
* Get authenticated user's email addresses
*/
async getUserEmails() {
if (!this.token) {
throw new Error('Access token required. Use withToken() method first.');
}
const api = (0, _apiFactory.createFyncApi)({
baseUrl: GITHUB_API_BASE_URL,
auth: {
type: 'bearer',
token: this.token
}
});
try {
const emails = await api.get('/user/emails');
return emails;
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to get user emails: ${error.message}`);
}
throw error;
}
}
/**
* Get authenticated user's primary email
*/
async getPrimaryEmail() {
const emails = await this.getUserEmails();
const primaryEmail = emails.find(email => email.primary);
return primaryEmail?.email || null;
}
/**
* Check if the current token is valid
*/
async validateToken() {
if (!this.token) {
return false;
}
try {
await this.getUser();
return true;
} catch {
return false;
}
}
/**
* Get the scopes associated with the current token
*/
async getTokenScopes() {
if (!this.token) {
throw new Error('Access token required. Use withToken() method first.');
}
const api = (0, _apiFactory.createFyncApi)({
baseUrl: GITHUB_API_BASE_URL,
auth: {
type: 'bearer',
token: this.token
}
});
try {
const response = await fetch(`${GITHUB_API_BASE_URL}/user`, {
headers: {
Authorization: `Bearer ${this.token}`
}
});
const scopes = response.headers.get('X-OAuth-Scopes');
return scopes ? scopes.split(', ').map(s => s.trim()) : [];
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to get token scopes: ${error.message}`);
}
throw error;
}
}
/**
* Create a complete user profile with user info and emails
*/
async getCompleteProfile() {
const [user, emails, scopes] = await Promise.all([this.getUser(), this.getUserEmails().catch(() => []),
// Email scope might not be granted
this.getTokenScopes()]);
const primaryEmail = emails.find(email => email.primary)?.email || null;
return {
user,
emails,
primaryEmail,
scopes
};
}
}
/**
* Factory function to create a GitHub OAuth instance
*
* @param config OAuth configuration
* @returns GitHubOAuth instance
*/
exports.GitHubOAuth = GitHubOAuth;
function createGitHubOAuth(config) {
return new GitHubOAuth(config);
}
/**
* Functional factory for GitHub OAuth - preferred pattern
*
* @param config OAuth configuration
* @returns GitHubOAuth instance
*/
function gitHubOAuth(config) {
return new GitHubOAuth(config);
}