Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

EW-1017 TSP Oauth 2.0 login #5290

Merged
merged 16 commits into from
Oct 16, 2024
Merged
3 changes: 2 additions & 1 deletion apps/server/src/modules/class/repo/classes.repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ export class ClassesRepo {
? classes.map((aclass: Class): ClassEntity => ClassMapper.mapToEntity(aclass))
: [ClassMapper.mapToEntity(classes)];

await this.em.persistAndFlush(entities);
await this.em.upsertMany(entities);
await this.em.flush();
}

async updateMany(classes: Class[]): Promise<void> {
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/modules/group/uc/class-group.uc.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ describe('ClassGroupUc', () => {
roles: [{ id: studentUser.roles[0].id, name: studentUser.roles[0].name }],
});

const startDate = schoolYearDo.getProps().startDate;
const { startDate } = schoolYearDo.getProps();
const schoolYear: SchoolYearEntity = schoolYearEntityFactory.buildWithId({ startDate });
const nextSchoolYear: SchoolYearEntity = schoolYearEntityFactory.buildWithId({
startDate: schoolYear.endDate,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ describe('TspProvisioningService', () => {
await sut.provisionUser(data, school);

expect(userServiceMock.save).toHaveBeenCalledTimes(1);
expect(accountServiceMock.saveWithValidation).toHaveBeenCalledTimes(1);
expect(accountServiceMock.save).toHaveBeenCalledTimes(1);
});
});

Expand Down Expand Up @@ -306,12 +306,6 @@ describe('TspProvisioningService', () => {

await expect(sut.provisionUser(data, school)).rejects.toThrow(BadDataLoggableException);
});

it('should throw with no email', async () => {
const { data, school } = setup(true, true, false);

await expect(sut.provisionUser(data, school)).rejects.toThrow(BadDataLoggableException);
});
});

