Skip to content

Commit

Permalink
N21-2179 Logout by external systems via oidc (#5264)
Browse files Browse the repository at this point in the history
  • Loading branch information
MarvinOehlerkingCap authored Nov 12, 2024
1 parent 22203bb commit 9bb5d3f
Show file tree
Hide file tree
Showing 33 changed files with 1,039 additions and 60 deletions.
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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],
})
Expand Down
Original file line number Diff line number Diff line change
@@ -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],
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -61,6 +63,7 @@ const createJwtOptions = () => {
IdentityManagementModule,
CacheWrapperModule,
AuthGuardModule.register([AuthGuardOptions.JWT]),
UserModule,
HttpModule,
EncryptionModule,
],
Expand All @@ -73,7 +76,8 @@ const createJwtOptions = () => {
LdapStrategy,
Oauth2Strategy,
JwtWhitelistAdapter,
LogoutService,
],
exports: [AuthenticationService],
exports: [AuthenticationService, LogoutService],
})
export class AuthenticationTestModule {}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -59,6 +61,7 @@ const createJwtOptions = (configService: ConfigService<AuthenticationConfig>) =>
RoleModule,
IdentityManagementModule,
CacheWrapperModule,
UserModule,
HttpModule,
EncryptionModule,
],
Expand All @@ -71,7 +74,8 @@ const createJwtOptions = (configService: ConfigService<AuthenticationConfig>) =>
LdapStrategy,
Oauth2Strategy,
JwtWhitelistAdapter,
LogoutService,
],
exports: [AuthenticationService],
exports: [AuthenticationService, LogoutService],
})
export class AuthenticationModule {}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';

export class OidcLogoutBodyParams {
@IsString()
@ApiProperty()
logout_token!: string;
}
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -9,6 +9,7 @@ import {
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { LogoutUc } from '../uc';
import { OidcLogoutBodyParams } from './dto';

@ApiTags('Authentication')
@Controller('logout')
Expand All @@ -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<void> {
await this.logoutUc.logoutOidc(body.logout_token);
}

@JwtAuthentication()
@Post('/external')
@HttpCode(HttpStatus.OK)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,25 @@ 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<Cache>;
let store: DeepMocked<Store>;

beforeAll(async () => {
module = await Test.createTestingModule({
providers: [
JwtWhitelistAdapter,
{
provide: CACHE_MANAGER,
useValue: createMock<Cache>(),
useValue: createMock<Cache>({
store: (store = createMock<Store>()),
}),
},
],
}).compile();
Expand Down Expand Up @@ -81,13 +84,38 @@ 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);

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);
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,21 @@ export class JwtWhitelistAdapter {
await this.cacheManager.set(redisIdentifier, redisData, expirationInMilliseconds);
}

async removeFromWhitelist(accountId: string, jti: string): Promise<void> {
const redisIdentifier: string = createRedisIdentifierFromJwtData(accountId, jti);
async removeFromWhitelist(accountId: string, jti?: string): Promise<void> {
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<void>[] = keys.map(
async (key: string): Promise<void> => this.cacheManager.del(key)
);

await this.cacheManager.del(redisIdentifier);
await Promise.all(deleteKeysPromise);
}
}
Original file line number Diff line number Diff line change
@@ -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,
},
});
});
});
});
Original file line number Diff line number Diff line change
@@ -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,
},
};
}
}
2 changes: 2 additions & 0 deletions apps/server/src/modules/authentication/loggable/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading

0 comments on commit 9bb5d3f

Please sign in to comment.