From 9bb5d3fce2af07af5f74482157fba1319a51604b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20=C3=96hlerking?= <103562092+MarvinOehlerkingCap@users.noreply.github.com> Date: Tue, 12 Nov 2024 14:14:27 +0100 Subject: [PATCH] N21-2179 Logout by external systems via oidc (#5264) --- .../authentication-api-test.module.ts | 3 +- .../authentication-api.module.ts | 3 +- .../authentication-test.module.ts | 6 +- .../authentication/authentication.module.ts | 6 +- .../controllers/api-test/logout.api.spec.ts | 58 ++++ .../authentication/controllers/dto/index.ts | 1 + .../dto/oidc-logout-body.params.ts | 8 + .../controllers/logout.controller.ts | 16 +- .../helper/jwt-whitelist.adapter.spec.ts | 36 +- .../helper/jwt-whitelist.adapter.ts | 18 +- ...system-mismatch.loggable-exception.spec.ts | 34 ++ ...ount-system-mismatch.loggable-exception.ts | 21 ++ .../modules/authentication/loggable/index.ts | 2 + .../invalid-token.loggable-exception.spec.ts | 24 ++ .../invalid-token.loggable-exception.ts | 12 + .../services/authentication.service.spec.ts | 16 +- .../services/authentication.service.ts | 4 + .../modules/authentication/services/index.ts | 1 + .../services/logout.service.spec.ts | 317 ++++++++++++++++++ .../authentication/services/logout.service.ts | 55 +++ .../authentication/uc/logout.uc.spec.ts | 78 ++++- .../modules/authentication/uc/logout.uc.ts | 25 +- .../oauth/service/oauth.service.spec.ts | 114 ++++++- .../modules/oauth/service/oauth.service.ts | 21 ++ .../domain/interface/system.repo.interface.ts | 2 + .../modules/system/entity/system.entity.ts | 9 +- .../system/repo/mikro-orm/system.repo.spec.ts | 35 +- .../system/repo/mikro-orm/system.repo.ts | 15 + .../system/service/system.service.spec.ts | 38 +++ .../modules/system/service/system.service.ts | 6 + .../domain/types/school-feature.enum.ts | 2 +- .../testing/factory/jwt.test.factory.ts | 25 +- .../testing/factory/systemEntityFactory.ts | 88 +++-- 33 files changed, 1039 insertions(+), 60 deletions(-) create mode 100644 apps/server/src/modules/authentication/controllers/dto/oidc-logout-body.params.ts create mode 100644 apps/server/src/modules/authentication/loggable/account-system-mismatch.loggable-exception.spec.ts create mode 100644 apps/server/src/modules/authentication/loggable/account-system-mismatch.loggable-exception.ts create mode 100644 apps/server/src/modules/authentication/loggable/invalid-token.loggable-exception.spec.ts create mode 100644 apps/server/src/modules/authentication/loggable/invalid-token.loggable-exception.ts create mode 100644 apps/server/src/modules/authentication/services/logout.service.spec.ts create mode 100644 apps/server/src/modules/authentication/services/logout.service.ts diff --git a/apps/server/src/modules/authentication/authentication-api-test.module.ts b/apps/server/src/modules/authentication/authentication-api-test.module.ts index 8f604ca3436..f56fcabdc1a 100644 --- a/apps/server/src/modules/authentication/authentication-api-test.module.ts +++ b/apps/server/src/modules/authentication/authentication-api-test.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; +import { LoggerModule } from '@src/core/logger'; import { SystemModule } from '@modules/system'; import { OauthModule } from '@modules/oauth'; import { AuthenticationTestModule } from './authentication-test.module'; @@ -7,7 +8,7 @@ import { LoginUc, LogoutUc } from './uc'; // This module is for use in api tests of other apps than the core server. @Module({ - imports: [AuthenticationTestModule, SystemModule, OauthModule], + imports: [AuthenticationTestModule, LoggerModule, SystemModule, OauthModule], providers: [LoginUc, LogoutUc], controllers: [LoginController, LogoutController], }) diff --git a/apps/server/src/modules/authentication/authentication-api.module.ts b/apps/server/src/modules/authentication/authentication-api.module.ts index 0645224f5dc..249f9ce2b88 100644 --- a/apps/server/src/modules/authentication/authentication-api.module.ts +++ b/apps/server/src/modules/authentication/authentication-api.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common'; import { SystemModule } from '@modules/system'; import { OauthModule } from '@modules/oauth/oauth.module'; +import { LoggerModule } from '@src/core/logger'; import { AuthenticationModule } from './authentication.module'; import { LoginController, LogoutController } from './controllers'; import { LoginUc, LogoutUc } from './uc'; @Module({ - imports: [AuthenticationModule, SystemModule, OauthModule], + imports: [AuthenticationModule, LoggerModule, SystemModule, OauthModule], providers: [LoginUc, LogoutUc], controllers: [LoginController, LogoutController], }) diff --git a/apps/server/src/modules/authentication/authentication-test.module.ts b/apps/server/src/modules/authentication/authentication-test.module.ts index d0cdf560f46..6a32df9c920 100644 --- a/apps/server/src/modules/authentication/authentication-test.module.ts +++ b/apps/server/src/modules/authentication/authentication-test.module.ts @@ -14,7 +14,9 @@ import { HttpModule } from '@nestjs/axios'; import { LegacySchoolRepo, UserRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; import { Algorithm, SignOptions } from 'jsonwebtoken'; +import { UserModule } from '../user'; import { JwtWhitelistAdapter } from './helper/jwt-whitelist.adapter'; +import { LogoutService } from './services'; import { AuthenticationService } from './services/authentication.service'; import { LdapService } from './services/ldap.service'; import { LdapStrategy } from './strategy/ldap.strategy'; @@ -61,6 +63,7 @@ const createJwtOptions = () => { IdentityManagementModule, CacheWrapperModule, AuthGuardModule.register([AuthGuardOptions.JWT]), + UserModule, HttpModule, EncryptionModule, ], @@ -73,7 +76,8 @@ const createJwtOptions = () => { LdapStrategy, Oauth2Strategy, JwtWhitelistAdapter, + LogoutService, ], - exports: [AuthenticationService], + exports: [AuthenticationService, LogoutService], }) export class AuthenticationTestModule {} diff --git a/apps/server/src/modules/authentication/authentication.module.ts b/apps/server/src/modules/authentication/authentication.module.ts index a2e495f7f8f..d910dfb1603 100644 --- a/apps/server/src/modules/authentication/authentication.module.ts +++ b/apps/server/src/modules/authentication/authentication.module.ts @@ -5,6 +5,7 @@ import { AccountModule } from '@modules/account'; import { OauthModule } from '@modules/oauth/oauth.module'; import { RoleModule } from '@modules/role'; import { SystemModule } from '@modules/system'; +import { UserModule } from '@modules/user'; import { Module } from '@nestjs/common'; import { HttpModule } from '@nestjs/axios'; import { ConfigService } from '@nestjs/config'; @@ -15,6 +16,7 @@ import { LoggerModule } from '@src/core/logger'; import { Algorithm, SignOptions } from 'jsonwebtoken'; import { AuthenticationConfig } from './authentication-config'; import { JwtWhitelistAdapter } from './helper/jwt-whitelist.adapter'; +import { LogoutService } from './services'; import { AuthenticationService } from './services/authentication.service'; import { LdapService } from './services/ldap.service'; import { LdapStrategy } from './strategy/ldap.strategy'; @@ -59,6 +61,7 @@ const createJwtOptions = (configService: ConfigService) => RoleModule, IdentityManagementModule, CacheWrapperModule, + UserModule, HttpModule, EncryptionModule, ], @@ -71,7 +74,8 @@ const createJwtOptions = (configService: ConfigService) => LdapStrategy, Oauth2Strategy, JwtWhitelistAdapter, + LogoutService, ], - exports: [AuthenticationService], + exports: [AuthenticationService, LogoutService], }) export class AuthenticationModule {} diff --git a/apps/server/src/modules/authentication/controllers/api-test/logout.api.spec.ts b/apps/server/src/modules/authentication/controllers/api-test/logout.api.spec.ts index 83b3d069184..a9b11a5dc8a 100644 --- a/apps/server/src/modules/authentication/controllers/api-test/logout.api.spec.ts +++ b/apps/server/src/modules/authentication/controllers/api-test/logout.api.spec.ts @@ -10,8 +10,11 @@ import { ExecutionContext, HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { cleanupCollections, + JwtTestFactory, currentUserFactory, + schoolEntityFactory, systemEntityFactory, + systemOauthConfigEntityFactory, TestApiClient, UserAndAccountTestFactory, } from '@shared/testing'; @@ -21,6 +24,19 @@ import { Request } from 'express'; import MockAdapter from 'axios-mock-adapter'; import axios from 'axios'; +jest.mock('jwks-rsa', () => () => { + return { + getKeys: jest.fn(), + getSigningKey: jest.fn().mockResolvedValue({ + kid: 'kid', + alg: 'RS256', + getPublicKey: jest.fn().mockReturnValue(JwtTestFactory.getPublicKey()), + rsaPublicKey: JwtTestFactory.getPublicKey(), + }), + getSigningKeys: jest.fn(), + }; +}); + describe('Logout Controller (api)', () => { const baseRouteName = '/logout'; @@ -85,6 +101,48 @@ describe('Logout Controller (api)', () => { }); }); + describe('logoutOidc', () => { + describe('when a valid logout token is provided', () => { + const setup = async () => { + const userExternalId = 'userExternalId'; + + const oauthConfigEntity = systemOauthConfigEntityFactory.build(); + const system = systemEntityFactory.withOauthConfig(oauthConfigEntity).buildWithId(); + + const school = schoolEntityFactory.buildWithId({ systems: [system] }); + const { studentAccount, studentUser } = UserAndAccountTestFactory.buildStudent({ + school, + externalId: userExternalId, + systemId: system.id, + }); + + await em.persistAndFlush([system, school, studentAccount, studentUser]); + em.clear(); + + const logoutToken = JwtTestFactory.createLogoutToken({ + sub: userExternalId, + iss: oauthConfigEntity.issuer, + aud: oauthConfigEntity.clientId, + }); + + return { + system, + logoutToken, + studentAccount, + }; + }; + + it('should log out the user', async () => { + const { logoutToken, studentAccount } = await setup(); + + const response: Response = await testApiClient.post('/oidc', { logout_token: logoutToken }); + + expect(response.status).toEqual(HttpStatus.OK); + expect(await cacheManager.store.keys(`jwt:${studentAccount.id}:*`)).toHaveLength(0); + }); + }); + }); + describe('externalSystemLogout', () => { let currentUser: ICurrentUser; diff --git a/apps/server/src/modules/authentication/controllers/dto/index.ts b/apps/server/src/modules/authentication/controllers/dto/index.ts index 69c69055f74..402c79bae8e 100644 --- a/apps/server/src/modules/authentication/controllers/dto/index.ts +++ b/apps/server/src/modules/authentication/controllers/dto/index.ts @@ -3,3 +3,4 @@ export * from './login.response'; export * from './ldap-authorization.body.params'; export * from './local-authorization.body.params'; export * from './oauth-login.response'; +export { OidcLogoutBodyParams } from './oidc-logout-body.params'; diff --git a/apps/server/src/modules/authentication/controllers/dto/oidc-logout-body.params.ts b/apps/server/src/modules/authentication/controllers/dto/oidc-logout-body.params.ts new file mode 100644 index 00000000000..33767949260 --- /dev/null +++ b/apps/server/src/modules/authentication/controllers/dto/oidc-logout-body.params.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +export class OidcLogoutBodyParams { + @IsString() + @ApiProperty() + logout_token!: string; +} diff --git a/apps/server/src/modules/authentication/controllers/logout.controller.ts b/apps/server/src/modules/authentication/controllers/logout.controller.ts index ff232e894fd..50bec7f73ec 100644 --- a/apps/server/src/modules/authentication/controllers/logout.controller.ts +++ b/apps/server/src/modules/authentication/controllers/logout.controller.ts @@ -1,5 +1,5 @@ import { CurrentUser, ICurrentUser, JWT, JwtAuthentication } from '@infra/auth-guard'; -import { Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; +import { Body, Controller, Header, HttpCode, HttpStatus, Post } from '@nestjs/common'; import { ApiForbiddenResponse, ApiInternalServerErrorResponse, @@ -9,6 +9,7 @@ import { ApiUnauthorizedResponse, } from '@nestjs/swagger'; import { LogoutUc } from '../uc'; +import { OidcLogoutBodyParams } from './dto'; @ApiTags('Authentication') @Controller('logout') @@ -25,6 +26,19 @@ export class LogoutController { await this.logoutUc.logout(jwt); } + /** + * @see https://openid.net/specs/openid-connect-backchannel-1_0.html + */ + @HttpCode(HttpStatus.OK) + @Header('Cache-Control', 'no-store') + @Post('oidc') + @ApiOperation({ summary: 'Logs out a user for a given logout token from an external oidc system.' }) + @ApiOkResponse({ description: 'Logout was successful.' }) + @ApiUnauthorizedResponse({ description: 'There has been an error while logging out.' }) + async logoutOidc(@Body() body: OidcLogoutBodyParams): Promise { + await this.logoutUc.logoutOidc(body.logout_token); + } + @JwtAuthentication() @Post('/external') @HttpCode(HttpStatus.OK) diff --git a/apps/server/src/modules/authentication/helper/jwt-whitelist.adapter.spec.ts b/apps/server/src/modules/authentication/helper/jwt-whitelist.adapter.spec.ts index 471584ea96c..b2e5cd5fa64 100644 --- a/apps/server/src/modules/authentication/helper/jwt-whitelist.adapter.spec.ts +++ b/apps/server/src/modules/authentication/helper/jwt-whitelist.adapter.spec.ts @@ -3,14 +3,15 @@ import { Configuration } from '@hpi-schul-cloud/commons/lib'; import { ObjectId } from '@mikro-orm/mongodb'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Test, TestingModule } from '@nestjs/testing'; -import { Cache } from 'cache-manager'; +import { Cache, Store } from 'cache-manager'; import { JwtWhitelistAdapter } from './jwt-whitelist.adapter'; -describe('jwt strategy', () => { +describe(JwtWhitelistAdapter.name, () => { let module: TestingModule; let jwtWhitelistAdapter: JwtWhitelistAdapter; let cacheManager: DeepMocked; + let store: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ @@ -18,7 +19,9 @@ describe('jwt strategy', () => { JwtWhitelistAdapter, { provide: CACHE_MANAGER, - useValue: createMock(), + useValue: createMock({ + store: (store = createMock()), + }), }, ], }).compile(); @@ -81,7 +84,7 @@ describe('jwt strategy', () => { }; }; - it('should call the cache manager to jwt the entry from the cache', async () => { + it('should call the cache manager to remove the jwt from the cache', async () => { const { accountId, jti } = setup(); await jwtWhitelistAdapter.removeFromWhitelist(accountId, jti); @@ -89,5 +92,30 @@ describe('jwt strategy', () => { expect(cacheManager.del).toHaveBeenCalledWith(`jwt:${accountId}:${jti}`); }); }); + + describe('when removing a token from the whitelist', () => { + const setup = () => { + const accountId = new ObjectId().toHexString(); + const jwtKey1 = `jwt:${accountId}:jti1`; + const jwtKey2 = `jwt:${accountId}:jti2`; + + store.keys.mockResolvedValueOnce([jwtKey1, jwtKey2]); + + return { + accountId, + jwtKey1, + jwtKey2, + }; + }; + + it('should call the cache manager to delete all jwt entries from the cache', async () => { + const { accountId, jwtKey1, jwtKey2 } = setup(); + + await jwtWhitelistAdapter.removeFromWhitelist(accountId); + + expect(cacheManager.del).toHaveBeenNthCalledWith(1, jwtKey1); + expect(cacheManager.del).toHaveBeenNthCalledWith(2, jwtKey2); + }); + }); }); }); diff --git a/apps/server/src/modules/authentication/helper/jwt-whitelist.adapter.ts b/apps/server/src/modules/authentication/helper/jwt-whitelist.adapter.ts index c7568545906..8afa48e0131 100644 --- a/apps/server/src/modules/authentication/helper/jwt-whitelist.adapter.ts +++ b/apps/server/src/modules/authentication/helper/jwt-whitelist.adapter.ts @@ -15,9 +15,21 @@ export class JwtWhitelistAdapter { await this.cacheManager.set(redisIdentifier, redisData, expirationInMilliseconds); } - async removeFromWhitelist(accountId: string, jti: string): Promise { - const redisIdentifier: string = createRedisIdentifierFromJwtData(accountId, jti); + async removeFromWhitelist(accountId: string, jti?: string): Promise { + let keys: string[]; + + if (jti) { + const redisIdentifier: string = createRedisIdentifierFromJwtData(accountId, jti); + keys = [redisIdentifier]; + } else { + const redisIdentifier: string = createRedisIdentifierFromJwtData(accountId, '*'); + keys = await this.cacheManager.store.keys(redisIdentifier); + } + + const deleteKeysPromise: Promise[] = keys.map( + async (key: string): Promise => this.cacheManager.del(key) + ); - await this.cacheManager.del(redisIdentifier); + await Promise.all(deleteKeysPromise); } } diff --git a/apps/server/src/modules/authentication/loggable/account-system-mismatch.loggable-exception.spec.ts b/apps/server/src/modules/authentication/loggable/account-system-mismatch.loggable-exception.spec.ts new file mode 100644 index 00000000000..2b787fbef90 --- /dev/null +++ b/apps/server/src/modules/authentication/loggable/account-system-mismatch.loggable-exception.spec.ts @@ -0,0 +1,34 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { AccountSystemMismatchLoggableException } from './account-system-mismatch.loggable-exception'; + +describe(AccountSystemMismatchLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const expected = new ObjectId().toHexString(); + const received = new ObjectId().toHexString(); + + const exception = new AccountSystemMismatchLoggableException(expected, received); + + return { + exception, + expected, + received, + }; + }; + + it('should return the correct log message', () => { + const { exception, expected, received } = setup(); + + const message = exception.getLogMessage(); + + expect(message).toEqual({ + type: 'LOGIN_SYSTEM_MISMATCH', + stack: expect.any(String), + data: { + expected, + received, + }, + }); + }); + }); +}); diff --git a/apps/server/src/modules/authentication/loggable/account-system-mismatch.loggable-exception.ts b/apps/server/src/modules/authentication/loggable/account-system-mismatch.loggable-exception.ts new file mode 100644 index 00000000000..525abe10a77 --- /dev/null +++ b/apps/server/src/modules/authentication/loggable/account-system-mismatch.loggable-exception.ts @@ -0,0 +1,21 @@ +import { BadRequestException } from '@nestjs/common'; +import { EntityId } from '@shared/domain/types'; +import { Loggable } from '@src/core/logger/interfaces'; +import { ErrorLogMessage } from '@src/core/logger/types'; + +export class AccountSystemMismatchLoggableException extends BadRequestException implements Loggable { + constructor(private readonly expectedSystemId?: EntityId, private readonly receivedSystemId?: EntityId) { + super(); + } + + getLogMessage(): ErrorLogMessage { + return { + type: 'LOGIN_SYSTEM_MISMATCH', + stack: this.stack, + data: { + expected: this.expectedSystemId, + received: this.receivedSystemId, + }, + }; + } +} diff --git a/apps/server/src/modules/authentication/loggable/index.ts b/apps/server/src/modules/authentication/loggable/index.ts index f6635c532a4..20139237cb8 100644 --- a/apps/server/src/modules/authentication/loggable/index.ts +++ b/apps/server/src/modules/authentication/loggable/index.ts @@ -2,5 +2,7 @@ export * from './account-not-found.loggable-exception'; export * from './school-in-migration.loggable-exception'; export * from './shd-user-create-token.loggable'; export * from './user-authenticated.loggable'; +export { InvalidTokenLoggableException } from './invalid-token.loggable-exception'; +export { AccountSystemMismatchLoggableException } from './account-system-mismatch.loggable-exception'; export * from './user-could-not-be-authenticated.loggable.exception'; export { UserAccountDeactivatedLoggableException } from './user-account-deactivated-exception'; diff --git a/apps/server/src/modules/authentication/loggable/invalid-token.loggable-exception.spec.ts b/apps/server/src/modules/authentication/loggable/invalid-token.loggable-exception.spec.ts new file mode 100644 index 00000000000..b8f8e6478b7 --- /dev/null +++ b/apps/server/src/modules/authentication/loggable/invalid-token.loggable-exception.spec.ts @@ -0,0 +1,24 @@ +import { InvalidTokenLoggableException } from './invalid-token.loggable-exception'; + +describe(InvalidTokenLoggableException.name, () => { + describe('getLogMessage', () => { + const setup = () => { + const exception = new InvalidTokenLoggableException(); + + return { + exception, + }; + }; + + it('should return the correct log message', () => { + const { exception } = setup(); + + const message = exception.getLogMessage(); + + expect(message).toEqual({ + type: 'INVALID_TOKEN', + stack: expect.any(String), + }); + }); + }); +}); diff --git a/apps/server/src/modules/authentication/loggable/invalid-token.loggable-exception.ts b/apps/server/src/modules/authentication/loggable/invalid-token.loggable-exception.ts new file mode 100644 index 00000000000..04bf887b57d --- /dev/null +++ b/apps/server/src/modules/authentication/loggable/invalid-token.loggable-exception.ts @@ -0,0 +1,12 @@ +import { BadRequestException } from '@nestjs/common'; +import { Loggable } from '@src/core/logger/interfaces'; +import { ErrorLogMessage } from '@src/core/logger/types'; + +export class InvalidTokenLoggableException extends BadRequestException implements Loggable { + getLogMessage(): ErrorLogMessage { + return { + type: 'INVALID_TOKEN', + stack: this.stack, + }; + } +} diff --git a/apps/server/src/modules/authentication/services/authentication.service.spec.ts b/apps/server/src/modules/authentication/services/authentication.service.spec.ts index eabb6577fa1..804e6b30513 100644 --- a/apps/server/src/modules/authentication/services/authentication.service.spec.ts +++ b/apps/server/src/modules/authentication/services/authentication.service.spec.ts @@ -30,13 +30,13 @@ import { ExternalSystemLogoutFailedLoggableException, } from '../errors'; import { JwtWhitelistAdapter } from '../helper/jwt-whitelist.adapter'; -import { UserAccountDeactivatedLoggableException } from '../loggable/user-account-deactivated-exception'; +import { UserAccountDeactivatedLoggableException } from '../loggable'; import { CurrentUserMapper } from '../mapper'; import { AuthenticationService } from './authentication.service'; jest.mock('jsonwebtoken'); -describe('AuthenticationService', () => { +describe(AuthenticationService.name, () => { let module: TestingModule; let authenticationService: AuthenticationService; @@ -266,6 +266,18 @@ describe('AuthenticationService', () => { }); }); + describe('removeUserFromWhitelist', () => { + describe('when an account was provided', () => { + it('should call the jwtValidationAdapter to remove the jwt', async () => { + const account = accountDoFactory.build(); + + await authenticationService.removeUserFromWhitelist(account); + + expect(jwtWhitelistAdapter.removeFromWhitelist).toHaveBeenCalledWith(account.id); + }); + }); + }); + describe('checkBrutForce', () => { describe('when user tries multiple logins', () => { const setup = (elapsedSeconds: number) => { diff --git a/apps/server/src/modules/authentication/services/authentication.service.ts b/apps/server/src/modules/authentication/services/authentication.service.ts index 1d3be5b7564..3e1ba0a4d34 100644 --- a/apps/server/src/modules/authentication/services/authentication.service.ts +++ b/apps/server/src/modules/authentication/services/authentication.service.ts @@ -115,6 +115,10 @@ export class AuthenticationService { } } + public async removeUserFromWhitelist(account: Account): Promise { + await this.jwtWhitelistAdapter.removeFromWhitelist(account.id); + } + private isValidJwt(decodedJwt: JwtPayload | null): decodedJwt is { accountId: string; jti: string } { return typeof decodedJwt?.jti === 'string' && typeof decodedJwt?.accountId === 'string'; } diff --git a/apps/server/src/modules/authentication/services/index.ts b/apps/server/src/modules/authentication/services/index.ts index 45277bbf1c5..cc2c6a37a95 100644 --- a/apps/server/src/modules/authentication/services/index.ts +++ b/apps/server/src/modules/authentication/services/index.ts @@ -1,2 +1,3 @@ export * from './ldap.service'; export * from './authentication.service'; +export { LogoutService } from './logout.service'; diff --git a/apps/server/src/modules/authentication/services/logout.service.spec.ts b/apps/server/src/modules/authentication/services/logout.service.spec.ts new file mode 100644 index 00000000000..bb4954641a4 --- /dev/null +++ b/apps/server/src/modules/authentication/services/logout.service.spec.ts @@ -0,0 +1,317 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { ObjectId } from '@mikro-orm/mongodb'; +import { AccountService } from '@modules/account'; +import { accountDoFactory } from '@modules/account/testing'; +import { OAuthService } from '@modules/oauth'; +import { SystemService } from '@modules/system'; +import { systemFactory, systemOauthConfigFactory } from '@modules/system/testing'; +import { UserService } from '@modules/user'; +import { Test, TestingModule } from '@nestjs/testing'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; +import { JwtTestFactory, userDoFactory } from '@shared/testing'; +import { AccountSystemMismatchLoggableException, InvalidTokenLoggableException } from '../loggable'; +import { LogoutService } from './logout.service'; + +describe(LogoutService.name, () => { + let module: TestingModule; + let service: LogoutService; + + let systemService: DeepMocked; + let oauthService: DeepMocked; + let userService: DeepMocked; + let accountService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + LogoutService, + { + provide: SystemService, + useValue: createMock(), + }, + { + provide: OAuthService, + useValue: createMock(), + }, + { + provide: UserService, + useValue: createMock(), + }, + { + provide: AccountService, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(LogoutService); + systemService = module.get(SystemService); + oauthService = module.get(OAuthService); + userService = module.get(UserService); + accountService = module.get(AccountService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('getAccountFromLogoutToken', () => { + describe('when the logout token is valid', () => { + const setup = () => { + const oauthConfig = systemOauthConfigFactory.build(); + const system = systemFactory.build({ oauthConfig }); + + const userExternalId = 'userExternalId'; + const user = userDoFactory.buildWithId({ externalId: userExternalId }); + const account = accountDoFactory.build({ userId: user.id, systemId: system.id }); + + const logoutToken = JwtTestFactory.createLogoutToken({ + sub: userExternalId, + iss: oauthConfig.issuer, + aud: oauthConfig.clientId, + }); + + systemService.findByOauth2Issuer.mockResolvedValueOnce(system); + userService.findByExternalId.mockResolvedValueOnce(user); + accountService.findByUserId.mockResolvedValueOnce(account); + + return { + logoutToken, + oauthConfig, + userExternalId, + system, + account, + }; + }; + + it('should search for the correct system', async () => { + const { logoutToken, oauthConfig } = setup(); + + await service.getAccountFromLogoutToken(logoutToken); + + expect(systemService.findByOauth2Issuer).toHaveBeenCalledWith(oauthConfig.issuer); + }); + + it('should validate the token', async () => { + const { logoutToken, oauthConfig } = setup(); + + await service.getAccountFromLogoutToken(logoutToken); + + expect(oauthService.validateLogoutToken).toHaveBeenCalledWith(logoutToken, oauthConfig); + }); + + it('should search the correct user', async () => { + const { logoutToken, userExternalId, system } = setup(); + + await service.getAccountFromLogoutToken(logoutToken); + + expect(userService.findByExternalId).toHaveBeenCalledWith(userExternalId, system.id); + }); + + it('should return the account', async () => { + const { logoutToken, account } = setup(); + + const result = await service.getAccountFromLogoutToken(logoutToken); + + expect(result).toEqual(account); + }); + }); + + describe('when the logout token does not have an issuer', () => { + const setup = () => { + const logoutToken = JwtTestFactory.createLogoutToken({ + iss: undefined, + }); + + return { + logoutToken, + }; + }; + + it('should throw an error', async () => { + const { logoutToken } = setup(); + + await expect(service.getAccountFromLogoutToken(logoutToken)).rejects.toThrow(InvalidTokenLoggableException); + }); + }); + + describe('when the logout token does not have a subject', () => { + const setup = () => { + const logoutToken = JwtTestFactory.createLogoutToken({ + sub: undefined, + }); + + return { + logoutToken, + }; + }; + + it('should throw an error', async () => { + const { logoutToken } = setup(); + + await expect(service.getAccountFromLogoutToken(logoutToken)).rejects.toThrow(InvalidTokenLoggableException); + }); + }); + + describe('when there is no system', () => { + const setup = () => { + const logoutToken = JwtTestFactory.createLogoutToken(); + + systemService.findByOauth2Issuer.mockResolvedValueOnce(null); + + return { + logoutToken, + }; + }; + + it('should throw an error', async () => { + const { logoutToken } = setup(); + + await expect(service.getAccountFromLogoutToken(logoutToken)).rejects.toThrow(NotFoundLoggableException); + }); + }); + + describe('when the system does not have an oauth config', () => { + const setup = () => { + const system = systemFactory.build({ oauthConfig: undefined }); + const logoutToken = JwtTestFactory.createLogoutToken(); + + systemService.findByOauth2Issuer.mockResolvedValueOnce(system); + + return { + logoutToken, + }; + }; + + it('should throw an error', async () => { + const { logoutToken } = setup(); + + await expect(service.getAccountFromLogoutToken(logoutToken)).rejects.toThrow(NotFoundLoggableException); + }); + }); + + describe('when the logout token validation fails', () => { + const setup = () => { + const oauthConfig = systemOauthConfigFactory.build(); + const system = systemFactory.build({ oauthConfig }); + + const logoutToken = JwtTestFactory.createLogoutToken({ + iss: oauthConfig.issuer, + aud: oauthConfig.clientId, + }); + + const error = new Error(); + + systemService.findByOauth2Issuer.mockResolvedValueOnce(system); + oauthService.validateLogoutToken.mockRejectedValueOnce(error); + + return { + logoutToken, + error, + }; + }; + + it('should throw an error', async () => { + const { logoutToken, error } = setup(); + + await expect(service.getAccountFromLogoutToken(logoutToken)).rejects.toThrow(error); + }); + }); + + describe('when there is no user', () => { + const setup = () => { + const oauthConfig = systemOauthConfigFactory.build(); + const system = systemFactory.build({ oauthConfig }); + + const logoutToken = JwtTestFactory.createLogoutToken({ + iss: oauthConfig.issuer, + aud: oauthConfig.clientId, + }); + + systemService.findByOauth2Issuer.mockResolvedValueOnce(system); + userService.findByExternalId.mockResolvedValueOnce(null); + + return { + logoutToken, + }; + }; + + it('should throw an error', async () => { + const { logoutToken } = setup(); + + await expect(service.getAccountFromLogoutToken(logoutToken)).rejects.toThrow(NotFoundLoggableException); + }); + }); + + describe('when there is no account', () => { + const setup = () => { + const oauthConfig = systemOauthConfigFactory.build(); + const system = systemFactory.build({ oauthConfig }); + + const userExternalId = 'userExternalId'; + const user = userDoFactory.buildWithId({ externalId: userExternalId }); + + const logoutToken = JwtTestFactory.createLogoutToken({ + sub: userExternalId, + iss: oauthConfig.issuer, + aud: oauthConfig.clientId, + }); + + systemService.findByOauth2Issuer.mockResolvedValueOnce(system); + userService.findByExternalId.mockResolvedValueOnce(user); + accountService.findByUserId.mockResolvedValueOnce(null); + + return { + logoutToken, + }; + }; + + it('should throw an error', async () => { + const { logoutToken } = setup(); + + await expect(service.getAccountFromLogoutToken(logoutToken)).rejects.toThrow(NotFoundLoggableException); + }); + }); + + describe('when the system of the account is not the same as the one of the token', () => { + const setup = () => { + const oauthConfig = systemOauthConfigFactory.build(); + const system = systemFactory.build({ oauthConfig }); + + const userExternalId = 'userExternalId'; + const user = userDoFactory.buildWithId({ externalId: userExternalId }); + const account = accountDoFactory.build({ userId: user.id, systemId: new ObjectId().toHexString() }); + + const logoutToken = JwtTestFactory.createLogoutToken({ + sub: userExternalId, + iss: oauthConfig.issuer, + aud: oauthConfig.clientId, + }); + + systemService.findByOauth2Issuer.mockResolvedValueOnce(system); + userService.findByExternalId.mockResolvedValueOnce(user); + accountService.findByUserId.mockResolvedValueOnce(account); + + return { + logoutToken, + oauthConfig, + userExternalId, + system, + account, + }; + }; + + it('should throw an error', async () => { + const { logoutToken } = setup(); + + await expect(service.getAccountFromLogoutToken(logoutToken)).rejects.toThrow( + AccountSystemMismatchLoggableException + ); + }); + }); + }); +}); diff --git a/apps/server/src/modules/authentication/services/logout.service.ts b/apps/server/src/modules/authentication/services/logout.service.ts new file mode 100644 index 00000000000..0a2cb925a0d --- /dev/null +++ b/apps/server/src/modules/authentication/services/logout.service.ts @@ -0,0 +1,55 @@ +import { Account, AccountService } from '@modules/account'; +import { OAuthService } from '@modules/oauth'; +import { System, SystemService } from '@modules/system'; +import { UserService } from '@modules/user'; +import { Injectable } from '@nestjs/common'; +import { NotFoundLoggableException } from '@shared/common/loggable-exception'; +import { UserDO } from '@shared/domain/domainobject'; +import jwt, { JwtPayload } from 'jsonwebtoken'; +import { AccountSystemMismatchLoggableException, InvalidTokenLoggableException } from '../loggable'; + +@Injectable() +export class LogoutService { + constructor( + private readonly systemService: SystemService, + private readonly oauthService: OAuthService, + private readonly userService: UserService, + private readonly accountService: AccountService + ) {} + + async getAccountFromLogoutToken(logoutToken: string): Promise { + const decodedLogoutToken: JwtPayload | null = jwt.decode(logoutToken, { json: true }); + + if (!decodedLogoutToken || !decodedLogoutToken.iss || !decodedLogoutToken.sub) { + throw new InvalidTokenLoggableException(); + } + + const system: System | null = await this.systemService.findByOauth2Issuer(decodedLogoutToken.iss); + + if (!system?.oauthConfig) { + throw new NotFoundLoggableException(System.name, { 'oauthConfig.issuer': decodedLogoutToken.iss }); + } + + await this.oauthService.validateLogoutToken(logoutToken, system.oauthConfig); + + const externalId: string = decodedLogoutToken.sub; + + const user: UserDO | null = await this.userService.findByExternalId(externalId, system.id); + + if (!user?.id) { + throw new NotFoundLoggableException('User', { externalId, systemId: system.id }); + } + + const account: Account | null = await this.accountService.findByUserId(user.id); + + if (!account) { + throw new NotFoundLoggableException(Account.name, { userId: user.id }); + } + + if (account.systemId !== system.id) { + throw new AccountSystemMismatchLoggableException(account.systemId, system.id); + } + + return account; + } +} diff --git a/apps/server/src/modules/authentication/uc/logout.uc.spec.ts b/apps/server/src/modules/authentication/uc/logout.uc.spec.ts index 3d774a82d6b..ba39be4ab67 100644 --- a/apps/server/src/modules/authentication/uc/logout.uc.spec.ts +++ b/apps/server/src/modules/authentication/uc/logout.uc.spec.ts @@ -1,12 +1,16 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { accountDoFactory } from '@modules/account/testing'; +import { BadRequestException } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; +import { ErrorLoggable } from '@src/core/error/loggable'; +import { Logger } from '@src/core/logger'; import { currentUserFactory, JwtTestFactory } from '@shared/testing'; import { ConfigService } from '@nestjs/config'; import { SystemService } from '@modules/system'; import { systemFactory } from '@modules/system/testing'; import { OauthSessionTokenService } from '@modules/oauth'; import { oauthSessionTokenFactory } from '@modules/oauth/testing'; -import { AuthenticationService } from '../services'; +import { AuthenticationService, LogoutService } from '../services'; import { ExternalSystemLogoutIsDisabledLoggableException } from '../errors'; import { LogoutUc } from './logout.uc'; @@ -15,6 +19,8 @@ describe(LogoutUc.name, () => { let logoutUc: LogoutUc; let authenticationService: DeepMocked; + let logoutService: DeepMocked; + let logger: DeepMocked; let configService: DeepMocked; let systemService: DeepMocked; let oauthSessionTokenService: DeepMocked; @@ -27,6 +33,14 @@ describe(LogoutUc.name, () => { provide: AuthenticationService, useValue: createMock(), }, + { + provide: LogoutService, + useValue: createMock(), + }, + { + provide: Logger, + useValue: createMock(), + }, { provide: ConfigService, useValue: createMock({ get: () => true }), @@ -44,6 +58,8 @@ describe(LogoutUc.name, () => { logoutUc = await module.get(LogoutUc); authenticationService = await module.get(AuthenticationService); + logoutService = await module.get(LogoutService); + logger = await module.get(Logger); configService = await module.get(ConfigService); systemService = module.get(SystemService); oauthSessionTokenService = module.get(OauthSessionTokenService); @@ -73,6 +89,66 @@ describe(LogoutUc.name, () => { }); }); + describe('logoutOidc', () => { + describe('when the logout token is valid', () => { + const setup = () => { + const logoutToken = 'logoutToken'; + const account = accountDoFactory.build(); + + logoutService.getAccountFromLogoutToken.mockResolvedValueOnce(account); + + return { + logoutToken, + account, + }; + }; + + it('should validate the logout token and get the account', async () => { + const { logoutToken } = setup(); + + await logoutUc.logoutOidc(logoutToken); + + expect(logoutService.getAccountFromLogoutToken).toHaveBeenCalledWith(logoutToken); + }); + + it('should remove the user from the whitelist', async () => { + const { logoutToken, account } = setup(); + + await logoutUc.logoutOidc(logoutToken); + + expect(authenticationService.removeUserFromWhitelist).toHaveBeenCalledWith(account); + }); + }); + + describe('when the logout token is invalid', () => { + const setup = () => { + const logoutToken = 'logoutToken'; + const error = new Error('Validation error'); + + logoutService.getAccountFromLogoutToken.mockRejectedValueOnce(error); + + return { + logoutToken, + error, + }; + }; + + it('should throw a generic error', async () => { + const { logoutToken } = setup(); + + await expect(logoutUc.logoutOidc(logoutToken)).rejects.toThrow(BadRequestException); + }); + + it('should log the original error', async () => { + const { logoutToken, error } = setup(); + + await expect(logoutUc.logoutOidc(logoutToken)).rejects.toThrow(BadRequestException); + + expect(logger.warning).toHaveBeenCalledWith(new ErrorLoggable(error)); + }); + }); + }); + describe('externalSystemLogout', () => { describe('when the feature flag FEATURE_EXTERNAL_SYSTEM_LOGOUT_ENABLED is disabled', () => { const setup = () => { diff --git a/apps/server/src/modules/authentication/uc/logout.uc.ts b/apps/server/src/modules/authentication/uc/logout.uc.ts index 0a1c9892563..0cf663b4f0a 100644 --- a/apps/server/src/modules/authentication/uc/logout.uc.ts +++ b/apps/server/src/modules/authentication/uc/logout.uc.ts @@ -1,16 +1,21 @@ -import { Injectable } from '@nestjs/common'; +import { Account } from '@modules/account'; +import { BadRequestException, Injectable } from '@nestjs/common'; +import { ErrorLoggable } from '@src/core/error/loggable'; +import { Logger } from '@src/core/logger'; import { ICurrentUser } from '@infra/auth-guard'; import { System, SystemService } from '@modules/system'; import { OauthSessionToken, OauthSessionTokenService } from '@modules/oauth'; import { ConfigService } from '@nestjs/config'; +import { AuthenticationService, LogoutService } from '../services'; import { AuthenticationConfig } from '../authentication-config'; -import { AuthenticationService } from '../services'; import { ExternalSystemLogoutIsDisabledLoggableException } from '../errors'; @Injectable() export class LogoutUc { constructor( private readonly authenticationService: AuthenticationService, + private readonly logoutService: LogoutService, + private readonly logger: Logger, private readonly configService: ConfigService, private readonly systemService: SystemService, private readonly oauthSessionTokenService: OauthSessionTokenService @@ -20,6 +25,22 @@ export class LogoutUc { await this.authenticationService.removeJwtFromWhitelist(jwt); } + async logoutOidc(logoutToken: string): Promise { + // Do not publish any information (like the users existence) before validating the logout tokens origin + try { + const account: Account = await this.logoutService.getAccountFromLogoutToken(logoutToken); + + await this.authenticationService.removeUserFromWhitelist(account); + } catch (error: unknown) { + if (error instanceof Error) { + this.logger.warning(new ErrorLoggable(error)); + } + + // Must respond with bad request: https://openid.net/specs/openid-connect-backchannel-1_0.html#BCResponse + throw new BadRequestException(); + } + } + async externalSystemLogout(user: ICurrentUser): Promise { if (!this.configService.get('FEATURE_EXTERNAL_SYSTEM_LOGOUT_ENABLED')) { throw new ExternalSystemLogoutIsDisabledLoggableException(); diff --git a/apps/server/src/modules/oauth/service/oauth.service.spec.ts b/apps/server/src/modules/oauth/service/oauth.service.spec.ts index 880c0d2558a..c850532d7ea 100644 --- a/apps/server/src/modules/oauth/service/oauth.service.spec.ts +++ b/apps/server/src/modules/oauth/service/oauth.service.spec.ts @@ -6,7 +6,7 @@ import { LegacySchoolService } from '@modules/legacy-school'; import { ProvisioningService } from '@modules/provisioning'; import { OauthConfigEntity } from '@modules/system/entity'; import { SystemService } from '@modules/system/service'; -import { systemFactory } from '@modules/system/testing'; +import { systemFactory, systemOauthConfigFactory } from '@modules/system/testing'; import { UserService } from '@modules/user'; import { MigrationCheckService } from '@modules/user-login-migration'; import { Test, TestingModule } from '@nestjs/testing'; @@ -170,9 +170,6 @@ describe('OAuthService', () => { }); describe('validateToken', () => { - afterEach(() => { - jest.clearAllMocks(); - }); describe('when the token is validated', () => { it('should validate id_token and return it decoded', async () => { jest.spyOn(jwt, 'verify').mockImplementationOnce((): JwtPayload => { @@ -196,6 +193,115 @@ describe('OAuthService', () => { }); }); + describe('validateLogoutToken', () => { + describe('when the token is valid', () => { + const setup = () => { + const secret = 'secret'; + const jwtPayload: JwtPayload = { + sub: 'externalUserId', + iss: 'externalSystem', + events: { 'http://schemas.openid.net/event/backchannel-logout': {} }, + }; + const oauthConfig = systemOauthConfigFactory.build(); + + oauthAdapterService.getPublicKey.mockResolvedValue(secret); + jest.spyOn(jwt, 'verify').mockImplementationOnce(() => jwtPayload); + + return { + secret, + jwtPayload, + oauthConfig, + }; + }; + + it('should return the decoded token', async () => { + const { oauthConfig, jwtPayload } = setup(); + + const result = await service.validateLogoutToken('token', oauthConfig); + + expect(result).toEqual(jwtPayload); + }); + }); + + describe('when the validation only returns a string', () => { + const setup = () => { + const secret = 'secret'; + const oauthConfig = systemOauthConfigFactory.build(); + + oauthAdapterService.getPublicKey.mockResolvedValue(secret); + jest.spyOn(jwt, 'verify').mockImplementationOnce(() => 'string'); + + return { + secret, + oauthConfig, + }; + }; + + it('should throw an error', async () => { + const { oauthConfig } = setup(); + + await expect(service.validateLogoutToken('token', oauthConfig)).rejects.toEqual( + new TokenInvalidLoggableException() + ); + }); + }); + + describe('when the token does not contain the backchannel-logout event', () => { + const setup = () => { + const secret = 'secret'; + const jwtPayload: JwtPayload = { sub: 'externalUserId', iss: 'externalSystem' }; + const oauthConfig = systemOauthConfigFactory.build(); + + oauthAdapterService.getPublicKey.mockResolvedValue(secret); + jest.spyOn(jwt, 'verify').mockImplementationOnce(() => jwtPayload); + + return { + secret, + jwtPayload, + oauthConfig, + }; + }; + + it('should throw an error', async () => { + const { oauthConfig } = setup(); + + await expect(service.validateLogoutToken('token', oauthConfig)).rejects.toEqual( + new TokenInvalidLoggableException() + ); + }); + }); + + describe('when the token has a nonce', () => { + const setup = () => { + const secret = 'secret'; + const jwtPayload: JwtPayload = { + sub: 'externalUserId', + iss: 'externalSystem', + events: { 'http://schemas.openid.net/event/backchannel-logout': {} }, + nonce: '8321937182', + }; + const oauthConfig = systemOauthConfigFactory.build(); + + oauthAdapterService.getPublicKey.mockResolvedValue(secret); + jest.spyOn(jwt, 'verify').mockImplementationOnce(() => jwtPayload); + + return { + secret, + jwtPayload, + oauthConfig, + }; + }; + + it('should throw an error', async () => { + const { oauthConfig } = setup(); + + await expect(service.validateLogoutToken('token', oauthConfig)).rejects.toEqual( + new TokenInvalidLoggableException() + ); + }); + }); + }); + describe('authenticateUser is called', () => { const setup = () => { const authCode = '43534543jnj543342jn2'; diff --git a/apps/server/src/modules/oauth/service/oauth.service.ts b/apps/server/src/modules/oauth/service/oauth.service.ts index 1ce3ebc8dd5..c02843fa679 100644 --- a/apps/server/src/modules/oauth/service/oauth.service.ts +++ b/apps/server/src/modules/oauth/service/oauth.service.ts @@ -8,6 +8,7 @@ import { UserService } from '@modules/user'; import { MigrationCheckService } from '@modules/user-login-migration'; import { Inject } from '@nestjs/common'; import { Injectable } from '@nestjs/common/decorators/core/injectable.decorator'; +import { isObject } from '@nestjs/common/utils/shared.utils'; import { LegacySchoolDo, UserDO } from '@shared/domain/domainobject'; import { EntityId, SchoolFeature } from '@shared/domain/types'; import { LegacyLogger } from '@src/core/logger'; @@ -136,6 +137,26 @@ export class OAuthService { return decodedJWT; } + /** + * @see https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation + */ + async validateLogoutToken(logoutToken: string, oauthConfig: OauthConfigEntity): Promise { + const validatedJwt: JwtPayload = await this.validateToken(logoutToken, oauthConfig); + + if ( + !isObject(validatedJwt.events) || + !Object.keys(validatedJwt.events).includes('http://schemas.openid.net/event/backchannel-logout') + ) { + throw new TokenInvalidLoggableException(); + } + + if (validatedJwt.nonce !== undefined) { + throw new TokenInvalidLoggableException(); + } + + return validatedJwt; + } + private buildTokenRequestPayload( code: string, oauthConfig: OauthConfigEntity, diff --git a/apps/server/src/modules/system/domain/interface/system.repo.interface.ts b/apps/server/src/modules/system/domain/interface/system.repo.interface.ts index fdfb8bbacb1..612a3718449 100644 --- a/apps/server/src/modules/system/domain/interface/system.repo.interface.ts +++ b/apps/server/src/modules/system/domain/interface/system.repo.interface.ts @@ -9,6 +9,8 @@ export interface SystemRepo { getSystemById(systemId: EntityId): Promise; + findByOauth2Issuer(issuer: string): Promise; + findAllForLdapLogin(): Promise; delete(domainObject: System): Promise; diff --git a/apps/server/src/modules/system/entity/system.entity.ts b/apps/server/src/modules/system/entity/system.entity.ts index 66b8ae6162b..27d17f2c686 100644 --- a/apps/server/src/modules/system/entity/system.entity.ts +++ b/apps/server/src/modules/system/entity/system.entity.ts @@ -16,6 +16,7 @@ export interface SystemEntityProps { provisioningUrl?: string; } +@Embeddable() export class OauthConfigEntity { constructor(oauthConfig: OauthConfigEntity) { this.clientId = oauthConfig.clientId; @@ -155,6 +156,8 @@ export class LdapConfigEntity { }; }; } + +@Embeddable() export class OidcConfigEntity { constructor(oidcConfig: OidcConfigEntity) { this.clientId = oidcConfig.clientId; @@ -206,17 +209,17 @@ export class SystemEntity extends BaseEntityWithTimestamps { @Property({ nullable: true }) displayName?: string; - @Property({ nullable: true }) + @Embedded(() => OauthConfigEntity, { object: true, nullable: true }) oauthConfig?: OauthConfigEntity; @Property({ nullable: true }) @Enum() provisioningStrategy?: SystemProvisioningStrategy; - @Property({ nullable: true }) + @Embedded(() => OidcConfigEntity, { object: true, nullable: true }) oidcConfig?: OidcConfigEntity; - @Embedded({ entity: () => LdapConfigEntity, object: true, nullable: true }) + @Embedded(() => LdapConfigEntity, { object: true, nullable: true }) ldapConfig?: LdapConfigEntity; @Property({ nullable: true }) diff --git a/apps/server/src/modules/system/repo/mikro-orm/system.repo.spec.ts b/apps/server/src/modules/system/repo/mikro-orm/system.repo.spec.ts index bb16db8ed9a..37eb130ca48 100644 --- a/apps/server/src/modules/system/repo/mikro-orm/system.repo.spec.ts +++ b/apps/server/src/modules/system/repo/mikro-orm/system.repo.spec.ts @@ -7,9 +7,9 @@ import { SystemTypeEnum } from '@shared/domain/types'; import { cleanupCollections, systemEntityFactory } from '@shared/testing'; import { System, SYSTEM_REPO, SystemProps, SystemRepo, SystemType } from '../../domain'; import { SystemEntity } from '../../entity'; +import { systemLdapConfigFactory, systemOauthConfigFactory, systemOidcConfigFactory } from '../../testing'; import { SystemEntityMapper } from './mapper'; import { SystemMikroOrmRepo } from './system.repo'; -import { systemLdapConfigFactory, systemOauthConfigFactory, systemOidcConfigFactory } from '../../testing'; describe(SystemMikroOrmRepo.name, () => { let module: TestingModule; @@ -366,4 +366,37 @@ describe(SystemMikroOrmRepo.name, () => { }); }); }); + + describe('findByOauth2Issuer', () => { + describe('when the system exists', () => { + const setup = async () => { + const issuer = 'external-system-issuer'; + const systemEntity: SystemEntity = systemEntityFactory.withOauthConfig({ issuer }).buildWithId(); + + await em.persistAndFlush([systemEntity]); + em.clear(); + + return { + systemEntity, + issuer, + }; + }; + + it('should return the system', async () => { + const { systemEntity, issuer } = await setup(); + + const result = await repo.findByOauth2Issuer(issuer); + + expect(result).toEqual(SystemEntityMapper.mapToDo(systemEntity)); + }); + }); + + describe('when the system does not exist', () => { + it('should return null', async () => { + const result = await repo.findByOauth2Issuer('unknown-issuer'); + + expect(result).toBeNull(); + }); + }); + }); }); diff --git a/apps/server/src/modules/system/repo/mikro-orm/system.repo.ts b/apps/server/src/modules/system/repo/mikro-orm/system.repo.ts index c83d82abcbf..609c7cd0ff4 100644 --- a/apps/server/src/modules/system/repo/mikro-orm/system.repo.ts +++ b/apps/server/src/modules/system/repo/mikro-orm/system.repo.ts @@ -61,4 +61,19 @@ export class SystemMikroOrmRepo extends BaseDomainObjectRepo { + const entity: SystemEntity | null = await this.em.findOne(SystemEntity, { + type: SystemTypeEnum.OAUTH, + oauthConfig: { issuer }, + }); + + if (!entity) { + return null; + } + + const domainObject: System = SystemEntityMapper.mapToDo(entity); + + return domainObject; + } } diff --git a/apps/server/src/modules/system/service/system.service.spec.ts b/apps/server/src/modules/system/service/system.service.spec.ts index 382e3eea754..87b80169ff2 100644 --- a/apps/server/src/modules/system/service/system.service.spec.ts +++ b/apps/server/src/modules/system/service/system.service.spec.ts @@ -217,6 +217,44 @@ describe(SystemService.name, () => { }); }); + describe('findByOauth2Issuer', () => { + describe('when the system exists', () => { + const setup = () => { + const issuer = 'issuer'; + const system = systemFactory.withOauthConfig({ issuer }).build(); + + systemRepo.findByOauth2Issuer.mockResolvedValueOnce(system); + + return { + system, + issuer, + }; + }; + + it('should return the system', async () => { + const { system, issuer } = setup(); + + const result = await service.findByOauth2Issuer(issuer); + + expect(result).toEqual(system); + }); + }); + + describe('when the system does not exist', () => { + const setup = () => { + systemRepo.findByOauth2Issuer.mockResolvedValueOnce(null); + }; + + it('should return null', async () => { + setup(); + + const result = await service.findByOauth2Issuer('issuer'); + + expect(result).toBeNull(); + }); + }); + }); + describe('delete', () => { describe('when the system was deleted', () => { const setup = () => { diff --git a/apps/server/src/modules/system/service/system.service.ts b/apps/server/src/modules/system/service/system.service.ts index 4b83558cac9..9718d8879fd 100644 --- a/apps/server/src/modules/system/service/system.service.ts +++ b/apps/server/src/modules/system/service/system.service.ts @@ -41,6 +41,12 @@ export class SystemService { return systems; } + public async findByOauth2Issuer(issuer: EntityId): Promise { + const system: System | null = await this.systemRepo.findByOauth2Issuer(issuer); + + return system; + } + public async delete(domainObject: System): Promise { await this.systemRepo.delete(domainObject); diff --git a/apps/server/src/shared/domain/types/school-feature.enum.ts b/apps/server/src/shared/domain/types/school-feature.enum.ts index 15da5924d0d..2a0d6503b4e 100644 --- a/apps/server/src/shared/domain/types/school-feature.enum.ts +++ b/apps/server/src/shared/domain/types/school-feature.enum.ts @@ -8,5 +8,5 @@ export enum SchoolFeature { OAUTH_PROVISIONING_ENABLED = 'oauthProvisioningEnabled', SHOW_OUTDATED_USERS = 'showOutdatedUsers', ENABLE_LDAP_SYNC_DURING_MIGRATION = 'enableLdapSyncDuringMigration', - AI_TUTOR = 'aiTutor', + AI_TUTOR = 'aiTutor', // TODO has to be added to shd } diff --git a/apps/server/src/shared/testing/factory/jwt.test.factory.ts b/apps/server/src/shared/testing/factory/jwt.test.factory.ts index d62aefe6946..0389884d7a4 100644 --- a/apps/server/src/shared/testing/factory/jwt.test.factory.ts +++ b/apps/server/src/shared/testing/factory/jwt.test.factory.ts @@ -1,5 +1,5 @@ import crypto, { KeyPairKeyObjectResult } from 'crypto'; -import jwt from 'jsonwebtoken'; +import jwt, { JwtPayload, SignOptions } from 'jsonwebtoken'; const keyPair: KeyPairKeyObjectResult = crypto.generateKeyPairSync('rsa', { modulusLength: 4096 }); const publicKey: string | Buffer = keyPair.publicKey.export({ type: 'pkcs1', format: 'pem' }); @@ -37,6 +37,29 @@ export class JwtTestFactory { algorithm: 'RS256', } ); + return validJwt; + } + + public static createLogoutToken(payload?: JwtPayload, options?: SignOptions): string { + const validJwt = jwt.sign( + { + sub: 'testUser', + iss: 'issuer', + aud: 'audience', + jti: 'jti', + iat: Date.now(), + exp: Date.now() + 100000, + events: { + 'http://schemas.openid.net/event/backchannel-logout': {}, + }, + ...payload, + }, + privateKey, + { + algorithm: 'RS256', + ...options, + } + ); return validJwt; } diff --git a/apps/server/src/shared/testing/factory/systemEntityFactory.ts b/apps/server/src/shared/testing/factory/systemEntityFactory.ts index ea4862a4253..88e1e9a6b05 100644 --- a/apps/server/src/shared/testing/factory/systemEntityFactory.ts +++ b/apps/server/src/shared/testing/factory/systemEntityFactory.ts @@ -10,57 +10,79 @@ import { SystemTypeEnum } from '@shared/domain/types'; import { DeepPartial } from 'fishery'; import { BaseFactory } from './base.factory'; +export const systemOauthConfigEntityFactory = BaseFactory.define( + OauthConfigEntity, + () => { + return { + clientId: '12345', + clientSecret: 'mocksecret', + idpHint: 'mock-oauth-idpHint', + tokenEndpoint: 'https://mock.de/mock/auth/public/mockToken', + grantType: 'authorization_code', + redirectUri: 'https://mockhost:3030/api/v3/sso/oauth/', + scope: 'openid uuid', + responseType: 'code', + authEndpoint: 'https://mock.de/auth', + provider: 'mock_type', + logoutEndpoint: 'https://mock.de/logout', + issuer: 'mock_issuer', + jwksEndpoint: 'https://mock.de/jwks', + endSessionEndpoint: 'https://mock.de/logout', + }; + } +); + +export const systemLdapConfigEntityFactory = BaseFactory.define( + LdapConfigEntity, + () => { + return { + url: 'ldaps:mock.de:389', + active: true, + }; + } +); + +export const systemOidcConfigEntityFactory = BaseFactory.define( + OidcConfigEntity, + () => { + return { + clientId: 'mock-client-id', + clientSecret: 'mock-client-secret', + idpHint: 'mock-oidc-idpHint', + defaultScopes: 'openid email userinfo', + authorizationUrl: 'https://mock.tld/auth', + tokenUrl: 'https://mock.tld/token', + userinfoUrl: 'https://mock.tld/userinfo', + logoutUrl: 'https://mock.tld/logout', + }; + } +); + export class SystemEntityFactory extends BaseFactory { - withOauthConfig(): this { + withOauthConfig(otherParams?: DeepPartial): this { const params: DeepPartial = { type: SystemTypeEnum.OAUTH, - oauthConfig: new OauthConfigEntity({ - clientId: '12345', - clientSecret: 'mocksecret', - idpHint: 'mock-oauth-idpHint', - tokenEndpoint: 'https://mock.de/mock/auth/public/mockToken', - grantType: 'authorization_code', - redirectUri: 'https://mockhost:3030/api/v3/sso/oauth/', - scope: 'openid uuid', - responseType: 'code', - authEndpoint: 'https://mock.de/auth', - provider: 'mock_type', - logoutEndpoint: 'https://mock.de/logout', - issuer: 'mock_issuer', - jwksEndpoint: 'https://mock.de/jwks', - endSessionEndpoint: 'https://mock.de/logout', - }), + oauthConfig: systemOauthConfigEntityFactory.build(otherParams), }; + return this.params(params); } withLdapConfig(otherParams?: DeepPartial): this { const params: DeepPartial = { type: SystemTypeEnum.LDAP, - ldapConfig: new LdapConfigEntity({ - url: 'ldaps:mock.de:389', - active: true, - ...otherParams, - }), + ldapConfig: systemLdapConfigEntityFactory.build(otherParams), }; return this.params(params); } - withOidcConfig(): this { + withOidcConfig(otherParams?: DeepPartial): this { const params = { type: SystemTypeEnum.OIDC, - oidcConfig: new OidcConfigEntity({ - clientId: 'mock-client-id', - clientSecret: 'mock-client-secret', - idpHint: 'mock-oidc-idpHint', - defaultScopes: 'openid email userinfo', - authorizationUrl: 'https://mock.tld/auth', - tokenUrl: 'https://mock.tld/token', - userinfoUrl: 'https://mock.tld/userinfo', - logoutUrl: 'https://mock.tld/logout', - }), + oidcConfig: systemOidcConfigEntityFactory.build(otherParams), }; + return this.params(params); } }