From 60cf156230e47bc395570a9fea639d7ac175b4cc Mon Sep 17 00:00:00 2001 From: thibaud-leclere Date: Wed, 6 May 2026 08:31:39 +0200 Subject: [PATCH] feat: expose local auth endpoints --- .../src/auth/auth.controller.spec.ts | 144 ++++++++++++++++++ .../src/auth/auth.controller.ts | 35 +++++ 2 files changed, 179 insertions(+) create mode 100644 packages/hoppscotch-backend/src/auth/auth.controller.spec.ts diff --git a/packages/hoppscotch-backend/src/auth/auth.controller.spec.ts b/packages/hoppscotch-backend/src/auth/auth.controller.spec.ts new file mode 100644 index 00000000..0ab61bdf --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/auth.controller.spec.ts @@ -0,0 +1,144 @@ +import { ConfigService } from '@nestjs/config'; +import { mockDeep, mockReset } from 'jest-mock-extended'; +import * as E from 'fp-ts/Either'; +import { Response } from 'express'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { LocalAuthService } from './local-auth.service'; +import { AuthUser } from 'src/types/AuthUser'; + +const mockAuthService = mockDeep(); +const mockConfigService = mockDeep(); +const mockLocalAuthService = mockDeep(); + +const authController = new AuthController( + mockAuthService, + mockConfigService, + mockLocalAuthService, +); + +const currentTime = new Date(); +const adminUser: AuthUser = { + uid: 'admin-1', + username: 'admin', + email: null, + displayName: 'Admin', + photoURL: null, + isAdmin: true, + refreshToken: null, + lastLoggedOn: currentTime, + lastActiveOn: currentTime, + createdOn: currentTime, + currentGQLSession: null, + currentRESTSession: null, +}; + +function createMockResponse() { + const res = { + cookie: jest.fn(), + status: jest.fn().mockReturnThis(), + send: jest.fn(), + }; + + return res as unknown as Response & typeof res; +} + +describe('AuthController local auth', () => { + beforeEach(() => { + mockReset(mockAuthService); + mockReset(mockConfigService); + mockReset(mockLocalAuthService); + mockConfigService.get.mockImplementation((key: string) => { + if (key === 'INFRA.ACCESS_TOKEN_VALIDITY') return '86400000'; + if (key === 'INFRA.REFRESH_TOKEN_VALIDITY') return '604800000'; + if (key === 'INFRA.ALLOW_SECURE_COOKIES') return 'false'; + return null; + }); + }); + + it('sets auth cookies after local signin succeeds', async () => { + const res = createMockResponse(); + mockLocalAuthService.signIn.mockResolvedValue( + E.right({ + access_token: 'access-token', + refresh_token: 'refresh-token', + }), + ); + + await authController.signInLocal( + { + username: 'admin', + password: 'strong-password', + }, + res, + ); + + expect(mockLocalAuthService.signIn).toHaveBeenCalledWith({ + username: 'admin', + password: 'strong-password', + }); + expect(res.cookie).toHaveBeenCalledTimes(2); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('sets auth cookies after local setup admin succeeds', async () => { + const res = createMockResponse(); + mockLocalAuthService.setupFirstAdmin.mockResolvedValue( + E.right({ + access_token: 'access-token', + refresh_token: 'refresh-token', + }), + ); + + await authController.setupLocalAdmin( + { + username: 'admin', + password: 'strong-password', + }, + res, + ); + + expect(mockLocalAuthService.setupFirstAdmin).toHaveBeenCalledWith({ + username: 'admin', + password: 'strong-password', + }); + expect(res.cookie).toHaveBeenCalledTimes(2); + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('delegates local user creation to the local auth service', async () => { + mockLocalAuthService.createLocalUser.mockResolvedValue( + E.right({ + uid: 'user-1', + username: 'dwight', + displayName: 'Dwight Schrute', + email: null, + photoURL: null, + isAdmin: false, + }), + ); + + const result = await authController.createLocalUser(adminUser, { + username: 'dwight', + password: 'strong-password', + displayName: 'Dwight Schrute', + }); + + expect(mockLocalAuthService.createLocalUser).toHaveBeenCalledWith( + { + username: 'dwight', + password: 'strong-password', + displayName: 'Dwight Schrute', + }, + adminUser, + ); + expect(result).toEqual({ + uid: 'user-1', + username: 'dwight', + displayName: 'Dwight Schrute', + email: null, + photoURL: null, + isAdmin: false, + }); + }); +}); diff --git a/packages/hoppscotch-backend/src/auth/auth.controller.ts b/packages/hoppscotch-backend/src/auth/auth.controller.ts index 94fbe553..4d3e53dc 100644 --- a/packages/hoppscotch-backend/src/auth/auth.controller.ts +++ b/packages/hoppscotch-backend/src/auth/auth.controller.ts @@ -30,6 +30,12 @@ import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors'; import { ConfigService } from '@nestjs/config'; import { throwHTTPErr } from 'src/utils'; import { UserLastLoginInterceptor } from 'src/interceptors/user-last-login.interceptor'; +import { + CreateLocalUserDto, + LocalSetupAdminDto, + LocalSignInDto, +} from './dto/local-auth.dto'; +import { LocalAuthService } from './local-auth.service'; @UseGuards(ThrottlerBehindProxyGuard) @Controller({ path: 'auth', version: '1' }) @@ -37,6 +43,7 @@ export class AuthController { constructor( private authService: AuthService, private configService: ConfigService, + private localAuthService: LocalAuthService, ) {} @Get('providers') @@ -80,6 +87,34 @@ export class AuthController { authCookieHandler(res, authTokens.right, false, null, this.configService); } + @Post('local/signin') + async signInLocal(@Body() data: LocalSignInDto, @Res() res: Response) { + const authTokens = await this.localAuthService.signIn(data); + if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left); + authCookieHandler(res, authTokens.right, false, null, this.configService); + } + + @Post('local/setup-admin') + async setupLocalAdmin( + @Body() data: LocalSetupAdminDto, + @Res() res: Response, + ) { + const authTokens = await this.localAuthService.setupFirstAdmin(data); + if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left); + authCookieHandler(res, authTokens.right, false, null, this.configService); + } + + @Post('local/users') + @UseGuards(JwtAuthGuard) + async createLocalUser( + @GqlUser() user: AuthUser, + @Body() data: CreateLocalUserDto, + ) { + const createdUser = await this.localAuthService.createLocalUser(data, user); + if (E.isLeft(createdUser)) throwHTTPErr(createdUser.left); + return createdUser.right; + } + /** ** Route to refresh auth tokens with Refresh Token Rotation * @see https://auth0.com/docs/secure/tokens/refresh-tokens/refresh-token-rotation