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
128 changes: 67 additions & 61 deletions backend/src/auth/github.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import crypto from 'crypto';
import prisma from '../db/index.js';
import logger from '../utils/logger.js';
import { encryptToken, decryptToken } from '../utils/tokenEncryption.js';
import { githubApiBreaker } from '../lib/circuit-breaker/externalServices.js';
import {
generateAccessToken,
generateRefreshToken,
Expand Down Expand Up @@ -122,34 +124,36 @@ export const buildAuthorizationUrl = (state: string): string => {
export const exchangeCodeForToken = async (
code: string
): Promise<GitHubAccessTokenResponse> => {
const response = await fetch(GITHUB_TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
client_id: GITHUB_CLIENT_ID,
client_secret: GITHUB_CLIENT_SECRET,
code,
redirect_uri: GITHUB_REDIRECT_URI,
}),
});
return githubApiBreaker.execute(async () => {
const response = await fetch(GITHUB_TOKEN_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
client_id: GITHUB_CLIENT_ID,
client_secret: GITHUB_CLIENT_SECRET,
code,
redirect_uri: GITHUB_REDIRECT_URI,
}),
});

if (!response.ok) {
const errorBody = await response.text();
logger.error('GitHub token exchange failed:', { status: response.status, body: errorBody });
throw new Error(`GitHub token exchange failed with status ${response.status}`);
}
if (!response.ok) {
const errorBody = await response.text();
logger.error('GitHub token exchange failed:', { status: response.status, body: errorBody });
throw new Error(`GitHub token exchange failed with status ${response.status}`);
}

const data = (await response.json()) as GitHubAccessTokenResponse;
const data = (await response.json()) as GitHubAccessTokenResponse;

if ((data as any).error) {
logger.error('GitHub OAuth error:', (data as any).error_description || (data as any).error);
throw new Error(`GitHub OAuth error: ${(data as any).error_description || (data as any).error}`);
}
if ((data as any).error) {
logger.error('GitHub OAuth error:', (data as any).error_description || (data as any).error);
throw new Error(`GitHub OAuth error: ${(data as any).error_description || (data as any).error}`);
}

return data;
return data;
});
};

/**
Expand All @@ -158,21 +162,23 @@ export const exchangeCodeForToken = async (
export const fetchGitHubUser = async (
accessToken: string
): Promise<GitHubUser> => {
const response = await fetch(`${GITHUB_API_URL}/user`, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'Web3-Student-Lab',
},
});
return githubApiBreaker.execute(async () => {
const response = await fetch(`${GITHUB_API_URL}/user`, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'Web3-Student-Lab',
},
});

if (!response.ok) {
const errorBody = await response.text();
logger.error('GitHub API user fetch failed:', { status: response.status, body: errorBody });
throw new Error(`Failed to fetch GitHub user: ${response.status}`);
}
if (!response.ok) {
const errorBody = await response.text();
logger.error('GitHub API user fetch failed:', { status: response.status, body: errorBody });
throw new Error(`Failed to fetch GitHub user: ${response.status}`);
}

return response.json() as Promise<GitHubUser>;
return response.json() as Promise<GitHubUser>;
});
};

/**
Expand All @@ -181,26 +187,29 @@ export const fetchGitHubUser = async (
export const fetchGitHubPrimaryEmail = async (
accessToken: string
): Promise<string | null> => {
try {
const response = await fetch(`${GITHUB_API_URL}/user/emails`, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'Web3-Student-Lab',
},
});
return githubApiBreaker.execute(
async () => {
const response = await fetch(`${GITHUB_API_URL}/user/emails`, {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'Web3-Student-Lab',
},
});

if (!response.ok) {
if (!response.ok) {
return null;
}

const emails = (await response.json()) as GitHubEmail[];
const primaryEmail = emails.find((e) => e.primary && e.verified);
return primaryEmail?.email || null;
},
(error) => {
logger.warn('Circuit breaker fallback for GitHub email fetch:', error.message);
return null;
}

const emails = (await response.json()) as GitHubEmail[];
const primaryEmail = emails.find((e) => e.primary && e.verified);
return primaryEmail?.email || null;
} catch (error) {
logger.warn('Failed to fetch GitHub emails:', error);
return null;
}
);
};

/**
Expand All @@ -222,7 +231,7 @@ export const findOrCreateStudentByGitHub = async (
data: {
githubUsername: githubUser.login,
githubAvatarUrl: githubUser.avatar_url,
githubAccessToken,
githubAccessToken: encryptToken(githubAccessToken),
updatedAt: new Date(),
},
});
Expand All @@ -246,7 +255,7 @@ export const findOrCreateStudentByGitHub = async (
githubId: githubUser.id,
githubUsername: githubUser.login,
githubAvatarUrl: githubUser.avatar_url,
githubAccessToken,
githubAccessToken: encryptToken(githubAccessToken),
updatedAt: new Date(),
},
});
Expand Down Expand Up @@ -276,8 +285,7 @@ export const findOrCreateStudentByGitHub = async (
githubId: githubUser.id,
githubUsername: githubUser.login,
githubAvatarUrl: githubUser.avatar_url,
// TODO: Encrypt the GitHub access token before storing in production
githubAccessToken,
githubAccessToken: encryptToken(githubAccessToken),
},
});

Expand Down Expand Up @@ -360,9 +368,7 @@ export const linkGitHubAccount = async (
githubId: githubUser.id,
githubUsername: githubUser.login,
githubAvatarUrl: githubUser.avatar_url,
// TODO: Encrypt the GitHub access token before storing in production
// The project has encryptPayload/decryptionMiddleware utilities
githubAccessToken: tokenResponse.access_token,
githubAccessToken: encryptToken(tokenResponse.access_token),
updatedAt: new Date(),
},
});
Expand Down
2 changes: 2 additions & 0 deletions backend/src/graphql/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { json } from 'express';
import { typeDefs } from './schema.js';
import { resolvers } from './resolvers.js';
import { createGraphQLContext } from './context.js';
import { graphqlQueryComplexityLimiter } from '../middleware/graphqlRateLimiter.js';
import logger from '../utils/logger.js';

export const createGraphQLServer = async () => {
Expand Down Expand Up @@ -36,6 +37,7 @@ export const graphQLMiddleware = async () => {
return [
json(),
cors<cors.CorsRequest>({ origin: true }),
graphqlQueryComplexityLimiter,
expressMiddleware(server, {
context: createGraphQLContext,
}),
Expand Down
2 changes: 2 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { dbRoutingMiddleware } from './middleware/dbRouting.js';
import { decryptionMiddleware } from './middleware/encryptionMiddleware.js';
import { errorHandler } from './middleware/errorHandler.js';
import { createI18nMiddleware } from './middleware/i18n.js';
import { graphqlQueryComplexityLimiter } from './middleware/graphqlRateLimiter.js';
import { rateLimiter } from './middleware/rateLimiter.js';
import { requestLogger } from './middleware/requestLogger.js';
import { requireWorkspaceMiddleware } from './middleware/WorkspaceContext.js';
Expand Down Expand Up @@ -170,6 +171,7 @@ async function setupGraphQL() {
'/graphql',
express.json(),
cors<cors.CorsRequest>({ origin: true }),
graphqlQueryComplexityLimiter,
expressMiddleware(graphqlServer, {
context: async () => ({ prisma, redis: redisConnection }),
})
Expand Down
Loading
Loading