Skip to content

Commit

Permalink
save refresh tokens
Browse files Browse the repository at this point in the history
  • Loading branch information
MarvinOehlerkingCap committed Oct 9, 2024
1 parent d95880a commit 46697a2
Show file tree
Hide file tree
Showing 32 changed files with 563 additions and 46 deletions.
1 change: 1 addition & 0 deletions apps/server/src/modules/authentication/loggable/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
@@ -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<AccountService>;
let oauthService: DeepMocked<OAuthService>;
let oauthSessionTokenService: DeepMocked<OauthSessionTokenService>;

beforeAll(async () => {
module = await Test.createTestingModule({
imports: [],
providers: [
Oauth2Strategy,
{
Expand All @@ -36,12 +35,17 @@ describe('Oauth2Strategy', () => {
provide: AccountService,
useValue: createMock<AccountService>(),
},
{
provide: OauthSessionTokenService,
useValue: createMock<OauthSessionTokenService>(),
},
],
}).compile();

strategy = module.get(Oauth2Strategy);
accountService = module.get(AccountService);
oauthService = module.get(OAuthService);
oauthSessionTokenService = module.get(OauthSessionTokenService);
});

afterAll(async () => {
Expand All @@ -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();

Expand Down
47 changes: 37 additions & 10 deletions apps/server/src/modules/authentication/strategy/oauth2.strategy.ts
Original file line number Diff line number Diff line change
@@ -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<ICurrentUser> {
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();
}
Expand All @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/modules/oauth/domain/do/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { OauthSessionTokenProps, OauthSessionToken } from './oauth-session-token';
export { OauthSessionTokenFactory } from './oauth-session-token.factory';
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
});
Original file line number Diff line number Diff line change
@@ -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;
}
}
30 changes: 30 additions & 0 deletions apps/server/src/modules/oauth/domain/do/oauth-session-token.ts
Original file line number Diff line number Diff line change
@@ -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<OauthSessionTokenProps> {
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;
}
}
1 change: 1 addition & 0 deletions apps/server/src/modules/oauth/domain/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { OauthSessionToken, OauthSessionTokenProps, OauthSessionTokenFactory } from './do';
1 change: 1 addition & 0 deletions apps/server/src/modules/oauth/entity/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { OauthSessionTokenEntityProps, OauthSessionTokenEntity } from './oauth-session-token.entity';
44 changes: 44 additions & 0 deletions apps/server/src/modules/oauth/entity/oauth-session-token.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
1 change: 1 addition & 0 deletions apps/server/src/modules/oauth/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './interface';
export * from './oauth.module';
export * from './service';
export * from './domain';
2 changes: 1 addition & 1 deletion apps/server/src/modules/oauth/loggable/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Loading

0 comments on commit 46697a2

Please sign in to comment.