Skip to content

Commit

Permalink
Merge pull request #24 from bcgov/feature/new-oath-flow
Browse files Browse the repository at this point in the history
Feature/new oath flow
  • Loading branch information
timwekkenbc authored Jul 31, 2024
2 parents 90cfcbb + 8e3d3ef commit 649dcd6
Show file tree
Hide file tree
Showing 5 changed files with 238 additions and 0 deletions.
69 changes: 69 additions & 0 deletions src/auth/github-auth/github-auth.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(GithubAuthController);
service = module.get<GithubAuthService>(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) || '/');
});
});
});
37 changes: 37 additions & 0 deletions src/auth/github-auth/github-auth.controller.ts
Original file line number Diff line number Diff line change
@@ -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 || '/');
}
}
9 changes: 9 additions & 0 deletions src/auth/github-auth/github-auth.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
57 changes: 57 additions & 0 deletions src/auth/github-auth/github-auth.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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<typeof axios>;

describe('GithubAuthService', () => {
let service: GithubAuthService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [GithubAuthService],
}).compile();

service = module.get<GithubAuthService>(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();
});
});
});
66 changes: 66 additions & 0 deletions src/auth/github-auth/github-auth.service.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<any> {
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');
}
}
}

0 comments on commit 649dcd6

Please sign in to comment.