Skip to content

Commit

Permalink
chore: refresh token improvements, still a lot of wrk TODO
Browse files Browse the repository at this point in the history
  • Loading branch information
radoslavirha committed Dec 3, 2023
1 parent c064f6b commit 05c50a8
Show file tree
Hide file tree
Showing 14 changed files with 131 additions and 46 deletions.
2 changes: 2 additions & 0 deletions api/authentication/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
"method-override": "^3.0.0",
"moment": "^2.29.4",
"mongoose": "^8.0.0",
"ms": "^2.1.3",
"nodemailer": "^6.9.7",
"passport": "^0.6.0",
"passport-facebook": "^3.0.0",
Expand Down Expand Up @@ -100,6 +101,7 @@
"@types/jest": "^29.5.7",
"@types/jsonwebtoken": "^9.0.4",
"@types/method-override": "^0.0.34",
"@types/ms": "^0.7.34",
"@types/multer": "^1.4.9",
"@types/node": "^20.8.10",
"@types/nodemailer": "^6.4.13",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { EmailService } from '../services/EmailService';
import { ProtocolAuthService } from '../services/ProtocolAuthService';
import { CredentialsMongoService } from '../services/mongo/CredentialsMongoService';
import { EmailVerificationMongoService } from '../services/mongo/EmailVerificationMongoService';
import { CredentialsStub, EmailVerificationStub } from '../test/stubs';
import { CredentialsStub, EmailVerificationStub, TokensStub } from '../test/stubs';
import { CryptographyUtils } from '../utils';
import { AuthProviderEmailController } from './EmailController';

Expand Down Expand Up @@ -313,6 +313,18 @@ describe('AuthProviderEmailController', () => {
expect(response.status).toBe(400);
expect(response.body.message).toEqual(`Passwords do not match!`);
});

it('Should call authService.setRefreshCookie()', async () => {
jest.spyOn(verifyTokenHandler, 'execute').mockImplementation();
jest.spyOn(authService, 'emailSignUp').mockResolvedValue(TokensStub);
const spy = jest.spyOn(authService, 'setRefreshCookie').mockImplementation();

expect.assertions(1);

await request.post('/provider/email/sign-up').send(requestStub);

expect(spy).toHaveBeenCalledWith(expect.anything(), TokensStub.refresh);
});
});

