From e3afbd415d806f8d3ed5c2f46cd338735ad54a33 Mon Sep 17 00:00:00 2001 From: agnisa-cap Date: Fri, 13 Oct 2023 14:34:50 +0200 Subject: [PATCH] N21-1273 calling oauth2 logoutEndpoint (#4467) * adds externalIdToken to ICurrentUser to be able to trigger rp initiated logout, after successful login * adds migration script to remove logout endpoint for sanis * removes logoutUrl changes init script of moin.schule --- .../templates/configmap_file_init.yml.j2 | 1 - .../controllers/api-test/login.api.spec.ts | 35 +++++- .../authentication/controllers/dto/index.ts | 1 + .../controllers/dto/oauth-login.response.ts | 15 +++ .../controllers/login.controller.ts | 13 ++- .../mapper/login-response.mapper.ts | 17 ++- .../modules/authentication/interface/user.ts | 5 + .../mapper/current-user.mapper.spec.ts | 108 +++++++++++++++--- .../mapper/current-user.mapper.ts | 23 +++- .../strategy/oauth2.strategy.spec.ts | 12 +- .../strategy/oauth2.strategy.ts | 9 +- .../authentication/uc/login.uc.spec.ts | 20 +++- .../src/modules/authentication/uc/login.uc.ts | 10 +- .../oauth/controller/oauth-sso.controller.ts | 2 +- .../oauth/{error => loggable}/index.ts | 0 .../oauth-sso.error.spec.ts | 0 .../{error => loggable}/oauth-sso.error.ts | 0 .../sso-error-code.enum.ts | 0 ...er-provisioning.loggable-exception.spec.ts | 0 ...d-after-provisioning.loggable-exception.ts | 0 .../service/oauth-adapter.service.spec.ts | 7 +- .../oauth/service/oauth-adapter.service.ts | 7 +- .../oauth/service/oauth.service.spec.ts | 2 +- .../modules/oauth/service/oauth.service.ts | 4 +- .../modules/oauth/uc/hydra-oauth.uc.spec.ts | 2 +- .../src/modules/oauth/uc/hydra-oauth.uc.ts | 2 +- .../src/modules/oauth/uc/oauth.uc.spec.ts | 2 +- .../strategy/iserv/iserv.strategy.spec.ts | 2 +- .../strategy/iserv/iserv.strategy.ts | 2 +- .../oidc-mock/oidc-mock.strategy.spec.ts | 2 +- .../strategy/oidc-mock/oidc-mock.strategy.ts | 2 +- .../controller/dto/oauth-config.response.ts | 6 +- .../system/service/dto/oauth-config.dto.ts | 5 +- .../error/oauth-migration.error.ts | 2 +- .../src/shared/domain/entity/system.entity.ts | 4 +- backup/setup/migrations.json | 11 ++ ...8782-remove-moin-schule-logout-endpoint.js | 87 ++++++++++++++ 37 files changed, 345 insertions(+), 75 deletions(-) create mode 100644 apps/server/src/modules/authentication/controllers/dto/oauth-login.response.ts rename apps/server/src/modules/oauth/{error => loggable}/index.ts (100%) rename apps/server/src/modules/oauth/{error => loggable}/oauth-sso.error.spec.ts (100%) rename apps/server/src/modules/oauth/{error => loggable}/oauth-sso.error.ts (100%) rename apps/server/src/modules/oauth/{error => loggable}/sso-error-code.enum.ts (100%) rename apps/server/src/modules/oauth/{error => loggable}/user-not-found-after-provisioning.loggable-exception.spec.ts (100%) rename apps/server/src/modules/oauth/{error => loggable}/user-not-found-after-provisioning.loggable-exception.ts (100%) create mode 100644 migrations/1697020818782-remove-moin-schule-logout-endpoint.js diff --git a/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 b/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 index 6b17e522753..82ff81afb8b 100644 --- a/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 +++ b/ansible/roles/schulcloud-server-init/templates/configmap_file_init.yml.j2 @@ -177,7 +177,6 @@ data: "redirectUri": "https://{{ NAMESPACE }}.cd.dbildungscloud.dev/api/v3/sso/oauth", "authEndpoint": "https://auth.stage.niedersachsen-login.schule/realms/SANIS/protocol/openid-connect/auth", "provider": "sanis", - "logoutEndpoint": "https://auth.stage.niedersachsen-login.schule/realms/SANIS/protocol/openid-connect/logout", "jwksEndpoint": "https://auth.stage.niedersachsen-login.schule/realms/SANIS/protocol/openid-connect/certs", "issuer": "https://auth.stage.niedersachsen-login.schule/realms/SANIS" } diff --git a/apps/server/src/modules/authentication/controllers/api-test/login.api.spec.ts b/apps/server/src/modules/authentication/controllers/api-test/login.api.spec.ts index 80fc43734bc..8d8ea8b305d 100644 --- a/apps/server/src/modules/authentication/controllers/api-test/login.api.spec.ts +++ b/apps/server/src/modules/authentication/controllers/api-test/login.api.spec.ts @@ -3,7 +3,7 @@ import { HttpStatus, INestApplication } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { Account, RoleName, SchoolEntity, SystemEntity, User } from '@shared/domain'; import { accountFactory, roleFactory, schoolFactory, systemFactory, userFactory } from '@shared/testing'; -import { SSOErrorCode } from '@src/modules/oauth/error/sso-error-code.enum'; +import { SSOErrorCode } from '@src/modules/oauth/loggable'; import { OauthTokenResponse } from '@src/modules/oauth/service/dto'; import { ServerTestModule } from '@src/modules/server/server.module'; import axios from 'axios'; @@ -11,7 +11,7 @@ import MockAdapter from 'axios-mock-adapter'; import crypto, { KeyPairKeyObjectResult } from 'crypto'; import jwt from 'jsonwebtoken'; import request, { Response } from 'supertest'; -import { LdapAuthorizationBodyParams, LocalAuthorizationBodyParams } from '../dto'; +import { LdapAuthorizationBodyParams, LocalAuthorizationBodyParams, OauthLoginResponse } from '../dto'; const ldapAccountUserName = 'ldapAccountUserName'; const mockUserLdapDN = 'mockUserLdapDN'; @@ -129,6 +129,7 @@ describe('Login Controller (api)', () => { expect(decodedToken).toHaveProperty('accountId'); expect(decodedToken).toHaveProperty('schoolId'); expect(decodedToken).toHaveProperty('roles'); + expect(decodedToken).not.toHaveProperty('externalIdToken'); }); }); @@ -193,6 +194,7 @@ describe('Login Controller (api)', () => { expect(decodedToken).toHaveProperty('accountId'); expect(decodedToken).toHaveProperty('schoolId'); expect(decodedToken).toHaveProperty('roles'); + expect(decodedToken).not.toHaveProperty('externalIdToken'); }); }); @@ -253,10 +255,30 @@ describe('Login Controller (api)', () => { return { system, + idToken, }; }; - it('should return jwt', async () => { + it('should return oauth login response', async () => { + const { system, idToken } = await setup(); + + const response: Response = await request(app.getHttpServer()) + .post(`${basePath}/oauth2`) + .send({ + redirectUri: 'redirectUri', + code: 'code', + systemId: system.id, + }) + .expect(HttpStatus.OK); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect(response.body).toEqual({ + accessToken: expect.any(String), + externalIdToken: idToken, + }); + }); + + it('should return a valid jwt as access token', async () => { const { system } = await setup(); const response: Response = await request(app.getHttpServer()) @@ -268,8 +290,15 @@ describe('Login Controller (api)', () => { }) .expect(HttpStatus.OK); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-argument + const decodedToken = jwt.decode(response.body.accessToken); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access expect(response.body.accessToken).toBeDefined(); + expect(decodedToken).toHaveProperty('userId'); + expect(decodedToken).toHaveProperty('accountId'); + expect(decodedToken).toHaveProperty('schoolId'); + expect(decodedToken).toHaveProperty('roles'); + expect(decodedToken).not.toHaveProperty('externalIdToken'); }); }); diff --git a/apps/server/src/modules/authentication/controllers/dto/index.ts b/apps/server/src/modules/authentication/controllers/dto/index.ts index 513041f604a..69c69055f74 100644 --- a/apps/server/src/modules/authentication/controllers/dto/index.ts +++ b/apps/server/src/modules/authentication/controllers/dto/index.ts @@ -2,3 +2,4 @@ export * from './oauth2-authorization.body.params'; export * from './login.response'; export * from './ldap-authorization.body.params'; export * from './local-authorization.body.params'; +export * from './oauth-login.response'; diff --git a/apps/server/src/modules/authentication/controllers/dto/oauth-login.response.ts b/apps/server/src/modules/authentication/controllers/dto/oauth-login.response.ts new file mode 100644 index 00000000000..53bb3cc6b38 --- /dev/null +++ b/apps/server/src/modules/authentication/controllers/dto/oauth-login.response.ts @@ -0,0 +1,15 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { LoginResponse } from './login.response'; + +export class OauthLoginResponse extends LoginResponse { + @ApiPropertyOptional({ + description: + 'The external id token which is from the external oauth system and set when scope openid is available.', + }) + externalIdToken?: string; + + constructor(props: OauthLoginResponse) { + super(props); + this.externalIdToken = props.externalIdToken; + } +} diff --git a/apps/server/src/modules/authentication/controllers/login.controller.ts b/apps/server/src/modules/authentication/controllers/login.controller.ts index d6d1d1f0b40..4db3f825acf 100644 --- a/apps/server/src/modules/authentication/controllers/login.controller.ts +++ b/apps/server/src/modules/authentication/controllers/login.controller.ts @@ -3,7 +3,7 @@ import { AuthGuard } from '@nestjs/passport'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ForbiddenOperationError, ValidationError } from '@shared/common'; import { CurrentUser } from '../decorator/auth.decorator'; -import type { ICurrentUser } from '../interface'; +import type { ICurrentUser, OauthCurrentUser } from '../interface'; import { LoginDto } from '../uc/dto'; import { LoginUc } from '../uc/login.uc'; import { @@ -11,6 +11,7 @@ import { LocalAuthorizationBodyParams, LoginResponse, Oauth2AuthorizationBodyParams, + OauthLoginResponse, } from './dto'; import { LoginResponseMapper } from './mapper/login-response.mapper'; @@ -30,7 +31,7 @@ export class LoginController { async loginLdap(@CurrentUser() user: ICurrentUser, @Body() _: LdapAuthorizationBodyParams): Promise { const loginDto: LoginDto = await this.loginUc.getLoginData(user); - const mapped: LoginResponse = LoginResponseMapper.mapLoginDtoToResponse(loginDto); + const mapped: LoginResponse = LoginResponseMapper.mapToLoginResponse(loginDto); return mapped; } @@ -46,7 +47,7 @@ export class LoginController { async loginLocal(@CurrentUser() user: ICurrentUser, @Body() _: LocalAuthorizationBodyParams): Promise { const loginDto: LoginDto = await this.loginUc.getLoginData(user); - const mapped: LoginResponse = LoginResponseMapper.mapLoginDtoToResponse(loginDto); + const mapped: LoginResponse = LoginResponseMapper.mapToLoginResponse(loginDto); return mapped; } @@ -59,13 +60,13 @@ export class LoginController { @ApiResponse({ status: 400, type: ValidationError, description: 'Request data has invalid format.' }) @ApiResponse({ status: 403, type: ForbiddenOperationError, description: 'Invalid user credentials.' }) async loginOauth2( - @CurrentUser() user: ICurrentUser, + @CurrentUser() user: OauthCurrentUser, // eslint-disable-next-line @typescript-eslint/no-unused-vars @Body() _: Oauth2AuthorizationBodyParams - ): Promise { + ): Promise { const loginDto: LoginDto = await this.loginUc.getLoginData(user); - const mapped: LoginResponse = LoginResponseMapper.mapLoginDtoToResponse(loginDto); + const mapped: OauthLoginResponse = LoginResponseMapper.mapToOauthLoginResponse(loginDto, user.externalIdToken); return mapped; } diff --git a/apps/server/src/modules/authentication/controllers/mapper/login-response.mapper.ts b/apps/server/src/modules/authentication/controllers/mapper/login-response.mapper.ts index aeffbfe8960..9ff67a39095 100644 --- a/apps/server/src/modules/authentication/controllers/mapper/login-response.mapper.ts +++ b/apps/server/src/modules/authentication/controllers/mapper/login-response.mapper.ts @@ -1,9 +1,20 @@ -import { LoginResponse } from '../dto'; import { LoginDto } from '../../uc/dto'; +import { LoginResponse, OauthLoginResponse } from '../dto'; export class LoginResponseMapper { - static mapLoginDtoToResponse(loginDto: LoginDto): LoginResponse { - const response: LoginResponse = new LoginResponse({ accessToken: loginDto.accessToken }); + static mapToLoginResponse(loginDto: LoginDto): LoginResponse { + const response: LoginResponse = new LoginResponse({ + accessToken: loginDto.accessToken, + }); + + return response; + } + + static mapToOauthLoginResponse(loginDto: LoginDto, externalIdToken?: string): OauthLoginResponse { + const response: OauthLoginResponse = new OauthLoginResponse({ + accessToken: loginDto.accessToken, + externalIdToken, + }); return response; } diff --git a/apps/server/src/modules/authentication/interface/user.ts b/apps/server/src/modules/authentication/interface/user.ts index 1baa64d192b..e2874d1a8e0 100644 --- a/apps/server/src/modules/authentication/interface/user.ts +++ b/apps/server/src/modules/authentication/interface/user.ts @@ -40,3 +40,8 @@ export interface ICurrentUser { /** True if a support member impersonates the user */ impersonated?: boolean; } + +export interface OauthCurrentUser extends ICurrentUser { + /** Contains the idToken of the external idp. Will be set during oAuth2 login and used for rp initiated logout */ + externalIdToken?: string; +} diff --git a/apps/server/src/modules/authentication/mapper/current-user.mapper.spec.ts b/apps/server/src/modules/authentication/mapper/current-user.mapper.spec.ts index c89d1e3a037..09a76c1ebfb 100644 --- a/apps/server/src/modules/authentication/mapper/current-user.mapper.spec.ts +++ b/apps/server/src/modules/authentication/mapper/current-user.mapper.spec.ts @@ -2,8 +2,8 @@ import { ValidationError } from '@shared/common'; import { Permission, RoleName } from '@shared/domain'; import { UserDO } from '@shared/domain/domainobject/user.do'; import { roleFactory, schoolFactory, setupEntities, userDoFactory, userFactory } from '@shared/testing'; -import { ICurrentUser } from '../interface'; -import { JwtPayload } from '../interface/jwt-payload'; +import { ICurrentUser, OauthCurrentUser } from '../interface'; +import { CreateJwtPayload, JwtPayload } from '../interface/jwt-payload'; import { CurrentUserMapper } from './current-user.mapper'; describe('CurrentUserMapper', () => { @@ -56,40 +56,89 @@ describe('CurrentUserMapper', () => { }); }); - describe('userDoToICurrentUser', () => { - const userId = 'mockUserId'; + describe('OauthCurrentUser', () => { + const userIdMock = 'mockUserId'; describe('when userDO has no ID', () => { it('should throw error', () => { const user: UserDO = userDoFactory.build({ createdAt: new Date(), updatedAt: new Date() }); - expect(() => CurrentUserMapper.userDoToICurrentUser(accountId, user)).toThrow(ValidationError); + expect(() => CurrentUserMapper.mapToOauthCurrentUser(accountId, user, undefined, 'idToken')).toThrow( + ValidationError + ); }); }); describe('when userDO is valid', () => { - it('should return valid ICurrentUser instance', () => { - const user: UserDO = userDoFactory.buildWithId({ id: userId, createdAt: new Date(), updatedAt: new Date() }); - const currentUser = CurrentUserMapper.userDoToICurrentUser(accountId, user); - expect(currentUser).toMatchObject({ + const setup = () => { + const user: UserDO = userDoFactory.buildWithId({ + id: userIdMock, + createdAt: new Date(), + updatedAt: new Date(), + }); + const idToken = 'idToken'; + + return { + user, + userId: user.id as string, + idToken, + }; + }; + + it('should return valid oauth current user instance', () => { + const { user, userId, idToken } = setup(); + + const currentUser: OauthCurrentUser = CurrentUserMapper.mapToOauthCurrentUser( + accountId, + user, + undefined, + idToken + ); + + expect(currentUser).toMatchObject({ accountId, systemId: undefined, roles: [], schoolId: user.schoolId, - userId: user.id, + userId, + externalIdToken: idToken, }); }); }); describe('when userDO is valid and a systemId is provided', () => { - it('should return valid ICurrentUser instance with systemId', () => { - const user: UserDO = userDoFactory.buildWithId({ id: userId, createdAt: new Date(), updatedAt: new Date() }); + const setup = () => { + const user: UserDO = userDoFactory.buildWithId({ + id: userIdMock, + createdAt: new Date(), + updatedAt: new Date(), + }); const systemId = 'mockSystemId'; - const currentUser = CurrentUserMapper.userDoToICurrentUser(accountId, user, systemId); - expect(currentUser).toMatchObject({ + const idToken = 'idToken'; + + return { + user, + userId: user.id as string, + idToken, + systemId, + }; + }; + + it('should return valid ICurrentUser instance with systemId', () => { + const { user, userId, systemId, idToken } = setup(); + + const currentUser: OauthCurrentUser = CurrentUserMapper.mapToOauthCurrentUser( + accountId, + user, + systemId, + idToken + ); + + expect(currentUser).toMatchObject({ accountId, systemId, roles: [], schoolId: user.schoolId, - userId: user.id, + userId, + externalIdToken: idToken, }); }); }); @@ -104,7 +153,7 @@ describe('CurrentUserMapper', () => { }, ]) .buildWithId({ - id: userId, + id: userIdMock, createdAt: new Date(), updatedAt: new Date(), }); @@ -117,7 +166,7 @@ describe('CurrentUserMapper', () => { it('should return valid ICurrentUser instance without systemId', () => { const { user } = setup(); - const currentUser = CurrentUserMapper.userDoToICurrentUser(accountId, user); + const currentUser = CurrentUserMapper.mapToOauthCurrentUser(accountId, user, undefined, 'idToken'); expect(currentUser).toMatchObject({ accountId, @@ -158,6 +207,7 @@ describe('CurrentUserMapper', () => { }); }); }); + describe('when JWT is provided without optional claims', () => { it('should return current user', () => { const jwtPayload: JwtPayload = { @@ -182,4 +232,28 @@ describe('CurrentUserMapper', () => { }); }); }); + + describe('mapCurrentUserToCreateJwtPayload', () => { + it('should map current user to create jwt payload', () => { + const currentUser: ICurrentUser = { + accountId: 'dummyAccountId', + systemId: 'dummySystemId', + roles: ['mockRoleId'], + schoolId: 'dummySchoolId', + userId: 'dummyUserId', + impersonated: true, + }; + + const createJwtPayload: CreateJwtPayload = CurrentUserMapper.mapCurrentUserToCreateJwtPayload(currentUser); + + expect(createJwtPayload).toMatchObject({ + accountId: currentUser.accountId, + systemId: currentUser.systemId, + roles: currentUser.roles, + schoolId: currentUser.schoolId, + userId: currentUser.userId, + support: currentUser.impersonated, + }); + }); + }); }); diff --git a/apps/server/src/modules/authentication/mapper/current-user.mapper.ts b/apps/server/src/modules/authentication/mapper/current-user.mapper.ts index d4eb31fbede..80ca91b56b0 100644 --- a/apps/server/src/modules/authentication/mapper/current-user.mapper.ts +++ b/apps/server/src/modules/authentication/mapper/current-user.mapper.ts @@ -2,8 +2,8 @@ import { ValidationError } from '@shared/common'; import { Role, User } from '@shared/domain'; import { RoleReference } from '@shared/domain/domainobject'; import { UserDO } from '@shared/domain/domainobject/user.do'; -import { ICurrentUser } from '../interface'; -import { JwtPayload } from '../interface/jwt-payload'; +import { ICurrentUser, OauthCurrentUser } from '../interface'; +import { CreateJwtPayload, JwtPayload } from '../interface/jwt-payload'; export class CurrentUserMapper { static userToICurrentUser(accountId: string, user: User, systemId?: string): ICurrentUser { @@ -16,7 +16,12 @@ export class CurrentUserMapper { }; } - static userDoToICurrentUser(accountId: string, user: UserDO, systemId?: string): ICurrentUser { + static mapToOauthCurrentUser( + accountId: string, + user: UserDO, + systemId?: string, + externalIdToken?: string + ): OauthCurrentUser { if (!user.id) { throw new ValidationError('user has no ID'); } @@ -27,6 +32,18 @@ export class CurrentUserMapper { roles: user.roles.map((roleRef: RoleReference) => roleRef.id), schoolId: user.schoolId, userId: user.id, + externalIdToken, + }; + } + + static mapCurrentUserToCreateJwtPayload(currentUser: ICurrentUser): CreateJwtPayload { + return { + accountId: currentUser.accountId, + userId: currentUser.userId, + schoolId: currentUser.schoolId, + roles: currentUser.roles, + systemId: currentUser.systemId, + support: currentUser.impersonated, }; } diff --git a/apps/server/src/modules/authentication/strategy/oauth2.strategy.spec.ts b/apps/server/src/modules/authentication/strategy/oauth2.strategy.spec.ts index 01feb93b044..722ba125574 100644 --- a/apps/server/src/modules/authentication/strategy/oauth2.strategy.spec.ts +++ b/apps/server/src/modules/authentication/strategy/oauth2.strategy.spec.ts @@ -9,7 +9,7 @@ import { AccountDto } from '@src/modules/account/services/dto'; import { OAuthTokenDto } from '@src/modules/oauth'; import { OAuthService } from '@src/modules/oauth/service/oauth.service'; import { SchoolInMigrationError } from '../errors/school-in-migration.error'; -import { ICurrentUser } from '../interface'; +import { ICurrentUser, OauthCurrentUser } from '../interface'; import { Oauth2Strategy } from './oauth2.strategy'; describe('Oauth2Strategy', () => { @@ -60,9 +60,10 @@ describe('Oauth2Strategy', () => { username: 'username', }); + const idToken = 'idToken'; oauthService.authenticateUser.mockResolvedValue( new OAuthTokenDto({ - idToken: 'idToken', + idToken, accessToken: 'accessToken', refreshToken: 'refreshToken', }) @@ -70,22 +71,23 @@ describe('Oauth2Strategy', () => { oauthService.provisionUser.mockResolvedValue({ user, redirect: '' }); accountService.findByUserId.mockResolvedValue(account); - return { systemId, user, account }; + return { systemId, user, account, idToken }; }; it('should return the ICurrentUser', async () => { - const { systemId, user, account } = setup(); + const { systemId, user, account, idToken } = setup(); const result: ICurrentUser = await strategy.validate({ body: { code: 'code', redirectUri: 'redirectUri', systemId }, }); - expect(result).toEqual({ + expect(result).toEqual({ systemId, userId: user.id as EntityId, roles: [user.roles[0].id], schoolId: user.schoolId, accountId: account.id, + externalIdToken: idToken, }); }); }); diff --git a/apps/server/src/modules/authentication/strategy/oauth2.strategy.ts b/apps/server/src/modules/authentication/strategy/oauth2.strategy.ts index 2d774594a3d..6d532538e68 100644 --- a/apps/server/src/modules/authentication/strategy/oauth2.strategy.ts +++ b/apps/server/src/modules/authentication/strategy/oauth2.strategy.ts @@ -8,7 +8,7 @@ import { OAuthService } from '@src/modules/oauth/service/oauth.service'; import { Strategy } from 'passport-custom'; import { Oauth2AuthorizationBodyParams } from '../controllers/dto'; import { SchoolInMigrationError } from '../errors/school-in-migration.error'; -import { ICurrentUser } from '../interface'; +import { ICurrentUser, OauthCurrentUser } from '../interface'; import { CurrentUserMapper } from '../mapper'; @Injectable() @@ -37,7 +37,12 @@ export class Oauth2Strategy extends PassportStrategy(Strategy, 'oauth2') { throw new UnauthorizedException('no account found'); } - const currentUser: ICurrentUser = CurrentUserMapper.userDoToICurrentUser(account.id, user, systemId); + const currentUser: OauthCurrentUser = CurrentUserMapper.mapToOauthCurrentUser( + account.id, + user, + systemId, + tokenDto.idToken + ); return currentUser; } diff --git a/apps/server/src/modules/authentication/uc/login.uc.spec.ts b/apps/server/src/modules/authentication/uc/login.uc.spec.ts index 2a6b3ab12b9..a14b741ae88 100644 --- a/apps/server/src/modules/authentication/uc/login.uc.spec.ts +++ b/apps/server/src/modules/authentication/uc/login.uc.spec.ts @@ -1,6 +1,5 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; -import { CreateJwtPayload } from '../interface/jwt-payload'; import { AuthenticationService } from '../services/authentication.service'; import { LoginDto } from './dto'; import { LoginUc } from './login.uc'; @@ -29,7 +28,15 @@ describe('LoginUc', () => { describe('getLoginData', () => { describe('when userInfo is given', () => { const setup = () => { - const userInfo: CreateJwtPayload = { accountId: '', roles: [], schoolId: '', userId: '' }; + const userInfo = { + accountId: '', + roles: [], + schoolId: '', + userId: '', + systemId: '', + impersonated: false, + someProperty: 'shouldNotBeMapped', + }; const loginDto: LoginDto = new LoginDto({ accessToken: 'accessToken' }); authenticationService.generateJwt.mockResolvedValue(loginDto); @@ -44,7 +51,14 @@ describe('LoginUc', () => { await loginUc.getLoginData(userInfo); - expect(authenticationService.generateJwt).toHaveBeenCalledWith(userInfo); + expect(authenticationService.generateJwt).toHaveBeenCalledWith({ + accountId: userInfo.accountId, + userId: userInfo.userId, + schoolId: userInfo.schoolId, + roles: userInfo.roles, + systemId: userInfo.systemId, + support: userInfo.impersonated, + }); }); it('should return a loginDto', async () => { diff --git a/apps/server/src/modules/authentication/uc/login.uc.ts b/apps/server/src/modules/authentication/uc/login.uc.ts index f6ce6fbdd16..2a6404b0a87 100644 --- a/apps/server/src/modules/authentication/uc/login.uc.ts +++ b/apps/server/src/modules/authentication/uc/login.uc.ts @@ -1,14 +1,18 @@ import { Injectable } from '@nestjs/common'; -import { AuthenticationService } from '../services/authentication.service'; +import { ICurrentUser } from '../interface'; import { CreateJwtPayload } from '../interface/jwt-payload'; +import { CurrentUserMapper } from '../mapper'; +import { AuthenticationService } from '../services/authentication.service'; import { LoginDto } from './dto'; @Injectable() export class LoginUc { constructor(private readonly authService: AuthenticationService) {} - async getLoginData(userInfo: CreateJwtPayload): Promise { - const accessTokenDto: LoginDto = await this.authService.generateJwt(userInfo); + async getLoginData(userInfo: ICurrentUser): Promise { + const createJwtPayload: CreateJwtPayload = CurrentUserMapper.mapCurrentUserToCreateJwtPayload(userInfo); + + const accessTokenDto: LoginDto = await this.authService.generateJwt(createJwtPayload); const loginDto: LoginDto = new LoginDto({ accessToken: accessTokenDto.accessToken, diff --git a/apps/server/src/modules/oauth/controller/oauth-sso.controller.ts b/apps/server/src/modules/oauth/controller/oauth-sso.controller.ts index 934f7c26dc6..3c30984cc7a 100644 --- a/apps/server/src/modules/oauth/controller/oauth-sso.controller.ts +++ b/apps/server/src/modules/oauth/controller/oauth-sso.controller.ts @@ -21,7 +21,7 @@ import { HydraOauthUc } from '@src/modules/oauth/uc/hydra-oauth.uc'; import { OAuthMigrationError } from '@src/modules/user-login-migration/error/oauth-migration.error'; import { MigrationDto } from '@src/modules/user-login-migration/service/dto'; import { CookieOptions, Request, Response } from 'express'; -import { OAuthSSOError } from '../error/oauth-sso.error'; +import { OAuthSSOError } from '../loggable/oauth-sso.error'; import { OAuthTokenDto } from '../interface'; import { OauthLoginStateMapper } from '../mapper/oauth-login-state.mapper'; import { UserMigrationMapper } from '../mapper/user-migration.mapper'; diff --git a/apps/server/src/modules/oauth/error/index.ts b/apps/server/src/modules/oauth/loggable/index.ts similarity index 100% rename from apps/server/src/modules/oauth/error/index.ts rename to apps/server/src/modules/oauth/loggable/index.ts diff --git a/apps/server/src/modules/oauth/error/oauth-sso.error.spec.ts b/apps/server/src/modules/oauth/loggable/oauth-sso.error.spec.ts similarity index 100% rename from apps/server/src/modules/oauth/error/oauth-sso.error.spec.ts rename to apps/server/src/modules/oauth/loggable/oauth-sso.error.spec.ts diff --git a/apps/server/src/modules/oauth/error/oauth-sso.error.ts b/apps/server/src/modules/oauth/loggable/oauth-sso.error.ts similarity index 100% rename from apps/server/src/modules/oauth/error/oauth-sso.error.ts rename to apps/server/src/modules/oauth/loggable/oauth-sso.error.ts diff --git a/apps/server/src/modules/oauth/error/sso-error-code.enum.ts b/apps/server/src/modules/oauth/loggable/sso-error-code.enum.ts similarity index 100% rename from apps/server/src/modules/oauth/error/sso-error-code.enum.ts rename to apps/server/src/modules/oauth/loggable/sso-error-code.enum.ts diff --git a/apps/server/src/modules/oauth/error/user-not-found-after-provisioning.loggable-exception.spec.ts b/apps/server/src/modules/oauth/loggable/user-not-found-after-provisioning.loggable-exception.spec.ts similarity index 100% rename from apps/server/src/modules/oauth/error/user-not-found-after-provisioning.loggable-exception.spec.ts rename to apps/server/src/modules/oauth/loggable/user-not-found-after-provisioning.loggable-exception.spec.ts diff --git a/apps/server/src/modules/oauth/error/user-not-found-after-provisioning.loggable-exception.ts b/apps/server/src/modules/oauth/loggable/user-not-found-after-provisioning.loggable-exception.ts similarity index 100% rename from apps/server/src/modules/oauth/error/user-not-found-after-provisioning.loggable-exception.ts rename to apps/server/src/modules/oauth/loggable/user-not-found-after-provisioning.loggable-exception.ts diff --git a/apps/server/src/modules/oauth/service/oauth-adapter.service.spec.ts b/apps/server/src/modules/oauth/service/oauth-adapter.service.spec.ts index 897ca989c04..12c0a381d8b 100644 --- a/apps/server/src/modules/oauth/service/oauth-adapter.service.spec.ts +++ b/apps/server/src/modules/oauth/service/oauth-adapter.service.spec.ts @@ -2,10 +2,9 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { HttpService } from '@nestjs/axios'; import { Test, TestingModule } from '@nestjs/testing'; import { axiosResponseFactory } from '@shared/testing'; -import { LegacyLogger } from '@src/core/logger'; import { of, throwError } from 'rxjs'; -import { OAuthSSOError } from '../error/oauth-sso.error'; import { OAuthGrantType } from '../interface/oauth-grant-type.enum'; +import { OAuthSSOError } from '../loggable'; import { AuthenticationCodeGrantTokenRequest, OauthTokenResponse } from './dto'; import { OauthAdapterService } from './oauth-adapter.service'; @@ -44,10 +43,6 @@ describe('OauthAdapterServive', () => { provide: HttpService, useValue: createMock(), }, - { - provide: LegacyLogger, - useValue: createMock(), - }, ], }).compile(); service = module.get(OauthAdapterService); diff --git a/apps/server/src/modules/oauth/service/oauth-adapter.service.ts b/apps/server/src/modules/oauth/service/oauth-adapter.service.ts index f48982e051e..6b008b610cf 100644 --- a/apps/server/src/modules/oauth/service/oauth-adapter.service.ts +++ b/apps/server/src/modules/oauth/service/oauth-adapter.service.ts @@ -1,18 +1,15 @@ import { HttpService } from '@nestjs/axios'; import { Injectable } from '@nestjs/common/decorators'; -import { LegacyLogger } from '@src/core/logger'; import { AxiosResponse } from 'axios'; import JwksRsa from 'jwks-rsa'; import QueryString from 'qs'; import { lastValueFrom, Observable } from 'rxjs'; -import { OAuthSSOError } from '../error/oauth-sso.error'; +import { OAuthSSOError } from '../loggable'; import { AuthenticationCodeGrantTokenRequest, OauthTokenResponse } from './dto'; @Injectable() export class OauthAdapterService { - constructor(private readonly httpService: HttpService, private readonly logger: LegacyLogger) { - this.logger.setContext(OauthAdapterService.name); - } + constructor(private readonly httpService: HttpService) {} async getPublicKey(jwksUri: string): Promise { const client: JwksRsa.JwksClient = JwksRsa({ 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 5394070d273..81bb7724dbd 100644 --- a/apps/server/src/modules/oauth/service/oauth.service.spec.ts +++ b/apps/server/src/modules/oauth/service/oauth.service.spec.ts @@ -16,7 +16,7 @@ import { SystemService } from '@src/modules/system/service/system.service'; import { UserService } from '@src/modules/user'; import { MigrationCheckService, UserMigrationService } from '@src/modules/user-login-migration'; import jwt, { JwtPayload } from 'jsonwebtoken'; -import { OAuthSSOError, UserNotFoundAfterProvisioningLoggableException } from '../error'; +import { OAuthSSOError, UserNotFoundAfterProvisioningLoggableException } from '../loggable'; import { OAuthTokenDto } from '../interface'; import { OauthTokenResponse } from './dto'; import { OauthAdapterService } from './oauth-adapter.service'; diff --git a/apps/server/src/modules/oauth/service/oauth.service.ts b/apps/server/src/modules/oauth/service/oauth.service.ts index afef7f113e5..4445438b11f 100644 --- a/apps/server/src/modules/oauth/service/oauth.service.ts +++ b/apps/server/src/modules/oauth/service/oauth.service.ts @@ -12,7 +12,7 @@ import { SystemDto } from '@src/modules/system/service'; import { UserService } from '@src/modules/user'; import { MigrationCheckService, UserMigrationService } from '@src/modules/user-login-migration'; import jwt, { JwtPayload } from 'jsonwebtoken'; -import { OAuthSSOError, SSOErrorCode, UserNotFoundAfterProvisioningLoggableException } from '../error'; +import { OAuthSSOError, SSOErrorCode, UserNotFoundAfterProvisioningLoggableException } from '../loggable'; import { OAuthTokenDto } from '../interface'; import { TokenRequestMapper } from '../mapper/token-request.mapper'; import { AuthenticationCodeGrantTokenRequest, OauthTokenResponse } from './dto'; @@ -172,7 +172,7 @@ export class OAuthService { const system: SystemDto = await this.systemService.findById(systemId); let redirect: string; - if (system.oauthConfig?.provider === 'iserv') { + if (system.oauthConfig?.provider === 'iserv' && system.oauthConfig?.logoutEndpoint) { const iservLogoutUrl: URL = new URL(system.oauthConfig.logoutEndpoint); iservLogoutUrl.searchParams.append('id_token_hint', idToken); iservLogoutUrl.searchParams.append('post_logout_redirect_uri', postLoginRedirect || dashboardUrl.toString()); diff --git a/apps/server/src/modules/oauth/uc/hydra-oauth.uc.spec.ts b/apps/server/src/modules/oauth/uc/hydra-oauth.uc.spec.ts index ffcc9abc393..4736e1905d1 100644 --- a/apps/server/src/modules/oauth/uc/hydra-oauth.uc.spec.ts +++ b/apps/server/src/modules/oauth/uc/hydra-oauth.uc.spec.ts @@ -13,7 +13,7 @@ import { AxiosResponse } from 'axios'; import { HydraOauthUc } from '.'; import { AuthorizationParams } from '../controller/dto'; import { StatelessAuthorizationParams } from '../controller/dto/stateless-authorization.params'; -import { OAuthSSOError } from '../error/oauth-sso.error'; +import { OAuthSSOError } from '../loggable/oauth-sso.error'; import { OAuthTokenDto } from '../interface'; class HydraOauthUcSpec extends HydraOauthUc { diff --git a/apps/server/src/modules/oauth/uc/hydra-oauth.uc.ts b/apps/server/src/modules/oauth/uc/hydra-oauth.uc.ts index d042c037df5..7889ad31def 100644 --- a/apps/server/src/modules/oauth/uc/hydra-oauth.uc.ts +++ b/apps/server/src/modules/oauth/uc/hydra-oauth.uc.ts @@ -5,7 +5,7 @@ import { LegacyLogger } from '@src/core/logger'; import { HydraRedirectDto } from '@src/modules/oauth/service/dto/hydra.redirect.dto'; import { AxiosRequestConfig, AxiosResponse } from 'axios'; import { AuthorizationParams } from '../controller/dto'; -import { OAuthSSOError } from '../error/oauth-sso.error'; +import { OAuthSSOError } from '../loggable/oauth-sso.error'; import { OAuthTokenDto } from '../interface'; import { HydraSsoService } from '../service/hydra.service'; import { OAuthService } from '../service/oauth.service'; diff --git a/apps/server/src/modules/oauth/uc/oauth.uc.spec.ts b/apps/server/src/modules/oauth/uc/oauth.uc.spec.ts index dba6cca003c..15907d5dde2 100644 --- a/apps/server/src/modules/oauth/uc/oauth.uc.spec.ts +++ b/apps/server/src/modules/oauth/uc/oauth.uc.spec.ts @@ -8,7 +8,6 @@ import { legacySchoolDoFactory, setupEntities } from '@shared/testing'; import { LegacyLogger } from '@src/core/logger'; import { ICurrentUser } from '@src/modules/authentication'; import { AuthenticationService } from '@src/modules/authentication/services/authentication.service'; -import { OAuthSSOError } from '@src/modules/oauth/error/oauth-sso.error'; import { OauthUc } from '@src/modules/oauth/uc/oauth.uc'; import { ProvisioningService } from '@src/modules/provisioning'; import { ExternalUserDto, OauthDataDto, ProvisioningSystemDto } from '@src/modules/provisioning/dto'; @@ -20,6 +19,7 @@ import { UserMigrationService } from '@src/modules/user-login-migration'; import { OAuthMigrationError } from '@src/modules/user-login-migration/error/oauth-migration.error'; import { SchoolMigrationService } from '@src/modules/user-login-migration/service'; import { MigrationDto } from '@src/modules/user-login-migration/service/dto'; +import { OAuthSSOError } from '../loggable/oauth-sso.error'; import { AuthorizationParams } from '../controller/dto'; import { OAuthTokenDto } from '../interface'; import { OAuthProcessDto } from '../service/dto'; diff --git a/apps/server/src/modules/provisioning/strategy/iserv/iserv.strategy.spec.ts b/apps/server/src/modules/provisioning/strategy/iserv/iserv.strategy.spec.ts index 0926ae7e3a7..fad80012e06 100644 --- a/apps/server/src/modules/provisioning/strategy/iserv/iserv.strategy.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/iserv/iserv.strategy.spec.ts @@ -4,10 +4,10 @@ import { Test, TestingModule } from '@nestjs/testing'; import { LegacySchoolDo, RoleName, User, UserDO } from '@shared/domain'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; import { legacySchoolDoFactory, schoolFactory, setupEntities, userDoFactory, userFactory } from '@shared/testing'; -import { OAuthSSOError } from '@src/modules/oauth/error/oauth-sso.error'; import { LegacySchoolService } from '@src/modules/legacy-school'; import { UserService } from '@src/modules/user'; import jwt from 'jsonwebtoken'; +import { OAuthSSOError } from '@src/modules/oauth/loggable'; import { RoleDto } from '../../../role/service/dto/role.dto'; import { ExternalSchoolDto, diff --git a/apps/server/src/modules/provisioning/strategy/iserv/iserv.strategy.ts b/apps/server/src/modules/provisioning/strategy/iserv/iserv.strategy.ts index 71ea2972b54..4a449b9287f 100644 --- a/apps/server/src/modules/provisioning/strategy/iserv/iserv.strategy.ts +++ b/apps/server/src/modules/provisioning/strategy/iserv/iserv.strategy.ts @@ -1,10 +1,10 @@ import { Injectable } from '@nestjs/common'; import { LegacySchoolDo, RoleName, RoleReference, User, UserDO } from '@shared/domain'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { OAuthSSOError } from '@src/modules/oauth/error/oauth-sso.error'; import { LegacySchoolService } from '@src/modules/legacy-school'; import { UserService } from '@src/modules/user'; import jwt, { JwtPayload } from 'jsonwebtoken'; +import { OAuthSSOError } from '@src/modules/oauth/loggable'; import { ExternalSchoolDto, ExternalUserDto, diff --git a/apps/server/src/modules/provisioning/strategy/oidc-mock/oidc-mock.strategy.spec.ts b/apps/server/src/modules/provisioning/strategy/oidc-mock/oidc-mock.strategy.spec.ts index 37ca542a6d7..c6cff205686 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc-mock/oidc-mock.strategy.spec.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc-mock/oidc-mock.strategy.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { OAuthSSOError } from '@src/modules/oauth/error/oauth-sso.error'; import jwt from 'jsonwebtoken'; +import { OAuthSSOError } from '@src/modules/oauth/loggable'; import { ExternalUserDto, OauthDataDto, diff --git a/apps/server/src/modules/provisioning/strategy/oidc-mock/oidc-mock.strategy.ts b/apps/server/src/modules/provisioning/strategy/oidc-mock/oidc-mock.strategy.ts index 1d944bb0c8f..5daa69ed7d2 100644 --- a/apps/server/src/modules/provisioning/strategy/oidc-mock/oidc-mock.strategy.ts +++ b/apps/server/src/modules/provisioning/strategy/oidc-mock/oidc-mock.strategy.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { SystemProvisioningStrategy } from '@shared/domain/interface/system-provisioning.strategy'; -import { OAuthSSOError } from '@src/modules/oauth/error/oauth-sso.error'; import jwt, { JwtPayload } from 'jsonwebtoken'; +import { OAuthSSOError } from '@src/modules/oauth/loggable'; import { ExternalUserDto, OauthDataDto, OauthDataStrategyInputDto, ProvisioningDto } from '../../dto'; import { ProvisioningStrategy } from '../base.strategy'; diff --git a/apps/server/src/modules/system/controller/dto/oauth-config.response.ts b/apps/server/src/modules/system/controller/dto/oauth-config.response.ts index 1d5f98a7c6d..f2649b75f9a 100644 --- a/apps/server/src/modules/system/controller/dto/oauth-config.response.ts +++ b/apps/server/src/modules/system/controller/dto/oauth-config.response.ts @@ -66,10 +66,10 @@ export class OauthConfigResponse { @ApiProperty({ description: 'Logout endpoint', - required: true, + required: false, nullable: false, }) - logoutEndpoint: string; + logoutEndpoint?: string; @ApiProperty({ description: 'Issuer', @@ -95,7 +95,7 @@ export class OauthConfigResponse { jwksEndpoint: string; authEndpoint: string; scope: string; - logoutEndpoint: string; + logoutEndpoint?: string; grantType: string; issuer: string; }) { diff --git a/apps/server/src/modules/system/service/dto/oauth-config.dto.ts b/apps/server/src/modules/system/service/dto/oauth-config.dto.ts index 8ee1605bee2..7af97200971 100644 --- a/apps/server/src/modules/system/service/dto/oauth-config.dto.ts +++ b/apps/server/src/modules/system/service/dto/oauth-config.dto.ts @@ -19,7 +19,10 @@ export class OauthConfigDto { provider: string; - logoutEndpoint: string; + /** + * If this is set it will be used to redirect the user after login to the logout endpoint of the identity provider. + */ + logoutEndpoint?: string; issuer: string; diff --git a/apps/server/src/modules/user-login-migration/error/oauth-migration.error.ts b/apps/server/src/modules/user-login-migration/error/oauth-migration.error.ts index 23d6b58c0fe..06f0c0c235e 100644 --- a/apps/server/src/modules/user-login-migration/error/oauth-migration.error.ts +++ b/apps/server/src/modules/user-login-migration/error/oauth-migration.error.ts @@ -1,4 +1,4 @@ -import { OAuthSSOError } from '@src/modules/oauth/error/oauth-sso.error'; +import { OAuthSSOError } from '@src/modules/oauth/loggable'; export class OAuthMigrationError extends OAuthSSOError { readonly message: string; diff --git a/apps/server/src/shared/domain/entity/system.entity.ts b/apps/server/src/shared/domain/entity/system.entity.ts index 8f1b5821fbd..0633e515589 100644 --- a/apps/server/src/shared/domain/entity/system.entity.ts +++ b/apps/server/src/shared/domain/entity/system.entity.ts @@ -62,8 +62,8 @@ export class OauthConfig { @Property() provider: string; - @Property() - logoutEndpoint: string; + @Property({ nullable: true }) + logoutEndpoint?: string; @Property() issuer: string; diff --git a/backup/setup/migrations.json b/backup/setup/migrations.json index 96c0f276522..4cec0040475 100644 --- a/backup/setup/migrations.json +++ b/backup/setup/migrations.json @@ -306,5 +306,16 @@ "$date": "2023-09-01T13:14:13.453Z" }, "__v": 0 + }, + { + "_id": { + "$oid": "652686eb35521c3d90686845" + }, + "state": "up", + "name": "remove-moin-schule-logout-endpoint", + "createdAt": { + "$date": "2023-10-11T10:40:18.782Z" + }, + "__v": 0 } ] diff --git a/migrations/1697020818782-remove-moin-schule-logout-endpoint.js b/migrations/1697020818782-remove-moin-schule-logout-endpoint.js new file mode 100644 index 00000000000..793db440b46 --- /dev/null +++ b/migrations/1697020818782-remove-moin-schule-logout-endpoint.js @@ -0,0 +1,87 @@ +const mongoose = require('mongoose'); +// eslint-disable-next-line no-unused-vars +const { alert, error } = require('../src/logger'); + +const { connect, close } = require('../src/utils/database'); + +const Systems = mongoose.model( + 'system2023101111140', + new mongoose.Schema( + { + alias: { type: String }, + oauthConfig: { + type: { + clientId: { type: String, required: true }, + clientSecret: { type: String, required: true }, + grantType: { type: String, required: true }, + redirectUri: { type: String, required: true }, + scope: { type: String, required: true }, + responseType: { type: String, required: true }, + authEndpoint: { type: String, required: true }, + provider: { type: String, required: true }, + logoutEndpoint: { type: String, required: false }, + issuer: { type: String, required: true }, + jwksEndpoint: { type: String, required: true }, + }, + required: false, + }, + }, + { + timestamps: true, + } + ), + 'systems' +); + +module.exports = { + up: async function up() { + await connect(); + + const result = await Systems.findOneAndUpdate( + { alias: 'SANIS' }, + { + $unset: { + 'oauthConfig.logoutEndpoint': 1, + }, + } + ) + .lean() + .exec(); + + if (result) { + alert(`Removed logoutEndpoint from oauthConfig of sanis/moin.schule system`); + } else { + alert('No matching document found with alias "SANIS" and logoutEndpoint'); + } + + await close(); + }, + + down: async function down() { + await connect(); + + const system = await Systems.findOne({ alias: 'SANIS' }).lean().exec(); + + if (system) { + const { authEndpoint } = system.oauthConfig; + const logoutEndpoint = authEndpoint.replace(/\/auth$/, '/logout'); + + const result = await Systems.findOneAndUpdate( + { alias: 'SANIS' }, + { + $set: { + 'oauthConfig.logoutEndpoint': logoutEndpoint, + }, + } + ) + .lean() + .exec(); + + if (result) { + alert(`Added logoutEndpoint to oauthConfig of sanis/moin.schule system`); + } + } + + await close(); + }, +};