diff --git a/apps/server/src/modules/authentication/loggable/index.ts b/apps/server/src/modules/authentication/loggable/index.ts index 2d9f2567796..f6635c532a4 100644 --- a/apps/server/src/modules/authentication/loggable/index.ts +++ b/apps/server/src/modules/authentication/loggable/index.ts @@ -3,3 +3,4 @@ export * from './school-in-migration.loggable-exception'; export * from './shd-user-create-token.loggable'; export * from './user-authenticated.loggable'; export * from './user-could-not-be-authenticated.loggable.exception'; +export { UserAccountDeactivatedLoggableException } from './user-account-deactivated-exception'; 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 a86dad25432..4d49ae5b70a 100644 --- a/apps/server/src/modules/authentication/strategy/oauth2.strategy.spec.ts +++ b/apps/server/src/modules/authentication/strategy/oauth2.strategy.spec.ts @@ -1,31 +1,30 @@ import { createMock, DeepMocked } from '@golevelup/ts-jest'; import { Account, AccountService } from '@modules/account'; import { accountDoFactory } from '@modules/account/testing'; -import { OAuthService, OAuthTokenDto } from '@modules/oauth'; +import { OAuthService, OauthSessionToken, OauthSessionTokenService, OAuthTokenDto } from '@modules/oauth'; import { Test, TestingModule } from '@nestjs/testing'; import { UserDO } from '@shared/domain/domainobject/user.do'; import { RoleName } from '@shared/domain/interface'; import { EntityId } from '@shared/domain/types'; -import { userDoFactory } from '@shared/testing'; - +import { JwtTestFactory, userDoFactory } from '@shared/testing'; import { OauthCurrentUser } from '../interface'; - -import { SchoolInMigrationLoggableException } from '../loggable'; - -import { AccountNotFoundLoggableException } from '../loggable/account-not-found.loggable-exception'; -import { UserAccountDeactivatedLoggableException } from '../loggable/user-account-deactivated-exception'; +import { + AccountNotFoundLoggableException, + SchoolInMigrationLoggableException, + UserAccountDeactivatedLoggableException, +} from '../loggable'; import { Oauth2Strategy } from './oauth2.strategy'; -describe('Oauth2Strategy', () => { +describe(Oauth2Strategy.name, () => { let module: TestingModule; let strategy: Oauth2Strategy; let accountService: DeepMocked; let oauthService: DeepMocked; + let oauthSessionTokenService: DeepMocked; beforeAll(async () => { module = await Test.createTestingModule({ - imports: [], providers: [ Oauth2Strategy, { @@ -36,12 +35,17 @@ describe('Oauth2Strategy', () => { provide: AccountService, useValue: createMock(), }, + { + provide: OauthSessionTokenService, + useValue: createMock(), + }, ], }).compile(); strategy = module.get(Oauth2Strategy); accountService = module.get(AccountService); oauthService = module.get(OAuthService); + oauthSessionTokenService = module.get(OauthSessionTokenService); }); afterAll(async () => { @@ -58,21 +62,48 @@ describe('Oauth2Strategy', () => { const systemId: EntityId = 'systemId'; const user: UserDO = userDoFactory.withRoles([{ id: 'roleId', name: RoleName.USER }]).buildWithId(); const account = accountDoFactory.build(); + const expiryDate = new Date(); - const idToken = 'idToken'; + const idToken = JwtTestFactory.createJwt(); + const refreshToken = JwtTestFactory.createJwt({ exp: expiryDate.getTime() }); oauthService.authenticateUser.mockResolvedValue( new OAuthTokenDto({ idToken, accessToken: 'accessToken', - refreshToken: 'refreshToken', + refreshToken, }) ); oauthService.provisionUser.mockResolvedValue(user); accountService.findByUserId.mockResolvedValue(account); - return { systemId, user, account, idToken }; + return { + systemId, + user, + account, + idToken, + refreshToken, + expiryDate, + }; }; + it('should cache the refresh token', async () => { + const { systemId, user, refreshToken, expiryDate } = setup(); + + await strategy.validate({ + body: { code: 'code', redirectUri: 'redirectUri', systemId }, + }); + + expect(oauthSessionTokenService.save).toHaveBeenCalledWith( + new OauthSessionToken({ + id: expect.any(String), + systemId, + userId: user.id as string, + refreshToken, + expiresAt: expiryDate, + }) + ); + }); + it('should return the ICurrentUser', async () => { const { systemId, user, account, idToken } = setup(); diff --git a/apps/server/src/modules/authentication/strategy/oauth2.strategy.ts b/apps/server/src/modules/authentication/strategy/oauth2.strategy.ts index 39e055cc9d9..eca510bc76b 100644 --- a/apps/server/src/modules/authentication/strategy/oauth2.strategy.ts +++ b/apps/server/src/modules/authentication/strategy/oauth2.strategy.ts @@ -1,33 +1,47 @@ -import { ICurrentUser } from '@infra/auth-guard'; -import { AccountService } from '@modules/account'; -import { OAuthService } from '@modules/oauth'; +import type { ICurrentUser } from '@infra/auth-guard'; +import { Account, AccountService } from '@modules/account'; +import { + OAuthService, + OauthSessionToken, + OauthSessionTokenFactory, + OauthSessionTokenService, + OAuthTokenDto, +} from '@modules/oauth'; import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; +import type { UserDO } from '@shared/domain/domainobject'; import { Strategy } from 'passport-custom'; import { Oauth2AuthorizationBodyParams } from '../controllers/dto'; import { StrategyType } from '../interface'; -import { AccountNotFoundLoggableException, SchoolInMigrationLoggableException } from '../loggable'; -import { UserAccountDeactivatedLoggableException } from '../loggable/user-account-deactivated-exception'; +import { + AccountNotFoundLoggableException, + SchoolInMigrationLoggableException, + UserAccountDeactivatedLoggableException, +} from '../loggable'; import { CurrentUserMapper } from '../mapper'; @Injectable() export class Oauth2Strategy extends PassportStrategy(Strategy, StrategyType.OAUTH2) { - constructor(private readonly oauthService: OAuthService, private readonly accountService: AccountService) { + constructor( + private readonly oauthService: OAuthService, + private readonly accountService: AccountService, + private readonly oauthSessionTokenService: OauthSessionTokenService + ) { super(); } public async validate(request: { body: Oauth2AuthorizationBodyParams }): Promise { const { systemId, redirectUri, code } = request.body; - const tokenDto = await this.oauthService.authenticateUser(systemId, redirectUri, code); + const tokenDto: OAuthTokenDto = await this.oauthService.authenticateUser(systemId, redirectUri, code); - const user = await this.oauthService.provisionUser(systemId, tokenDto.idToken, tokenDto.accessToken); + const user: UserDO | null = await this.oauthService.provisionUser(systemId, tokenDto.idToken, tokenDto.accessToken); if (!user || !user.id) { throw new SchoolInMigrationLoggableException(); } - const account = await this.accountService.findByUserId(user.id); + const account: Account | null = await this.accountService.findByUserId(user.id); if (!account) { throw new AccountNotFoundLoggableException(); } @@ -36,7 +50,20 @@ export class Oauth2Strategy extends PassportStrategy(Strategy, StrategyType.OAUT throw new UserAccountDeactivatedLoggableException(); } - const currentUser = CurrentUserMapper.mapToOauthCurrentUser(account.id, user, systemId, tokenDto.idToken); + const oauthSessionToken: OauthSessionToken = OauthSessionTokenFactory.build({ + userId: user.id, + systemId, + refreshToken: tokenDto.refreshToken, + }); + + await this.oauthSessionTokenService.save(oauthSessionToken); + + const currentUser: ICurrentUser = CurrentUserMapper.mapToOauthCurrentUser( + account.id, + user, + systemId, + tokenDto.idToken + ); return currentUser; } diff --git a/apps/server/src/modules/oauth/domain/do/index.ts b/apps/server/src/modules/oauth/domain/do/index.ts new file mode 100644 index 00000000000..022fe4b4ddb --- /dev/null +++ b/apps/server/src/modules/oauth/domain/do/index.ts @@ -0,0 +1,2 @@ +export { OauthSessionTokenProps, OauthSessionToken } from './oauth-session-token'; +export { OauthSessionTokenFactory } from './oauth-session-token.factory'; diff --git a/apps/server/src/modules/oauth/domain/do/oauth-session-token.factory.spec.ts b/apps/server/src/modules/oauth/domain/do/oauth-session-token.factory.spec.ts new file mode 100644 index 00000000000..db082308597 --- /dev/null +++ b/apps/server/src/modules/oauth/domain/do/oauth-session-token.factory.spec.ts @@ -0,0 +1,71 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { JwtTestFactory } from '@shared/testing'; +import { TokenInvalidLoggableException } from '../../loggable'; +import { OauthSessionToken } from './oauth-session-token'; +import { OauthSessionTokenFactory } from './oauth-session-token.factory'; + +describe(OauthSessionTokenFactory.name, () => { + describe('build', () => { + describe('when the refresh token is valid', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const systemId = new ObjectId().toHexString(); + const expiryDate = new Date(); + const refreshToken = JwtTestFactory.createJwt({ exp: expiryDate.getTime() }); + + return { + userId, + systemId, + expiryDate, + refreshToken, + }; + }; + + it('should create the token object', () => { + const { userId, systemId, expiryDate, refreshToken } = setup(); + + const result = OauthSessionTokenFactory.build({ + userId, + systemId, + refreshToken, + }); + + expect(result).toEqual( + new OauthSessionToken({ + id: expect.any(String), + systemId, + userId, + refreshToken, + expiresAt: expiryDate, + }) + ); + }); + }); + + describe('when the refresh token is invalid', () => { + const setup = () => { + const userId = new ObjectId().toHexString(); + const systemId = new ObjectId().toHexString(); + const refreshToken = 'invalidOidcToken'; + + return { + userId, + systemId, + refreshToken, + }; + }; + + it('should create the token object', () => { + const { userId, systemId, refreshToken } = setup(); + + expect(() => + OauthSessionTokenFactory.build({ + userId, + systemId, + refreshToken, + }) + ).toThrow(TokenInvalidLoggableException); + }); + }); + }); +}); diff --git a/apps/server/src/modules/oauth/domain/do/oauth-session-token.factory.ts b/apps/server/src/modules/oauth/domain/do/oauth-session-token.factory.ts new file mode 100644 index 00000000000..6e1ac0b0d7e --- /dev/null +++ b/apps/server/src/modules/oauth/domain/do/oauth-session-token.factory.ts @@ -0,0 +1,23 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { EntityId } from '@shared/domain/types'; +import jwt, { JwtPayload } from 'jsonwebtoken'; +import { TokenInvalidLoggableException } from '../../loggable'; +import { OauthSessionToken } from './oauth-session-token'; + +export class OauthSessionTokenFactory { + public static build(params: { userId: EntityId; systemId: EntityId; refreshToken: string }): OauthSessionToken { + const decodedRefreshToken: JwtPayload | null = jwt.decode(params.refreshToken, { json: true }); + + if (!decodedRefreshToken?.exp) { + throw new TokenInvalidLoggableException(); + } + + const oauthSessionToken = new OauthSessionToken({ + ...params, + id: new ObjectId().toHexString(), + expiresAt: new Date(decodedRefreshToken.exp), + }); + + return oauthSessionToken; + } +} diff --git a/apps/server/src/modules/oauth/domain/do/oauth-session-token.ts b/apps/server/src/modules/oauth/domain/do/oauth-session-token.ts new file mode 100644 index 00000000000..cb1330b0eda --- /dev/null +++ b/apps/server/src/modules/oauth/domain/do/oauth-session-token.ts @@ -0,0 +1,30 @@ +import { AuthorizableObject, DomainObject } from '@shared/domain/domain-object'; +import { EntityId } from '@shared/domain/types'; + +export interface OauthSessionTokenProps extends AuthorizableObject { + userId: EntityId; + + systemId: EntityId; + + refreshToken: string; + + expiresAt: Date; +} + +export class OauthSessionToken extends DomainObject { + get userId(): EntityId { + return this.props.userId; + } + + get systemId(): EntityId { + return this.props.systemId; + } + + get refreshToken(): string { + return this.props.refreshToken; + } + + get expiresAt(): Date { + return this.props.expiresAt; + } +} diff --git a/apps/server/src/modules/oauth/domain/index.ts b/apps/server/src/modules/oauth/domain/index.ts new file mode 100644 index 00000000000..71d8bad0d75 --- /dev/null +++ b/apps/server/src/modules/oauth/domain/index.ts @@ -0,0 +1 @@ +export { OauthSessionToken, OauthSessionTokenProps, OauthSessionTokenFactory } from './do'; diff --git a/apps/server/src/modules/oauth/entity/index.ts b/apps/server/src/modules/oauth/entity/index.ts new file mode 100644 index 00000000000..004679cad3b --- /dev/null +++ b/apps/server/src/modules/oauth/entity/index.ts @@ -0,0 +1 @@ +export { OauthSessionTokenEntityProps, OauthSessionTokenEntity } from './oauth-session-token.entity'; diff --git a/apps/server/src/modules/oauth/entity/oauth-session-token.entity.ts b/apps/server/src/modules/oauth/entity/oauth-session-token.entity.ts new file mode 100644 index 00000000000..d2ec63136bf --- /dev/null +++ b/apps/server/src/modules/oauth/entity/oauth-session-token.entity.ts @@ -0,0 +1,44 @@ +import { Entity, Index, ManyToOne, Property } from '@mikro-orm/core'; +import { SystemEntity } from '@modules/system/entity'; +import { BaseEntityWithTimestamps } from '@shared/domain/entity/base.entity'; +import { User } from '@shared/domain/entity/user.entity'; +import { EntityId } from '@shared/domain/types'; + +export interface OauthSessionTokenEntityProps { + id?: EntityId; + + user: User; + + system: SystemEntity; + + refreshToken: string; + + expiresAt: Date; +} + +@Entity({ tableName: 'oauth-session-token' }) +export class OauthSessionTokenEntity extends BaseEntityWithTimestamps { + @ManyToOne(() => User) + user: User; + + @ManyToOne(() => SystemEntity) + system: SystemEntity; + + @Property() + refreshToken: string; + + @Index({ options: { expireAfterSeconds: 0 } }) + @Property() + expiresAt: Date; + + constructor(props: OauthSessionTokenEntityProps) { + super(); + if (props.id) { + this.id = props.id; + } + this.user = props.user; + this.system = props.system; + this.refreshToken = props.refreshToken; + this.expiresAt = props.expiresAt; + } +} diff --git a/apps/server/src/modules/oauth/index.ts b/apps/server/src/modules/oauth/index.ts index 72bfafa518b..d586a4f8380 100644 --- a/apps/server/src/modules/oauth/index.ts +++ b/apps/server/src/modules/oauth/index.ts @@ -1,3 +1,4 @@ export * from './interface'; export * from './oauth.module'; export * from './service'; +export * from './domain'; diff --git a/apps/server/src/modules/oauth/loggable/index.ts b/apps/server/src/modules/oauth/loggable/index.ts index 9244c7a19e9..2c5677e3d83 100644 --- a/apps/server/src/modules/oauth/loggable/index.ts +++ b/apps/server/src/modules/oauth/loggable/index.ts @@ -1,7 +1,7 @@ export { AuthCodeFailureLoggableException } from './auth-code-failure-loggable-exception'; export { UserNotFoundAfterProvisioningLoggableException } from './user-not-found-after-provisioning.loggable-exception'; export { TokenRequestLoggableException } from './token-request-loggable-exception'; -export { IdTokenInvalidLoggableException } from './id-token-invalid-loggable-exception'; +export { TokenInvalidLoggableException } from './token-invalid-loggable-exception'; export { OauthConfigMissingLoggableException } from './oauth-config-missing-loggable-exception'; export { IdTokenExtractionFailureLoggableException } from './id-token-extraction-failure-loggable-exception'; export { IdTokenUserNotFoundLoggableException } from './id-token-user-not-found-loggable-exception'; diff --git a/apps/server/src/modules/oauth/loggable/id-token-invalid-loggable-exception.spec.ts b/apps/server/src/modules/oauth/loggable/token-invalid-loggable-exception.spec.ts similarity index 64% rename from apps/server/src/modules/oauth/loggable/id-token-invalid-loggable-exception.spec.ts rename to apps/server/src/modules/oauth/loggable/token-invalid-loggable-exception.spec.ts index 1fe18e735cb..388621459f8 100644 --- a/apps/server/src/modules/oauth/loggable/id-token-invalid-loggable-exception.spec.ts +++ b/apps/server/src/modules/oauth/loggable/token-invalid-loggable-exception.spec.ts @@ -1,9 +1,9 @@ -import { IdTokenInvalidLoggableException } from './id-token-invalid-loggable-exception'; +import { TokenInvalidLoggableException } from './token-invalid-loggable-exception'; -describe(IdTokenInvalidLoggableException.name, () => { +describe(TokenInvalidLoggableException.name, () => { describe('getLogMessage', () => { const setup = () => { - const exception = new IdTokenInvalidLoggableException(); + const exception = new TokenInvalidLoggableException(); return { exception }; }; diff --git a/apps/server/src/modules/oauth/loggable/id-token-invalid-loggable-exception.ts b/apps/server/src/modules/oauth/loggable/token-invalid-loggable-exception.ts similarity index 68% rename from apps/server/src/modules/oauth/loggable/id-token-invalid-loggable-exception.ts rename to apps/server/src/modules/oauth/loggable/token-invalid-loggable-exception.ts index be76b5e58d8..827c6a347ba 100644 --- a/apps/server/src/modules/oauth/loggable/id-token-invalid-loggable-exception.ts +++ b/apps/server/src/modules/oauth/loggable/token-invalid-loggable-exception.ts @@ -2,13 +2,13 @@ import { HttpStatus } from '@nestjs/common'; import { BusinessError } from '@shared/common'; import { ErrorLogMessage, Loggable, LogMessage, ValidationErrorLogMessage } from '@src/core/logger'; -export class IdTokenInvalidLoggableException extends BusinessError implements Loggable { +export class TokenInvalidLoggableException extends BusinessError implements Loggable { constructor() { super( { - type: 'ID_TOKEN_INVALID', - title: 'Id token invalid', - defaultMessage: 'Failed to validate idToken', + type: 'TOKEN_INVALID', + title: 'token invalid', + defaultMessage: 'Failed to validate token', }, HttpStatus.UNAUTHORIZED ); diff --git a/apps/server/src/modules/oauth/oauth.module.ts b/apps/server/src/modules/oauth/oauth.module.ts index d9dc4927a49..7e512fb33b4 100644 --- a/apps/server/src/modules/oauth/oauth.module.ts +++ b/apps/server/src/modules/oauth/oauth.module.ts @@ -10,7 +10,8 @@ import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; import { LtiToolRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; -import { HydraSsoService, OauthAdapterService, OAuthService } from './service'; +import { OAUTH_SESSION_TOKEN_REPO, OauthSessionTokenMikroOrmRepo } from './repo'; +import { HydraSsoService, OauthAdapterService, OAuthService, OauthSessionTokenService } from './service'; @Module({ imports: [ @@ -25,7 +26,14 @@ import { HydraSsoService, OauthAdapterService, OAuthService } from './service'; UserLoginMigrationModule, LegacySchoolModule, ], - providers: [OAuthService, OauthAdapterService, HydraSsoService, LtiToolRepo], - exports: [OAuthService, HydraSsoService, OauthAdapterService], + providers: [ + OAuthService, + OauthAdapterService, + HydraSsoService, + LtiToolRepo, + { provide: OAUTH_SESSION_TOKEN_REPO, useClass: OauthSessionTokenMikroOrmRepo }, + OauthSessionTokenService, + ], + exports: [OAuthService, HydraSsoService, OauthAdapterService, OauthSessionTokenService], }) export class OauthModule {} diff --git a/apps/server/src/modules/oauth/repo/index.ts b/apps/server/src/modules/oauth/repo/index.ts new file mode 100644 index 00000000000..df3af9ecbef --- /dev/null +++ b/apps/server/src/modules/oauth/repo/index.ts @@ -0,0 +1,2 @@ +export { OAUTH_SESSION_TOKEN_REPO, OauthSessionTokenRepo } from './oauth-session-token.repo.interface'; +export { OauthSessionTokenMikroOrmRepo } from './mikro-orm'; diff --git a/apps/server/src/modules/oauth/repo/mikro-orm/index.ts b/apps/server/src/modules/oauth/repo/mikro-orm/index.ts new file mode 100644 index 00000000000..8a9c014c917 --- /dev/null +++ b/apps/server/src/modules/oauth/repo/mikro-orm/index.ts @@ -0,0 +1 @@ +export { OauthSessionTokenMikroOrmRepo } from './oauth-session-token.repo'; diff --git a/apps/server/src/modules/oauth/repo/mikro-orm/mapper/index.ts b/apps/server/src/modules/oauth/repo/mikro-orm/mapper/index.ts new file mode 100644 index 00000000000..dfb0ae8d29b --- /dev/null +++ b/apps/server/src/modules/oauth/repo/mikro-orm/mapper/index.ts @@ -0,0 +1 @@ +export { OauthSessionTokenEntityMapper } from './oauth-session-token-entity.mapper'; diff --git a/apps/server/src/modules/oauth/repo/mikro-orm/mapper/oauth-session-token-entity.mapper.ts b/apps/server/src/modules/oauth/repo/mikro-orm/mapper/oauth-session-token-entity.mapper.ts new file mode 100644 index 00000000000..4f6677b05a5 --- /dev/null +++ b/apps/server/src/modules/oauth/repo/mikro-orm/mapper/oauth-session-token-entity.mapper.ts @@ -0,0 +1,34 @@ +import { EntityManager } from '@mikro-orm/mongodb'; +import { SystemEntity } from '@modules/system/entity'; +import { User } from '@shared/domain/entity'; +import { OauthSessionToken } from '../../../domain'; +import { OauthSessionTokenEntity, OauthSessionTokenEntityProps } from '../../../entity'; + +export class OauthSessionTokenEntityMapper { + public static mapEntityToDO(entity: OauthSessionTokenEntity): OauthSessionToken { + const domainObject = new OauthSessionToken({ + id: entity.id, + userId: entity.user.id, + systemId: entity.system.id, + refreshToken: entity.refreshToken, + expiresAt: entity.expiresAt, + }); + + return domainObject; + } + + public static mapDOToEntityProperties( + domainObject: OauthSessionToken, + em: EntityManager + ): OauthSessionTokenEntityProps { + const entityProps: OauthSessionTokenEntityProps = { + id: domainObject.id, + user: em.getReference(User, domainObject.userId), + system: em.getReference(SystemEntity, domainObject.systemId), + refreshToken: domainObject.refreshToken, + expiresAt: domainObject.expiresAt, + }; + + return entityProps; + } +} diff --git a/apps/server/src/modules/oauth/repo/mikro-orm/oauth-session-token.repo.spec.ts b/apps/server/src/modules/oauth/repo/mikro-orm/oauth-session-token.repo.spec.ts new file mode 100644 index 00000000000..6b3f7ade916 --- /dev/null +++ b/apps/server/src/modules/oauth/repo/mikro-orm/oauth-session-token.repo.spec.ts @@ -0,0 +1,97 @@ +import { MongoMemoryDatabaseModule } from '@infra/database'; +import { EntityManager, ObjectId } from '@mikro-orm/mongodb'; +import { Test, TestingModule } from '@nestjs/testing'; +import { cleanupCollections } from '@shared/testing'; +import { OauthSessionTokenEntity } from '../../entity'; +import { oauthSessionTokenEntityFactory, oauthSessionTokenFactory } from '../../testing'; +import { OAUTH_SESSION_TOKEN_REPO } from '../oauth-session-token.repo.interface'; +import { OauthSessionTokenMikroOrmRepo } from './oauth-session-token.repo'; + +describe(OauthSessionTokenMikroOrmRepo.name, () => { + let module: TestingModule; + let repo: OauthSessionTokenMikroOrmRepo; + let em: EntityManager; + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [MongoMemoryDatabaseModule.forRoot()], + providers: [{ provide: OAUTH_SESSION_TOKEN_REPO, useClass: OauthSessionTokenMikroOrmRepo }], + }).compile(); + + repo = module.get(OAUTH_SESSION_TOKEN_REPO); + em = module.get(EntityManager); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(async () => { + await cleanupCollections(em); + }); + + describe('save', () => { + describe('when a new object is provided', () => { + const setup = () => { + const oauthSessionToken = oauthSessionTokenFactory.build(); + + return { + oauthSessionToken, + }; + }; + + it('should create a new entity', async () => { + const { oauthSessionToken } = setup(); + + await repo.save(oauthSessionToken); + + await expect(em.findOneOrFail(OauthSessionTokenEntity, oauthSessionToken.id)).resolves.toBeDefined(); + }); + + it('should return the object', async () => { + const { oauthSessionToken } = setup(); + + const result = await repo.save(oauthSessionToken); + + expect(result).toEqual(oauthSessionToken); + }); + }); + + describe('when an entity with the id exists', () => { + const setup = async () => { + const oauthSessionTokenId = new ObjectId().toHexString(); + const oauthSessionTokenEntity = oauthSessionTokenEntityFactory.buildWithId( + { refreshToken: 'token1' }, + oauthSessionTokenId + ); + + await em.persistAndFlush(oauthSessionTokenEntity); + em.clear(); + + const oauthSessionToken = oauthSessionTokenFactory.build({ id: oauthSessionTokenId, refreshToken: 'token2' }); + + return { + oauthSessionToken, + }; + }; + + it('should update the entity', async () => { + const { oauthSessionToken } = await setup(); + + await repo.save(oauthSessionToken); + + await expect(em.findOneOrFail(OauthSessionTokenEntity, oauthSessionToken.id)).resolves.toEqual( + expect.objectContaining({ refreshToken: 'token2' }) + ); + }); + + it('should return the object', async () => { + const { oauthSessionToken } = await setup(); + + const result = await repo.save(oauthSessionToken); + + expect(result).toEqual(oauthSessionToken); + }); + }); + }); +}); diff --git a/apps/server/src/modules/oauth/repo/mikro-orm/oauth-session-token.repo.ts b/apps/server/src/modules/oauth/repo/mikro-orm/oauth-session-token.repo.ts new file mode 100644 index 00000000000..54851ca078a --- /dev/null +++ b/apps/server/src/modules/oauth/repo/mikro-orm/oauth-session-token.repo.ts @@ -0,0 +1,21 @@ +import { EntityData, EntityName } from '@mikro-orm/core'; +import { Injectable } from '@nestjs/common'; +import { BaseDomainObjectRepo } from '@shared/repo/base-domain-object.repo'; +import { OauthSessionToken } from '../../domain'; +import { OauthSessionTokenEntity } from '../../entity'; +import { OauthSessionTokenRepo } from '../oauth-session-token.repo.interface'; +import { OauthSessionTokenEntityMapper } from './mapper'; + +@Injectable() +export class OauthSessionTokenMikroOrmRepo + extends BaseDomainObjectRepo + implements OauthSessionTokenRepo +{ + protected get entityName(): EntityName { + return OauthSessionTokenEntity; + } + + protected mapDOToEntityProperties(entityDO: OauthSessionToken): EntityData { + return OauthSessionTokenEntityMapper.mapDOToEntityProperties(entityDO, this.em); + } +} diff --git a/apps/server/src/modules/oauth/repo/oauth-session-token.repo.interface.ts b/apps/server/src/modules/oauth/repo/oauth-session-token.repo.interface.ts new file mode 100644 index 00000000000..3cfcc560ddb --- /dev/null +++ b/apps/server/src/modules/oauth/repo/oauth-session-token.repo.interface.ts @@ -0,0 +1,7 @@ +import { OauthSessionToken } from '../domain'; + +export interface OauthSessionTokenRepo { + save(domainObject: OauthSessionToken): Promise; +} + +export const OAUTH_SESSION_TOKEN_REPO = 'OAUTH_SESSION_TOKEN_REPO'; diff --git a/apps/server/src/modules/oauth/service/index.ts b/apps/server/src/modules/oauth/service/index.ts index d9123c8e5bd..a1a8a399d0a 100644 --- a/apps/server/src/modules/oauth/service/index.ts +++ b/apps/server/src/modules/oauth/service/index.ts @@ -1,3 +1,4 @@ export * from './hydra.service'; export * from './oauth.service'; export * from './oauth-adapter.service'; +export { OauthSessionTokenService } from './oauth-session-token.service'; diff --git a/apps/server/src/modules/oauth/service/oauth-session-token.service.spec.ts b/apps/server/src/modules/oauth/service/oauth-session-token.service.spec.ts new file mode 100644 index 00000000000..cd8d57624b4 --- /dev/null +++ b/apps/server/src/modules/oauth/service/oauth-session-token.service.spec.ts @@ -0,0 +1,65 @@ +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { OAUTH_SESSION_TOKEN_REPO, OauthSessionTokenRepo } from '../repo'; +import { oauthSessionTokenFactory } from '../testing'; +import { OauthSessionTokenService } from './oauth-session-token.service'; + +describe(OauthSessionTokenService.name, () => { + let module: TestingModule; + let service: OauthSessionTokenService; + + let repo: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + OauthSessionTokenService, + { + provide: OAUTH_SESSION_TOKEN_REPO, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(OauthSessionTokenService); + repo = module.get(OAUTH_SESSION_TOKEN_REPO); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('save', () => { + describe('when saving a session token', () => { + const setup = () => { + const oauthSessionToken = oauthSessionTokenFactory.build(); + + repo.save.mockResolvedValue(oauthSessionToken); + + return { + oauthSessionToken, + }; + }; + + it('should save the token', async () => { + const { oauthSessionToken } = setup(); + + await service.save(oauthSessionToken); + + expect(repo.save).toHaveBeenCalledWith(oauthSessionToken); + }); + + it('should return the saved token', async () => { + const { oauthSessionToken } = setup(); + + const result = await service.save(oauthSessionToken); + + expect(result).toEqual(oauthSessionToken); + }); + }); + }); +}); diff --git a/apps/server/src/modules/oauth/service/oauth-session-token.service.ts b/apps/server/src/modules/oauth/service/oauth-session-token.service.ts new file mode 100644 index 00000000000..aadf50577a9 --- /dev/null +++ b/apps/server/src/modules/oauth/service/oauth-session-token.service.ts @@ -0,0 +1,13 @@ +import { Inject } from '@nestjs/common'; +import { OauthSessionToken } from '../domain'; +import { OAUTH_SESSION_TOKEN_REPO, OauthSessionTokenRepo } from '../repo'; + +export class OauthSessionTokenService { + constructor(@Inject(OAUTH_SESSION_TOKEN_REPO) private readonly oauthSessionTokenRepo: OauthSessionTokenRepo) {} + + async save(domainObject: OauthSessionToken): Promise { + const oauthSessionToken: OauthSessionToken = await this.oauthSessionTokenRepo.save(domainObject); + + return oauthSessionToken; + } +} 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 7782b24067b..880c0d2558a 100644 --- a/apps/server/src/modules/oauth/service/oauth.service.spec.ts +++ b/apps/server/src/modules/oauth/service/oauth.service.spec.ts @@ -20,8 +20,8 @@ import { System } from '@src/modules/system'; import jwt, { JwtPayload } from 'jsonwebtoken'; import { OAuthTokenDto } from '../interface'; import { - IdTokenInvalidLoggableException, OauthConfigMissingLoggableException, + TokenInvalidLoggableException, UserNotFoundAfterProvisioningLoggableException, } from '../loggable'; import { OauthAdapterService } from './oauth-adapter.service'; @@ -190,7 +190,7 @@ describe('OAuthService', () => { jest.spyOn(jwt, 'verify').mockImplementationOnce((): string => 'string'); await expect(service.validateToken('idToken', testOauthConfig)).rejects.toEqual( - new IdTokenInvalidLoggableException() + new TokenInvalidLoggableException() ); }); }); diff --git a/apps/server/src/modules/oauth/service/oauth.service.ts b/apps/server/src/modules/oauth/service/oauth.service.ts index 4b8eb53afb8..1ce3ebc8dd5 100644 --- a/apps/server/src/modules/oauth/service/oauth.service.ts +++ b/apps/server/src/modules/oauth/service/oauth.service.ts @@ -14,8 +14,8 @@ import { LegacyLogger } from '@src/core/logger'; import jwt, { JwtPayload } from 'jsonwebtoken'; import { OAuthTokenDto } from '../interface'; import { - IdTokenInvalidLoggableException, OauthConfigMissingLoggableException, + TokenInvalidLoggableException, UserNotFoundAfterProvisioningLoggableException, } from '../loggable'; import { TokenRequestMapper } from '../mapper/token-request.mapper'; @@ -130,7 +130,7 @@ export class OAuthService { }); if (typeof decodedJWT === 'string') { - throw new IdTokenInvalidLoggableException(); + throw new TokenInvalidLoggableException(); } return decodedJWT; diff --git a/apps/server/src/modules/oauth/testing/index.ts b/apps/server/src/modules/oauth/testing/index.ts new file mode 100644 index 00000000000..842ed7a8015 --- /dev/null +++ b/apps/server/src/modules/oauth/testing/index.ts @@ -0,0 +1,2 @@ +export { oauthSessionTokenFactory } from './oauth-session-token.factory'; +export { oauthSessionTokenEntityFactory } from './oauth-session-token-entity.factory'; diff --git a/apps/server/src/modules/oauth/testing/oauth-session-token-entity.factory.ts b/apps/server/src/modules/oauth/testing/oauth-session-token-entity.factory.ts new file mode 100644 index 00000000000..816d96888a6 --- /dev/null +++ b/apps/server/src/modules/oauth/testing/oauth-session-token-entity.factory.ts @@ -0,0 +1,14 @@ +import { BaseFactory, JwtTestFactory, systemEntityFactory, userFactory } from '@shared/testing'; +import { OauthSessionTokenEntity, OauthSessionTokenEntityProps } from '../entity'; + +export const oauthSessionTokenEntityFactory = BaseFactory.define( + OauthSessionTokenEntity, + () => { + return { + user: userFactory.build(), + system: systemEntityFactory.build(), + refreshToken: JwtTestFactory.createJwt({ exp: Date.now() + 10000 }), + expiresAt: new Date(Date.now() + 10000), + }; + } +); diff --git a/apps/server/src/modules/oauth/testing/oauth-session-token.factory.ts b/apps/server/src/modules/oauth/testing/oauth-session-token.factory.ts new file mode 100644 index 00000000000..af1bdda80eb --- /dev/null +++ b/apps/server/src/modules/oauth/testing/oauth-session-token.factory.ts @@ -0,0 +1,16 @@ +import { ObjectId } from '@mikro-orm/mongodb'; +import { DomainObjectFactory, JwtTestFactory } from '@shared/testing'; +import { OauthSessionToken, OauthSessionTokenProps } from '../domain'; + +export const oauthSessionTokenFactory = DomainObjectFactory.define( + OauthSessionToken, + () => { + return { + id: new ObjectId().toHexString(), + userId: new ObjectId().toHexString(), + systemId: new ObjectId().toHexString(), + refreshToken: JwtTestFactory.createJwt({ exp: Date.now() + 10000 }), + expiresAt: new Date(Date.now() + 10000), + }; + } +); diff --git a/apps/server/src/shared/domain/entity/all-entities.ts b/apps/server/src/shared/domain/entity/all-entities.ts index 20e6bf3f638..b71d53420bf 100644 --- a/apps/server/src/shared/domain/entity/all-entities.ts +++ b/apps/server/src/shared/domain/entity/all-entities.ts @@ -6,6 +6,7 @@ import { DeletionRequestEntity } from '@modules/deletion/repo/entity/deletion-re import { GroupEntity } from '@modules/group/entity'; import { InstanceEntity } from '@modules/instance'; import { SchoolSystemOptionsEntity } from '@modules/legacy-school/entity'; +import { OauthSessionTokenEntity } from '@modules/oauth/entity'; import { ExternalToolPseudonymEntity, PseudonymEntity } from '@modules/pseudonym/entity'; import { RegistrationPinEntity } from '@modules/registration-pin/entity'; import { RocketChatUserEntity } from '@modules/rocketchat-user/entity'; @@ -101,4 +102,5 @@ export const ALL_ENTITIES = [ MediaUserLicenseEntity, InstanceEntity, MediaSourceEntity, + OauthSessionTokenEntity, ]; 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 6d63fada5c2..81d79a2846a 100644 --- a/apps/server/src/shared/testing/factory/jwt.test.factory.ts +++ b/apps/server/src/shared/testing/factory/jwt.test.factory.ts @@ -1,15 +1,15 @@ -import jwt from 'jsonwebtoken'; import crypto, { KeyPairKeyObjectResult } from 'crypto'; +import jwt from 'jsonwebtoken'; const keyPair: KeyPairKeyObjectResult = crypto.generateKeyPairSync('rsa', { modulusLength: 4096 }); const publicKey: string | Buffer = keyPair.publicKey.export({ type: 'pkcs1', format: 'pem' }); const privateKey: string | Buffer = keyPair.privateKey.export({ type: 'pkcs1', format: 'pem' }); interface CreateJwtParams { - privateKey?: string | Buffer; sub?: string; iss?: string; aud?: string; + exp?: number; accountId?: string; external_sub?: string; } @@ -22,16 +22,17 @@ export class JwtTestFactory { public static createJwt(params?: CreateJwtParams): string { const validJwt = jwt.sign( { - sub: params?.sub ?? 'testUser', - iss: params?.iss ?? 'issuer', - aud: params?.aud ?? 'audience', + sub: 'testUser', + iss: 'issuer', + aud: 'audience', jti: 'jti', iat: Date.now(), exp: Date.now() + 100000, - accountId: params?.accountId ?? 'accountId', - external_sub: params?.external_sub ?? 'externalSub', + accountId: 'accountId', + external_sub: 'externalSub', + ...params, }, - params?.privateKey ?? privateKey, + privateKey, { algorithm: 'RS256', }