diff --git a/api/authentication/package.json b/api/authentication/package.json index bb40dc6..2e013aa 100644 --- a/api/authentication/package.json +++ b/api/authentication/package.json @@ -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", @@ -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", diff --git a/api/authentication/src/auth/controllers/EmailController.integration.spec.ts b/api/authentication/src/auth/controllers/EmailController.integration.spec.ts index 16beb5e..2904c61 100644 --- a/api/authentication/src/auth/controllers/EmailController.integration.spec.ts +++ b/api/authentication/src/auth/controllers/EmailController.integration.spec.ts @@ -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'; @@ -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', () => { @@ -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'); @@ -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); + }); }); }); diff --git a/api/authentication/src/auth/models/Cookies.ts b/api/authentication/src/auth/models/Cookies.ts new file mode 100644 index 0000000..833382a --- /dev/null +++ b/api/authentication/src/auth/models/Cookies.ts @@ -0,0 +1,3 @@ +export enum Cookies { + Refresh = 'REFRESH' +} diff --git a/api/authentication/src/auth/models/auth/Tokens.ts b/api/authentication/src/auth/models/auth/Tokens.ts new file mode 100644 index 0000000..1aaeea7 --- /dev/null +++ b/api/authentication/src/auth/models/auth/Tokens.ts @@ -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; +} diff --git a/api/authentication/src/auth/models/auth/TokensResponse.ts b/api/authentication/src/auth/models/auth/TokensResponse.ts index b729468..48fe85c 100644 --- a/api/authentication/src/auth/models/auth/TokensResponse.ts +++ b/api/authentication/src/auth/models/auth/TokensResponse.ts @@ -6,9 +6,4 @@ export class TokensResponse { @Description('JWT access token.') @Required() access!: string; - - @Title('refresh') - @Description('JWT refresh token.') - @Required() - refresh!: string; } diff --git a/api/authentication/src/auth/models/index.ts b/api/authentication/src/auth/models/index.ts index 75504d4..73807b3 100644 --- a/api/authentication/src/auth/models/index.ts +++ b/api/authentication/src/auth/models/index.ts @@ -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'; diff --git a/api/authentication/src/auth/protocols/EmailSignInProtocol.ts b/api/authentication/src/auth/protocols/EmailSignInProtocol.ts index be7e46c..59f6bd0 100644 --- a/api/authentication/src/auth/protocols/EmailSignInProtocol.ts +++ b/api/authentication/src/auth/protocols/EmailSignInProtocol.ts @@ -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({ @@ -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 { + const tokens = await this.authService.emailSignIn(CommonUtils.buildModel(EmailSignInRequest, { email, password })); + this.authService.setRefreshCookie(request, tokens.refresh); + return CommonUtils.buildModel(TokensResponse, tokens); } } diff --git a/api/authentication/src/auth/protocols/EmailSignUpProtocol.ts b/api/authentication/src/auth/protocols/EmailSignUpProtocol.ts index 5bfbe31..aaf0775 100644 --- a/api/authentication/src/auth/protocols/EmailSignUpProtocol.ts +++ b/api/authentication/src/auth/protocols/EmailSignUpProtocol.ts @@ -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({ @@ -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 { 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); } } diff --git a/api/authentication/src/auth/services/ProtocolAuthService.spec.ts b/api/authentication/src/auth/services/ProtocolAuthService.spec.ts index fb7eb6f..414818a 100644 --- a/api/authentication/src/auth/services/ProtocolAuthService.spec.ts +++ b/api/authentication/src/auth/services/ProtocolAuthService.spec.ts @@ -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, @@ -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(); @@ -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' }); }); diff --git a/api/authentication/src/auth/services/ProtocolAuthService.ts b/api/authentication/src/auth/services/ProtocolAuthService.ts index 3b7f6b7..56136c0 100644 --- a/api/authentication/src/auth/services/ProtocolAuthService.ts +++ b/api/authentication/src/auth/services/ProtocolAuthService.ts @@ -2,6 +2,7 @@ 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'; @@ -9,8 +10,7 @@ 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'; @@ -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 { + public async facebook(profile: FacebookProfile, accessToken: string, refreshToken: string): Promise { 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 { + public async github(profile: GithubProfile, accessToken: string, refreshToken: string): Promise { 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 { + public async google(profile: GoogleProfile, accessToken: string, refreshToken: string): Promise { return this.handleOAuth2({ provider: AuthProviderEnum.GOOGLE, profile }); } - public async emailSignUp(profile: EmailSignUpRequest): Promise { + public async emailSignUp(profile: EmailSignUpRequest): Promise { const credentials = await this.credentials.findManyByEmail(profile.email); if (credentials.length === 0) { @@ -61,7 +61,7 @@ export class ProtocolAuthService { } } - public async emailSignIn(request: EmailSignInRequest): Promise { + public async emailSignIn(request: EmailSignInRequest): Promise { const credentials = await this.credentials.findByEmailAndProvider(request.email, AuthProviderEnum.EMAIL); if (!credentials) { @@ -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 { @@ -89,7 +97,7 @@ export class ProtocolAuthService { ); } - private async handleOAuth2(data: OAuth2ProviderPair): Promise { + private async handleOAuth2(data: OAuth2ProviderPair): Promise { const { provider, profile } = data; const email = this.getEmailFromOAuth2Profile(data); @@ -122,7 +130,7 @@ export class ProtocolAuthService { return (await this.credentials.findById(created.id)) as Credentials; } - private async createJWT(credentials: Credentials): Promise { + private async createJWT(credentials: Credentials): Promise { if (!credentials.user) { throw new UnprocessableEntity('Cannot generate JWT.'); } @@ -137,7 +145,7 @@ export class ProtocolAuthService { name: credentials.user.full_name }); - return CommonUtils.buildModel(TokensResponse, { + return CommonUtils.buildModel(Tokens, { access, refresh }); diff --git a/api/authentication/src/auth/test/stubs/Auth.ts b/api/authentication/src/auth/test/stubs/Auth.ts index 88bc7f7..85ad048 100644 --- a/api/authentication/src/auth/test/stubs/Auth.ts +++ b/api/authentication/src/auth/test/stubs/Auth.ts @@ -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', @@ -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' }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1cabb48..c6276ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -143,6 +143,9 @@ importers: mongoose: specifier: ^8.0.0 version: 8.0.0 + ms: + specifier: ^2.1.3 + version: 2.1.3 nodemailer: specifier: ^6.9.7 version: 6.9.7 @@ -225,6 +228,9 @@ importers: '@types/method-override': specifier: ^0.0.34 version: 0.0.34 + '@types/ms': + specifier: ^0.7.34 + version: 0.7.34 '@types/multer': specifier: ^1.4.9 version: 1.4.9 @@ -8632,6 +8638,10 @@ packages: resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==} dev: false + /@types/ms@0.7.34: + resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} + dev: true + /@types/multer@1.4.9: resolution: {integrity: sha512-9NSvPJ2E8bNTc8XtJq1Cimx2Wrn2Ah48F15B2Du/hM8a8CHLhVbJMlF3ZCqhvMdht7Sa+YdP0aKP7N4fxDcrrg==} dependencies: diff --git a/ui/hikers-book/src/app/core/services/authentication.service.ts b/ui/hikers-book/src/app/core/services/authentication.service.ts index 07fcbbe..5138b05 100644 --- a/ui/hikers-book/src/app/core/services/authentication.service.ts +++ b/ui/hikers-book/src/app/core/services/authentication.service.ts @@ -41,11 +41,11 @@ export class AuthenticationService { window.open(`${this.config.config.api.authentication}/auth/provider/facebook`, '_self', 'height=600,width=450'); } - public authenticate(access: string, refresh: string): void { + public authenticate(access: string): void { this.#isLoggedIn = true; this.#authErrorCode = undefined; sessionStorage.setItem('access', access); - sessionStorage.setItem('refresh', refresh); + // sessionStorage.setItem('refresh', refresh); this.router.navigate(['/']); } @@ -53,7 +53,7 @@ export class AuthenticationService { this.#isLoggedIn = false; this.#authErrorCode = undefined; sessionStorage.removeItem('access'); - sessionStorage.removeItem('refresh'); + // sessionStorage.removeItem('refresh'); this.router.navigate(['/auth/sign-in']); } @@ -61,15 +61,15 @@ export class AuthenticationService { return sessionStorage.getItem('access'); } - public getRefreshToken() { - return sessionStorage.getItem('refresh'); - } + // public getRefreshToken() { + // return sessionStorage.getItem('refresh'); + // } private getTokens(): void { const access = this.getToken(); - const refresh = this.getRefreshToken(); + // const refresh = this.getRefreshToken(); - this.#isLoggedIn = !!access && !!refresh; + this.#isLoggedIn = !!access; this.#authErrorCode = undefined; } } diff --git a/ui/hikers-book/src/app/modules/auth/callback/callback.component.ts b/ui/hikers-book/src/app/modules/auth/callback/callback.component.ts index 7dbaa2e..cf57189 100644 --- a/ui/hikers-book/src/app/modules/auth/callback/callback.component.ts +++ b/ui/hikers-book/src/app/modules/auth/callback/callback.component.ts @@ -15,8 +15,8 @@ export class CallbackComponent implements OnInit { ngOnInit(): void { this.route.queryParams.subscribe((params) => { - const tokens: { access: string; refresh: string } = params as { access: string; refresh: string }; - this.authenticationService.authenticate(tokens.access, tokens.refresh); + const tokens: { access: string } = params as { access: string }; + this.authenticationService.authenticate(tokens.access); }); } }