describe('when user does not exist', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { BadDataLoggableException } from '../loggable';
export class TspProvisioningService {
private ENTITY_SOURCE = 'tsp'; // used as source attribute in created users and classes

private TSP_EMAIL_DOMAIN = 'tsp.de';

constructor(
private readonly schoolService: SchoolService,
private readonly classService: ClassService,
Expand Down Expand Up @@ -47,9 +49,7 @@ export class TspProvisioningService {
if (currentClass) {
// Case: Class exists -> update class
currentClass.schoolId = school.id;
if (clazz.name) {
currentClass.name = clazz.name;
}
currentClass.name = clazz.name ?? currentClass.name;
currentClass.year = school.currentYear?.id;
currentClass.source = this.ENTITY_SOURCE;
currentClass.sourceOptions = new ClassSourceOptions({ tspUid: clazz.externalId });
Expand Down Expand Up @@ -115,17 +115,18 @@ export class TspProvisioningService {
roleRefs: RoleReference[],
schoolId: string
): Promise<UserDO> {
if (!externalUser.firstName || !externalUser.lastName || !externalUser.email) {
throw new BadDataLoggableException('User firstname, lastname or email is missing', { externalUser });
if (!externalUser.firstName || !externalUser.lastName) {
throw new BadDataLoggableException('User firstname or lastname is missing', { externalUser });
}

const newUser = new UserDO({
roles: roleRefs,
schoolId,
firstName: externalUser.firstName,
lastName: externalUser.lastName,
email: externalUser.email,
email: this.createTspEmail(externalUser.externalId),
birthday: externalUser.birthday,
externalId: externalUser.externalId,
});
const savedUser = await this.userService.save(newUser);

Expand All @@ -137,7 +138,12 @@ export class TspProvisioningService {

const account = await this.accountService.findByUserId(user.id);

if (!account) {
if (account) {
// Updates account with new systemId and username
await account.update(new AccountSave({ userId: user.id, systemId, username: user.email, activated: true }));
await this.accountService.save(account);
} else {
// Creates new account for user
await this.accountService.saveWithValidation(
new AccountSave({
userId: user.id,
Expand All @@ -146,10 +152,6 @@ export class TspProvisioningService {
activated: true,
})
);
} else {
account.username = user.email;

await this.accountService.saveWithValidation(account);
}
}

Expand All @@ -159,4 +161,10 @@ export class TspProvisioningService {

return roleRefs;
}

private createTspEmail(externalId: string): string {
const email = `${externalId}@${this.TSP_EMAIL_DOMAIN}`;

return email.toLowerCase();
}
}
Original file line number Diff line number Diff line change
@@ -1,33 +1,34 @@
import { IsString, IsOptional, IsArray } from 'class-validator';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { JwtPayload } from 'jsonwebtoken';

export class TspJwtPayload implements JwtPayload {
@IsString()
@IsNotEmpty()
public sub!: string;

@IsOptional()
@IsString()
public sid: string | undefined;

@IsOptional()
@IsString()
public ptscListRolle: string | undefined;
@IsNotEmpty()
public ptscListRolle!: string;

@IsOptional()
@IsString()
public personVorname: string | undefined;
@IsNotEmpty()
public personVorname!: string;

@IsOptional()
@IsString()
public personNachname: string | undefined;
@IsNotEmpty()
public personNachname!: string;

@IsOptional()
@IsString()
public ptscSchuleNummer: string | undefined;
@IsNotEmpty()
public ptscSchuleNummer!: string;

@IsOptional()
@IsArray()
public ptscListKlasseId: [] | undefined;
@IsString()
@IsNotEmpty()
public ptscListKlasseId!: string;

constructor(data: Partial<TspJwtPayload>) {
Object.assign(this, data);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ describe('TspProvisioningStrategy', () => {
personVorname: 'firstName',
personNachname: 'lastName',
ptscSchuleNummer: 'externalSchoolId',
ptscListKlasseId: ['externalClassId1', 'externalClassId2'],
ptscListKlasseId: 'externalClassId1,externalClassId2',
};
});

Expand Down Expand Up @@ -151,7 +151,7 @@ describe('TspProvisioningStrategy', () => {
return {
sub: 'externalUserId',
sid: 1000,
ptscListRolle: 'teacher',
ptscListRolle: 'lehrer,admin',
personVorname: 'firstName',
personNachname: 'lastName',
ptscSchuleNummer: 'externalSchoolId',
Expand All @@ -168,6 +168,38 @@ describe('TspProvisioningStrategy', () => {
await expect(sut.getData(input)).rejects.toThrow(new IdTokenExtractionFailureLoggableException('sub'));
});
});

describe('When roles are missing or unknown', () => {
const setup = () => {
const input: OauthDataStrategyInputDto = new OauthDataStrategyInputDto({
system: new ProvisioningSystemDto({
systemId: 'externalSchoolId',
provisioningStrategy: SystemProvisioningStrategy.TSP,
}),
idToken: 'tspIdToken',
accessToken: 'tspAccessToken',
});

jest.spyOn(jwt, 'decode').mockImplementation(() => {
return {
sub: 'externalUserId',
ptscListRolle: 'user',
personVorname: 'firstName',
personNachname: 'lastName',
ptscSchuleNummer: 'externalSchoolId',
ptscListKlasseId: 'externalClassId1,externalClassId2',
};
});

return { input };
};

it('should throw IdTokenExtractionFailure', async () => {
const { input } = setup();

await expect(sut.getData(input)).rejects.toThrow(IdTokenExtractionFailureLoggableException);
});
});
});

describe('apply', () => {
Expand Down
38 changes: 27 additions & 11 deletions apps/server/src/modules/provisioning/strategy/tsp/tsp.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,6 @@ import { TspJwtPayload } from './tsp.jwt.payload';

@Injectable()
export class TspProvisioningStrategy extends ProvisioningStrategy {
RoleMapping: Record<string, RoleName> = {
lehrer: RoleName.TEACHER,
schueler: RoleName.STUDENT,
admin: RoleName.ADMINISTRATOR,
};

constructor(private readonly provisioningService: TspProvisioningService) {
super();
}
Expand All @@ -51,16 +45,23 @@ export class TspProvisioningStrategy extends ProvisioningStrategy {
externalId: payload.sub,
firstName: payload.personVorname,
lastName: payload.personNachname,
roles: (payload.ptscListRolle ?? '').split(',').map((tspRole) => this.RoleMapping[tspRole]),
roles: payload.ptscListRolle
.split(',')
.map((role) => this.mapRoles(role))
.filter(Boolean) as RoleName[],
});

if (externalUserDto.roles && externalUserDto.roles.length < 1) {
throw new IdTokenExtractionFailureLoggableException('ptscListRolle');
}

const externalSchoolDto = new ExternalSchoolDto({
externalId: payload.ptscSchuleNummer || '',
externalId: payload.ptscSchuleNummer,
});

const externalClassDtoList = (payload.ptscListKlasseId ?? []).map(
(classId: string) => new ExternalClassDto({ externalId: classId })
);
const externalClassDtoList = payload.ptscListKlasseId
.split(',')
.map((externalId) => new ExternalClassDto({ externalId }));

const oauthDataDto = new OauthDataDto({
system: input.system,
Expand All @@ -83,4 +84,19 @@ export class TspProvisioningStrategy extends ProvisioningStrategy {

return new ProvisioningDto({ externalUserId: user.externalId || data.externalUser.externalId });
}

private mapRoles(tspRole: string): RoleName | null {
const roleNameLowerCase = tspRole.toLowerCase();

switch (roleNameLowerCase) {
case 'lehrer':
return RoleName.TEACHER;
case 'schueler':
return RoleName.STUDENT;
case 'admin':
return RoleName.ADMINISTRATOR;
default:
return null;
}
}
}
Loading