describe('GET /auth/provider/email/sign-in', () => {
Expand All @@ -321,7 +333,7 @@ describe('AuthProviderEmailController', () => {
password: '8^^3286UhpB$9m'
};

it('Should call authService.emailSignUp()', async () => {
it('Should call authService.emailSignIn()', async () => {
const spy = jest.spyOn(authService, 'emailSignIn').mockImplementation();
const base64 = Buffer.from(`${requestStub.email}:${requestStub.password}`).toString('base64');

Expand All @@ -331,5 +343,17 @@ describe('AuthProviderEmailController', () => {

expect(spy).toHaveBeenCalledWith(requestStub);
});

it('Should call authService.setRefreshCookie()', async () => {
jest.spyOn(authService, 'emailSignIn').mockResolvedValue(TokensStub);
const spy = jest.spyOn(authService, 'setRefreshCookie').mockImplementation();
const base64 = Buffer.from(`${requestStub.email}:${requestStub.password}`).toString('base64');

expect.assertions(1);

await request.get('/provider/email/sign-in').set('Authorization', `Basic ${base64}`);

expect(spy).toHaveBeenCalledWith(expect.anything(), TokensStub.refresh);
});
});
});
3 changes: 3 additions & 0 deletions api/authentication/src/auth/models/Cookies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export enum Cookies {
Refresh = 'REFRESH'
}
14 changes: 14 additions & 0 deletions api/authentication/src/auth/models/auth/Tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Description, Required, Schema, Title } from '@tsed/schema';

@Schema({ additionalProperties: false })
export class Tokens {
@Title('access')
@Description('JWT access token.')
@Required()
access!: string;

@Title('refresh')
@Description('JWT refresh token.')
@Required()
refresh!: string;
}
5 changes: 0 additions & 5 deletions api/authentication/src/auth/models/auth/TokensResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,4 @@ export class TokensResponse {
@Description('JWT access token.')
@Required()
access!: string;

@Title('refresh')
@Description('JWT refresh token.')
@Required()
refresh!: string;
}
2 changes: 2 additions & 0 deletions api/authentication/src/auth/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
* @file Automatically generated by barrelsby.
*/

export * from './Cookies';
export * from './Credentials';
export * from './EmailVerification';
export * from './User';
export * from './auth/Tokens';
export * from './auth/TokensResponse';
export * from './auth/email/EmailSendVerificationRequest';
export * from './auth/email/EmailSignInRequest';
Expand Down
8 changes: 5 additions & 3 deletions api/authentication/src/auth/protocols/EmailSignInProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { CommonUtils } from '@hikers-book/tsed-common/utils';
import { Req } from '@tsed/common';
import { Args, OnInstall, OnVerify, Protocol } from '@tsed/passport';
import { BasicStrategy, BasicStrategyOptions } from 'passport-http';
import { EmailSignInRequest } from '../models';
import { EmailSignInRequest, TokensResponse } from '../models';
import { ProtocolAuthService } from '../services/ProtocolAuthService';

@Protocol<BasicStrategyOptions>({
Expand All @@ -18,7 +18,9 @@ export class EmailSignInProtocol implements OnVerify, OnInstall {
// intercept the strategy instance to adding extra configuration
}

async $onVerify(@Req() request: Req, @Args() [email, password]: [string, string]) {
return this.authService.emailSignIn(CommonUtils.buildModel(EmailSignInRequest, { email, password }));
async $onVerify(@Req() request: Req, @Args() [email, password]: [string, string]): Promise<TokensResponse> {
const tokens = await this.authService.emailSignIn(CommonUtils.buildModel(EmailSignInRequest, { email, password }));
this.authService.setRefreshCookie(request, tokens.refresh);
return CommonUtils.buildModel(TokensResponse, tokens);
}
}
9 changes: 6 additions & 3 deletions api/authentication/src/auth/protocols/EmailSignUpProtocol.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { CommonUtils } from '@hikers-book/tsed-common/utils';
import { BodyParams, Req } from '@tsed/common';
import { BadRequest } from '@tsed/exceptions';
import { OnInstall, OnVerify, Protocol } from '@tsed/passport';
import { IStrategyOptions, Strategy } from 'passport-local';
import { EmailVerifyTokenHandler } from '../handlers';
import { EmailSignUpRequest } from '../models';
import { EmailSignUpRequest, TokensResponse } from '../models';
import { ProtocolAuthService } from '../services/ProtocolAuthService';

@Protocol<IStrategyOptions>({
Expand All @@ -25,13 +26,15 @@ export class EmailSignUpProtocol implements OnVerify, OnInstall {
// intercept the strategy instance to adding extra configuration
}

async $onVerify(@Req() request: Req, @BodyParams() body: EmailSignUpRequest) {
async $onVerify(@Req() request: Req, @BodyParams() body: EmailSignUpRequest): Promise<TokensResponse> {
if (body.password !== body.password_confirm) {
throw new BadRequest('Passwords do not match!');
}

await this.verifyTokenHandler.execute({ email: body.email, token: body.token });

return this.authService.emailSignUp(body);
const tokens = await this.authService.emailSignUp(body);
this.authService.setRefreshCookie(request, tokens.refresh);
return CommonUtils.buildModel(TokensResponse, tokens);
}
}
36 changes: 29 additions & 7 deletions api/authentication/src/auth/services/ProtocolAuthService.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { PlatformTest } from '@tsed/common';
import { Forbidden, UnprocessableEntity } from '@tsed/exceptions';
import ms from 'ms';
import { ConfigService } from '../../global/services/ConfigService';
import { TestAuthenticationApiContext } from '../../test/TestAuthenticationApiContext';
import { AuthProviderEnum } from '../enums';
import { CredentialsAlreadyExist } from '../exceptions';
import { CredentialsMapper } from '../mappers/CredentialsMapper';
import { Credentials, EmailSignInRequest, EmailSignUpRequest, TokensResponse, User } from '../models';
import { Cookies, Credentials, EmailSignInRequest, EmailSignUpRequest, Tokens, User } from '../models';
import {
CredentialsStub,
CredentialsStubPopulated,
Expand Down Expand Up @@ -284,19 +285,40 @@ describe('ProtocolAuthService', () => {

describe('redirectOAuth2Success', () => {
it('Should call res.redirect()', async () => {
const spy = jest.fn();
const redirect = jest.fn();
const cookie = jest.spyOn(service, 'setRefreshCookie').mockImplementation();
const request = { res: { redirect } };

expect.assertions(1);
expect.assertions(2);

// @ts-expect-error types
await service.redirectOAuth2Success({ res: { redirect: spy } }, TokensStub);
await service.redirectOAuth2Success(request, TokensStub);

expect(spy).toHaveBeenCalledWith(
`${configService.config.frontend.url}/auth/callback?access=${TokensStub.access}&refresh=${TokensStub.refresh}`
expect(cookie).toHaveBeenCalledWith(request, TokensStub.refresh);
expect(redirect).toHaveBeenCalledWith(
`${configService.config.frontend.url}/auth/callback?access=${TokensStub.access}`
);
});
});

describe('setRefreshCookie', () => {
it('Should call res.cookie()', async () => {
const cookie = jest.fn();

expect.assertions(1);

// @ts-expect-error types
await service.setRefreshCookie({ res: { cookie } }, TokensStub.refresh);

expect(cookie).toHaveBeenCalledWith(Cookies.Refresh, TokensStub.refresh, {
httpOnly: true,
secure: true,
sameSite: 'none',
maxAge: ms(configService.config.jwt.expiresInRefresh)
});
});
});

describe('redirectOAuth2Failure', () => {
it('Should call res.redirect()', async () => {
const spy = jest.fn();
Expand Down Expand Up @@ -558,7 +580,7 @@ describe('ProtocolAuthService', () => {
// @ts-expect-error private
const tokens = await service.createJWT(CredentialsStubPopulated);

expect(tokens).toBeInstanceOf(TokensResponse);
expect(tokens).toBeInstanceOf(Tokens);
expect(tokens).toEqual({ access: 'access', refresh: 'refresh' });
});

Expand Down
36 changes: 22 additions & 14 deletions api/authentication/src/auth/services/ProtocolAuthService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ import { CommonUtils } from '@hikers-book/tsed-common/utils';
import { Req } from '@tsed/common';
import { Service } from '@tsed/di';
import { ClientException, Forbidden, UnprocessableEntity } from '@tsed/exceptions';
import ms from 'ms';
import { Profile as FacebookProfile } from 'passport-facebook';
import { Profile as GithubProfile } from 'passport-github2';
import { Profile as GoogleProfile } from 'passport-google-oauth20';
import { ConfigService } from '../../global/services/ConfigService';
import { AuthProviderEnum } from '../enums';
import { CredentialsAlreadyExist } from '../exceptions';
import { CredentialsMapper } from '../mappers/CredentialsMapper';
import { Credentials, EmailSignInRequest, EmailSignUpRequest, User } from '../models';
import { TokensResponse } from '../models/auth/TokensResponse';
import { Cookies, Credentials, EmailSignInRequest, EmailSignUpRequest, Tokens, User } from '../models';
import { AuthProviderPair, OAuth2ProviderPair } from '../types';
import { CryptographyUtils } from '../utils/CryptographyUtils';
import { JWTService } from './JWTService';
Expand All @@ -31,21 +31,21 @@ export class ProtocolAuthService {
) {}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
public async facebook(profile: FacebookProfile, accessToken: string, refreshToken: string): Promise<TokensResponse> {
public async facebook(profile: FacebookProfile, accessToken: string, refreshToken: string): Promise<Tokens> {
return this.handleOAuth2({ provider: AuthProviderEnum.FACEBOOK, profile });
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
public async github(profile: GithubProfile, accessToken: string, refreshToken: string): Promise<TokensResponse> {
public async github(profile: GithubProfile, accessToken: string, refreshToken: string): Promise<Tokens> {
return this.handleOAuth2({ provider: AuthProviderEnum.GITHUB, profile });
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
public async google(profile: GoogleProfile, accessToken: string, refreshToken: string): Promise<TokensResponse> {
public async google(profile: GoogleProfile, accessToken: string, refreshToken: string): Promise<Tokens> {
return this.handleOAuth2({ provider: AuthProviderEnum.GOOGLE, profile });
}

public async emailSignUp(profile: EmailSignUpRequest): Promise<TokensResponse> {
public async emailSignUp(profile: EmailSignUpRequest): Promise<Tokens> {
const credentials = await this.credentials.findManyByEmail(profile.email);

if (credentials.length === 0) {
Expand All @@ -61,7 +61,7 @@ export class ProtocolAuthService {
}
}

public async emailSignIn(request: EmailSignInRequest): Promise<TokensResponse> {
public async emailSignIn(request: EmailSignInRequest): Promise<Tokens> {
const credentials = await this.credentials.findByEmailAndProvider(request.email, AuthProviderEnum.EMAIL);

if (!credentials) {
Expand All @@ -75,10 +75,18 @@ export class ProtocolAuthService {
return this.createJWT(credentials);
}

public redirectOAuth2Success(request: Req, tokens: TokensResponse): void {
return request.res?.redirect(
`${this.configService.config.frontend.url}/auth/callback?access=${tokens.access}&refresh=${tokens.refresh}`
);
public redirectOAuth2Success(request: Req, tokens: Tokens): void {
this.setRefreshCookie(request, tokens.refresh);
return request.res?.redirect(`${this.configService.config.frontend.url}/auth/callback?access=${tokens.access}`);
}

public setRefreshCookie(request: Req, refresh: string): void {
request.res?.cookie(Cookies.Refresh, refresh, {
httpOnly: true,
secure: true,
sameSite: 'none',
maxAge: ms(this.configService.config.jwt.expiresInRefresh)
});
}

public redirectOAuth2Failure(request: Req, error: ClientException): void {
Expand All @@ -89,7 +97,7 @@ export class ProtocolAuthService {
);
}

private async handleOAuth2(data: OAuth2ProviderPair): Promise<TokensResponse> {
private async handleOAuth2(data: OAuth2ProviderPair): Promise<Tokens> {
const { provider, profile } = data;

const email = this.getEmailFromOAuth2Profile(data);
Expand Down Expand Up @@ -122,7 +130,7 @@ export class ProtocolAuthService {
return (await this.credentials.findById(created.id)) as Credentials;
}

private async createJWT(credentials: Credentials): Promise<TokensResponse> {
private async createJWT(credentials: Credentials): Promise<Tokens> {
if (!credentials.user) {
throw new UnprocessableEntity('Cannot generate JWT.');
}
Expand All @@ -137,7 +145,7 @@ export class ProtocolAuthService {
name: credentials.user.full_name
});

return CommonUtils.buildModel(TokensResponse, {
return CommonUtils.buildModel(Tokens, {
access,
refresh
});
Expand Down
4 changes: 2 additions & 2 deletions api/authentication/src/auth/test/stubs/Auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { CommonUtils } from '@hikers-book/tsed-common/utils';
import { Profile as FacebookProfile } from 'passport-facebook';
import { Profile as GithubProfile } from 'passport-github2';
import { Profile as GoogleProfile } from 'passport-google-oauth20';
import { EmailSignUpRequest, TokensResponse } from '../../models';
import { EmailSignUpRequest, Tokens } from '../../models';

export const ProfileFacebookStub: FacebookProfile = {
id: 'id',
Expand Down Expand Up @@ -40,7 +40,7 @@ export const ProfileEmailStub: EmailSignUpRequest = {
full_name: 'Tester'
};

export const TokensStub = CommonUtils.buildModel(TokensResponse, {
export const TokensStub: Tokens = CommonUtils.buildModel(Tokens, {
access: 'access',
refresh: 'refresh'
});
10 changes: 10 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 05c50a8

Please sign in to comment.