diff --git a/src/auth/github-auth/github-auth.controller.spec.ts b/src/auth/github-auth/github-auth.controller.spec.ts new file mode 100644 index 0000000..dfbd0cf --- /dev/null +++ b/src/auth/github-auth/github-auth.controller.spec.ts @@ -0,0 +1,69 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { GithubAuthController } from './github-auth.controller'; +import { GithubAuthService } from './github-auth.service'; +import { Response } from 'express'; + +describe('GithubAuthController', () => { + let controller: GithubAuthController; + let service: GithubAuthService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [GithubAuthController], + providers: [ + { + provide: GithubAuthService, + useValue: { + getGitHubAuthURL: jest.fn(), + getAccessToken: jest.fn(), + getGithubUser: jest.fn(), + }, + }, + ], + }).compile(); + + controller = module.get(GithubAuthController); + service = module.get(GithubAuthService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('redirectToGitHub', () => { + it('should redirect to GitHub with the returnUrl', () => { + const returnUrl = 'http://localhost:3000/home'; + const expectedUrl = 'https://github.com/login/oauth/authorize?...'; + jest.spyOn(service, 'getGitHubAuthURL').mockReturnValue(expectedUrl); + + const result = controller.redirectToGitHub(returnUrl); + expect(service.getGitHubAuthURL).toHaveBeenCalledWith(returnUrl); + expect(result).toEqual({ url: expectedUrl }); + }); + }); + + describe('githubAuthCallback', () => { + it('should handle the GitHub auth callback', async () => { + const code = 'code123'; + const state = encodeURIComponent('http://localhost:3000/home'); + const accessToken = 'access_token_123'; + const githubUser = { login: 'user123' }; + + jest.spyOn(service, 'getAccessToken').mockResolvedValue(accessToken); + jest.spyOn(service, 'getGithubUser').mockResolvedValue(githubUser); + + const res = { + cookie: jest.fn(), + redirect: jest.fn(), + } as unknown as Response; + + await controller.githubAuthCallback(code, state, res); + + expect(service.getAccessToken).toHaveBeenCalledWith(code); + expect(service.getGithubUser).toHaveBeenCalledWith(accessToken); + expect(res.cookie).toHaveBeenCalledWith('github-authentication-token', accessToken, { httpOnly: true }); + expect(res.cookie).toHaveBeenCalledWith('github-authentication-username', githubUser.login, { httpOnly: true }); + expect(res.redirect).toHaveBeenCalledWith(decodeURIComponent(state) || '/'); + }); + }); +}); diff --git a/src/auth/github-auth/github-auth.controller.ts b/src/auth/github-auth/github-auth.controller.ts new file mode 100644 index 0000000..bb117d2 --- /dev/null +++ b/src/auth/github-auth/github-auth.controller.ts @@ -0,0 +1,37 @@ +import { Controller, Get, Query, Redirect, Res } from '@nestjs/common'; +import { GithubAuthService } from './github-auth.service'; + +@Controller('auth/github') +export class GithubAuthController { + constructor(private readonly authService: GithubAuthService) {} + + /** + * Redirect to github for oauth flow + * @param returnUrl the URL on our site we want to return to after going through github oauth flow + * @returns + */ + @Get() + @Redirect() + redirectToGitHub(@Query('returnUrl') returnUrl?: string) { + const url = this.authService.getGitHubAuthURL(returnUrl); + return { url }; + } + + /** + * This is the endpoint that github will call after it's been authorized by an account + * @param code used to get the user oauth token + * @param state used to get the URL on our site to return to + * @param res + */ + @Get('callback') + async githubAuthCallback(@Query('code') code: string, @Query('state') state: string, @Res() res) { + const accessToken = await this.authService.getAccessToken(code); + const githubUser = await this.authService.getGithubUser(accessToken); + // Set the server-side cookies + res.cookie('github-authentication-token', accessToken, { httpOnly: true }); + res.cookie('github-authentication-username', githubUser.login, { httpOnly: true }); + // Decode to get the redirect url and redirect there + const returnUrl = decodeURIComponent(state); + res.redirect(returnUrl || '/'); + } +} diff --git a/src/auth/github-auth/github-auth.module.ts b/src/auth/github-auth/github-auth.module.ts new file mode 100644 index 0000000..eab8c8b --- /dev/null +++ b/src/auth/github-auth/github-auth.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { GithubAuthService } from './github-auth.service'; +import { GithubAuthController } from './github-auth.controller'; + +@Module({ + controllers: [GithubAuthController], + providers: [GithubAuthService], +}) +export class GithubAuthModule {} diff --git a/src/auth/github-auth/github-auth.service.spec.ts b/src/auth/github-auth/github-auth.service.spec.ts new file mode 100644 index 0000000..9403bdb --- /dev/null +++ b/src/auth/github-auth/github-auth.service.spec.ts @@ -0,0 +1,57 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { GithubAuthService } from './github-auth.service'; +import axios from 'axios'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +describe('GithubAuthService', () => { + let service: GithubAuthService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [GithubAuthService], + }).compile(); + + service = module.get(GithubAuthService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getGitHubAuthURL', () => { + it('should generate the correct GitHub OAuth URL', () => { + const returnUrl = 'http://localhost:3000/home'; + const url = service.getGitHubAuthURL(returnUrl); + expect(url).toContain(`state=${encodeURIComponent(returnUrl)}`); + expect(url).toContain('scope=repo,read:user'); + }); + }); + + describe('getAccessToken', () => { + it('should return an access token', async () => { + const mockAccessToken = 'access_token_123'; + mockedAxios.post.mockResolvedValue({ data: { access_token: mockAccessToken } }); + + const code = 'code123'; + const accessToken = await service.getAccessToken(code); + + expect(accessToken).toEqual(mockAccessToken); + expect(mockedAxios.post).toHaveBeenCalled(); + }); + }); + + describe('getGithubUser', () => { + it('should return GitHub user info', async () => { + const mockGithubUser = { login: 'user123' }; + mockedAxios.get.mockResolvedValue({ data: mockGithubUser }); + + const accessToken = 'access_token_123'; + const githubUser = await service.getGithubUser(accessToken); + + expect(githubUser).toEqual(mockGithubUser); + expect(mockedAxios.get).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/auth/github-auth/github-auth.service.ts b/src/auth/github-auth/github-auth.service.ts new file mode 100644 index 0000000..7ddfd98 --- /dev/null +++ b/src/auth/github-auth/github-auth.service.ts @@ -0,0 +1,66 @@ +import { Injectable } from '@nestjs/common'; +import axios from 'axios'; + +const GITHUB_OAUTH_URL = 'https://github.com/login/oauth'; +const GITHUB_USER_URL = 'https://api.github.com/user'; + +@Injectable() +export class GithubAuthService { + private readonly redirectUri = `${process.env.FRONTEND_URI}/auth/github/callback`; + + /** + * Gnerates URL for GitHub oauth flow + * @param returnUrl the URL on our site we want to return to after going through github oauth flow + * @returns oauth URL + */ + getGitHubAuthURL(returnUrl: string = '/') { + // URL encode the returnUrl to ensure it's safely transmitted + const encodedReturnUrl = encodeURIComponent(returnUrl); + // Include the encoded returnUrl in the state parameter + const state = encodedReturnUrl; + const url = `${GITHUB_OAUTH_URL}/authorize?client_id=${process.env.GITHUB_APP_CLIENT_ID}&redirect_uri=${this.redirectUri}&scope=repo,read:user&state=${state}`; + return url; + } + + /** + * Gets the oauth access token for a user from the oauth code + * @param code generated by oauth flow + * @returns oauth access token + */ + async getAccessToken(code: string): Promise { + const response = await axios.post( + `${GITHUB_OAUTH_URL}/access_token`, + { + client_id: process.env.GITHUB_APP_CLIENT_ID, + client_secret: process.env.GITHUB_APP_CLIENT_SECRET, + code, + redirect_uri: this.redirectUri, + }, + { + headers: { + Accept: 'application/json', + }, + }, + ); + return response.data.access_token; + } + + /** + * Gets the Github user information (username, etc.) + * @param accessToken token of user + * @returns GitHub user info + */ + async getGithubUser(accessToken: string): Promise { + try { + const response = await axios.get(GITHUB_USER_URL, { + headers: { + Authorization: `token ${accessToken}`, + }, + }); + return response.data; + } catch (error) { + console.error('Error fetching GitHub user:', error); + throw new Error('Failed to fetch GitHub user information'); + } + } +}