From c65ba92322f3d384d0438494d8950d686eb6338c Mon Sep 17 00:00:00 2001 From: Kakious Date: Thu, 18 Jul 2024 22:51:36 -0400 Subject: [PATCH] feat: more implementing oidc-core --- .prettierrc | 9 +- package.json | 4 +- pnpm-lock.yaml | 11 ++ src/app.const.ts | 8 ++ src/auth/controllers/oidc.controller.ts | 19 +++ src/auth/oidc/core.service.ts | 122 ++++++++++++++---- src/auth/oidc/oidc.errors.ts | 43 ++++++ src/database/models/oidc_client.model.ts | 29 ++--- src/database/models/organization.model.ts | 3 + src/database/seeds/initial.seed.ts | 1 + .../express_res_error.interceptor.ts | 44 +++++++ src/redis/redis.service.ts | 8 +- src/s3/s3.module.ts | 0 src/s3/s3.service.ts | 0 src/user/user.constant.ts | 6 + src/user/user.module.ts | 13 ++ src/user/user.service.ts | 62 +++++++++ 17 files changed, 333 insertions(+), 49 deletions(-) create mode 100644 src/auth/oidc/oidc.errors.ts create mode 100644 src/database/seeds/initial.seed.ts create mode 100644 src/interceptor/express_res_error.interceptor.ts create mode 100644 src/s3/s3.module.ts create mode 100644 src/s3/s3.service.ts create mode 100644 src/user/user.constant.ts create mode 100644 src/user/user.module.ts create mode 100644 src/user/user.service.ts diff --git a/.prettierrc b/.prettierrc index dcb7279..0d73619 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,4 +1,7 @@ -{ - "singleQuote": true, - "trailingComma": "all" +{ + "singleQuote": true, + "trailingComma": "all", + "tabWidth": 2, + "semi": true, + "printWidth": 100 } \ No newline at end of file diff --git a/package.json b/package.json index bda8119..e532a27 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@nestjs/config": "^3.2.3", "@nestjs/core": "^10.3.10", "@nestjs/platform-express": "^10.3.10", + "@nestjs/swagger": "^7.4.0", "@nestjs/terminus": "^10.2.3", "@nestjs/typeorm": "^10.0.2", "@opentelemetry/api": "^1.9.0", @@ -52,7 +53,8 @@ "rxjs": "^7.8.1", "typeorm": "^0.3.20", "typia": "^6.5.1", - "uuid": "^10.0.0" + "uuid": "^10.0.0", + "wildcard": "^2.0.1" }, "devDependencies": { "@nestjs/cli": "^10.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c76703..aad2aeb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@nestjs/platform-express': specifier: ^10.3.10 version: 10.3.10(@nestjs/common@10.3.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.10) + '@nestjs/swagger': + specifier: ^7.4.0 + version: 7.4.0(@nestjs/common@10.3.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.10(@nestjs/common@10.3.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.10)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2) '@nestjs/terminus': specifier: ^10.2.3 version: 10.2.3(@nestjs/common@10.3.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.10(@nestjs/common@10.3.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.10)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/typeorm@10.0.2(@nestjs/common@10.3.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.3.10(@nestjs/common@10.3.10(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.3.10)(reflect-metadata@0.2.2)(rxjs@7.8.1))(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(ioredis@5.4.1)(mysql2@3.10.2)(ts-node@10.9.2(@types/node@20.14.10)(typescript@5.5.3))))(reflect-metadata@0.2.2)(rxjs@7.8.1)(typeorm@0.3.20(ioredis@5.4.1)(mysql2@3.10.2)(ts-node@10.9.2(@types/node@20.14.10)(typescript@5.5.3))) @@ -89,6 +92,9 @@ importers: uuid: specifier: ^10.0.0 version: 10.0.0 + wildcard: + specifier: ^2.0.1 + version: 2.0.1 devDependencies: '@nestjs/cli': specifier: ^10.4.2 @@ -4001,6 +4007,9 @@ packages: resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==} engines: {node: '>=8'} + wildcard@2.0.1: + resolution: {integrity: sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==} + with@7.0.2: resolution: {integrity: sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==} engines: {node: '>= 10.0.0'} @@ -8407,6 +8416,8 @@ snapshots: dependencies: string-width: 4.2.3 + wildcard@2.0.1: {} + with@7.0.2: dependencies: '@babel/parser': 7.24.8 diff --git a/src/app.const.ts b/src/app.const.ts index 10afdf9..0b75d2a 100644 --- a/src/app.const.ts +++ b/src/app.const.ts @@ -1,3 +1,11 @@ // This is the internal client ID for the application itself. export const internalClientId = 'system.internal.management'; export const internalRedirectTag = 'system.internalRedirect.management'; + +// This is the master org configuration. This is the default org configuration that is used when no org is specified. +export const masterOrgId = 1; +export const masterOrgName = 'WaterWolf'; +export const masterOrgSlug = 'waterwolf'; + +export const masterOrgAdminRole = 'admin'; +export const masterOrgUserRole = 'user'; diff --git a/src/auth/controllers/oidc.controller.ts b/src/auth/controllers/oidc.controller.ts index e69de29..240bacd 100644 --- a/src/auth/controllers/oidc.controller.ts +++ b/src/auth/controllers/oidc.controller.ts @@ -0,0 +1,19 @@ +import { All, Controller, Req, Res, UseInterceptors } from '@nestjs/common'; +import { ApiExcludeController } from '@nestjs/swagger'; + +import { OidcService } from '../oidc/core.service'; +import { ExpressResErrorInterceptor } from '../../interceptor/express_res_error.interceptor'; + +@UseInterceptors(new ExpressResErrorInterceptor()) +@ApiExcludeController() +@Controller('oidc') +export class OidcController { + constructor(private readonly oidcService: OidcService) {} + @All('/*') + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + public mountedOidc(@Req() req: any, @Res() res: any) { + req.url = req.originalUrl.replace('/oidc', ''); + const callback = this.oidcService.provider.callback(); + return callback(req, res); + } +} diff --git a/src/auth/oidc/core.service.ts b/src/auth/oidc/core.service.ts index 867af3c..94aa8c0 100644 --- a/src/auth/oidc/core.service.ts +++ b/src/auth/oidc/core.service.ts @@ -3,8 +3,9 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { promises as fs } from 'fs'; import type Provider from 'oidc-provider'; import psl from 'psl'; -import type { Configuration, errors, KoaContextWithOIDC } from 'oidc-provider'; +import type { Configuration, errors } from 'oidc-provider'; import { createOidcAdapter } from './adapter'; +import wildcard from 'wildcard'; import { ACCESS_TOKEN_LIFE, AUTHORIZATION_TOKEN_LIFE, @@ -17,6 +18,12 @@ import { REFRESH_TOKEN_LIFE, SESSION_LIFE, } from './oidc.const'; +import { ConfigService } from '@nestjs/config'; +import { DataSource } from 'typeorm'; +import { RedisService } from '../../redis/redis.service'; +import { UserService } from 'src/user/user.service'; +import { Span } from 'nestjs-otel'; +import generateId from './helper/nanoid.helper'; // This is an async import for the oidc-provider package as it's now only esm and we need to use it in a commonjs environment. async function getProvider(): Promise<{ @@ -54,15 +61,24 @@ async function getProvider(): Promise<{ * OidcService is the service that handles all interaction with the OIDC library and package */ export class OidcService implements OnModuleInit { - private provider!: Provider; + private _provider!: Provider; private jwks: any; private cookies: any; private readonly logger = new Logger(OidcService.name); - constructor() {} + constructor( + private readonly userService: UserService, + + private readonly configService: ConfigService, + private readonly dataSource: DataSource, + private readonly redisService: RedisService, + ) {} async onModuleInit() { + await this.loadKeys(); + const { provider, providerErrors } = await getProvider(); + const baseUrl = this.configService.getOrThrow('base.localUrl'); const isOrigin = (value: string): boolean => { if (typeof value !== 'string') { @@ -182,17 +198,14 @@ export class OidcService implements OnModuleInit { }, }, clientBasedCORS(ctx, origin, client) { - // ctx.oidc.route can be used to exclude endpoints from this behaviour, in that case just return - // true to always allow CORS on them, false to deny - // you may also allow some known internal origins if you want to return (client['client_cors'] as string[]).includes(origin); }, jwks: this.jwks, cookies: { keys: this.cookies, }, - // TODO: FUCKING IMPLEMENT THIS - findAccount: this.findAccount.bind(this), + // TODO: FUCKING IMPLEMENT THIS AHHHHHHHHHH + findAccount: this.userService.oidcFindAccount.bind(this), ttl: { AccessToken: ACCESS_TOKEN_LIFE, RefreshToken: function RefreshTokenTTL(ctx, token, client) { @@ -223,8 +236,7 @@ export class OidcService implements OnModuleInit { } return ( code.scopes.has('offline_access') || - (client.applicationType === 'web' && - client.tokenEndpointAuthMethod === 'none') + (client.applicationType === 'web' && client.tokenEndpointAuthMethod === 'none') ); }, features: { @@ -242,9 +254,9 @@ export class OidcService implements OnModuleInit { } if (token.clientId !== ctx.oidc.client?.clientId) { - return ( - client['allowed_introspection_targets'] as string[] - ).includes(token.clientId!); + return (client['allowed_introspection_targets'] as string[]).includes( + token.clientId!, + ); } return true; }, @@ -296,7 +308,6 @@ export class OidcService implements OnModuleInit { openid: ['sub'], profile: ['name', 'preferred_username', 'picture', 'displayName'], email: ['email', 'email_verified'], - roles: ['roles', 'permissions'], }, pkce: { methods: ['S256'], @@ -305,6 +316,68 @@ export class OidcService implements OnModuleInit { }, }, } as Configuration; + + const oidc = new provider(`${baseUrl}/oidc`, config) as Provider; + oidc.proxy = true; + + const { redirectUriAllowed, postLogoutRedirectUriAllowed } = oidc.Client.prototype; + + const hasWildcardHost = (redirectUri: string) => { + const { hostname } = new URL(redirectUri); + return hostname.includes('*'); + }; + + const wildcardMatches = (redirectUri: string, wildcardUri: string) => + !!wildcard(wildcardUri, redirectUri); + + const logger = this.logger; + oidc.Client.prototype.redirectUriAllowed = function wildcardRedirectUriAllowed(redirectUri) { + logger.log( + { + redirectUri, + redirectUris: this.redirectUris, + }, + 'Checking redirect uri - 1', + ); + if (!this.redirectUris) { + return redirectUriAllowed.call(this, redirectUri); + } + + if (!this.redirectUris.some(hasWildcardHost)) { + return redirectUriAllowed.call(this, redirectUri); + } + + const wildcardUris = this.redirectUris.filter(hasWildcardHost); + if (wildcardUris.some(wildcardMatches.bind(undefined, redirectUri))) { + return true; + } + + return redirectUriAllowed.call(this, redirectUri); + }; + + oidc.Client.prototype.postLogoutRedirectUriAllowed = + function wildcardPostLogoutRedirectUriAllowed(redirectUri) { + if (!this.postLogoutRedirectUris) { + return postLogoutRedirectUriAllowed.call(this, redirectUri); + } + + if (!this.postLogoutRedirectUris.some(hasWildcardHost)) { + return postLogoutRedirectUriAllowed.call(this, redirectUri); + } + + const wildcardUris = this.postLogoutRedirectUris.filter(hasWildcardHost); + if (wildcardUris.some(wildcardMatches.bind(undefined, redirectUri))) { + return true; + } + + return postLogoutRedirectUriAllowed.call(this, redirectUri); + }; + + this._provider = oidc; + } + + public get provider(): Provider { + return this._provider; } /// === SUPPORT FUNCTIONS === /// @@ -317,18 +390,12 @@ export class OidcService implements OnModuleInit { const keysFolder = this.getKeysFolder(); try { - this.jwks = JSON.parse( - await fs.readFile(keysFolder + 'keys/jwks.json', 'utf8'), - ); - this.cookies = JSON.parse( - await fs.readFile(keysFolder + 'keys/cookies.json', 'utf8'), - ); + this.jwks = JSON.parse(await fs.readFile(keysFolder + 'keys/jwks.json', 'utf8')); + this.cookies = JSON.parse(await fs.readFile(keysFolder + 'keys/cookies.json', 'utf8')); this.logger.debug(`Loaded oidc keys successfully`); } catch (error) { - throw new Error( - `Failed to load keys, keys should be located ${keysFolder}keys`, - ); + throw new Error(`Failed to load keys, keys should be located ${keysFolder}keys`); } } @@ -338,4 +405,13 @@ export class OidcService implements OnModuleInit { } return __dirname + '/../../../../'; } + + /** + * Generate a JTI for an OIDC object + * @returns string - The JTI + */ + @Span() + async generateJti(): Promise { + return await generateId(); + } } diff --git a/src/auth/oidc/oidc.errors.ts b/src/auth/oidc/oidc.errors.ts new file mode 100644 index 0000000..6b6f24b --- /dev/null +++ b/src/auth/oidc/oidc.errors.ts @@ -0,0 +1,43 @@ +// Write an error class that extends the base error class +// + +export enum OidcErrorType { + InvalidRequest = 'invalid_request', + InvalidClient = 'invalid_client', + InvalidGrant = 'invalid_grant', + UnauthorizedClient = 'unauthorized_client', + InvalidSession = 'invalid_session', +} +export default class OidcError extends Error { + constructor( + public errorType: OidcErrorType, + public errorDescription: string, + public errorUri?: string, + ) { + super(errorDescription); + } + + public toOidcError() { + return { + error: this.errorType, + error_description: this.errorDescription, + error_uri: this.errorUri, + }; + } + + public static fromOidcError(oidcError: any) { + return new OidcError(oidcError.error, oidcError.error_description, oidcError.error_uri); + } + + public static invalidSession() { + return new OidcError(OidcErrorType.InvalidSession, 'Session token is invalid'); + } + + public static invalidRequest() { + return new OidcError(OidcErrorType.InvalidRequest, 'Request is invalid'); + } + + public static invalidClient() { + return new OidcError(OidcErrorType.InvalidClient, 'Client is invalid'); + } +} diff --git a/src/database/models/oidc_client.model.ts b/src/database/models/oidc_client.model.ts index 5071d79..b35530f 100644 --- a/src/database/models/oidc_client.model.ts +++ b/src/database/models/oidc_client.model.ts @@ -1,13 +1,6 @@ import type { ClientAuthMethod, ResponseType } from 'oidc-provider'; -import { - Column, - Entity, - JoinTable, - ManyToMany, - OneToMany, - PrimaryGeneratedColumn, -} from 'typeorm'; +import { Column, Entity, JoinTable, ManyToMany, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; import { OidcClientPermission } from './oidc_client_permissions.model'; import { MAX_STRING_LENGTH } from '../database.const'; @@ -20,7 +13,7 @@ export class OidcClient { // Owner Org ID @Column({ type: 'varchar', length: MAX_STRING_LENGTH, nullable: false }) - ownerName: string; + owner_org_id: string; @Column({ type: 'varchar', length: MAX_STRING_LENGTH, nullable: false }) client_secret: string; @@ -28,21 +21,24 @@ export class OidcClient { @Column({ type: 'varchar', length: MAX_STRING_LENGTH, nullable: true }) client_name: string; - @Column({ type: 'simple-array', nullable: false }) + @Column({ type: 'simple-array', nullable: false, default: [] }) redirect_uris: string[]; - @Column({ type: 'simple-array', nullable: true }) + @Column({ type: 'simple-array', nullable: true, default: [] }) client_cors: string[]; - @Column({ type: 'simple-array', nullable: true }) + @Column({ type: 'simple-array', nullable: true, default: [] }) allowed_introspection_targets: string[]; - @Column({ type: 'simple-array', nullable: false }) + @Column({ type: 'simple-array', nullable: false, default: [] }) include_permissions_from_client: string[]; - @Column({ type: 'simple-array', nullable: false }) + @Column({ type: 'simple-array', nullable: false, default: [] }) post_logout_redirect_uris: string[]; + @Column({ type: 'simple-array', nullable: true }) + default_scopes: string[]; + @Column({ type: 'simple-array', nullable: false }) response_types: ResponseType[]; @@ -64,10 +60,7 @@ export class OidcClient { @OneToMany(() => OidcClientPermission, (permission) => permission.client) permissions: OidcClientPermission[]; - @ManyToMany( - () => OidcClientPermission, - (permission) => permission.assignedClients, - ) + @ManyToMany(() => OidcClientPermission, (permission) => permission.assignedClients) @JoinTable({ name: 'oidc_client_permissions', joinColumn: { diff --git a/src/database/models/organization.model.ts b/src/database/models/organization.model.ts index eb91607..37526c9 100644 --- a/src/database/models/organization.model.ts +++ b/src/database/models/organization.model.ts @@ -9,6 +9,9 @@ export class Organization { @Column({ length: MAX_STRING_LENGTH }) name: string; + @Column({ length: MAX_STRING_LENGTH, unique: true }) + slug: string; + @Column({ length: MAX_STRING_LENGTH, nullable: true }) logo?: string; diff --git a/src/database/seeds/initial.seed.ts b/src/database/seeds/initial.seed.ts new file mode 100644 index 0000000..4167432 --- /dev/null +++ b/src/database/seeds/initial.seed.ts @@ -0,0 +1 @@ +// This contains the inital setup data for the IdP. This should be standalone. diff --git a/src/interceptor/express_res_error.interceptor.ts b/src/interceptor/express_res_error.interceptor.ts new file mode 100644 index 0000000..c351ea4 --- /dev/null +++ b/src/interceptor/express_res_error.interceptor.ts @@ -0,0 +1,44 @@ +import type { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common'; +import { HttpException, Injectable, Logger } from '@nestjs/common'; +import type { Response } from 'express'; +import { catchError, type Observable, throwError } from 'rxjs'; + +/** + * Finishes the request if an error was unexpectedly thrown. + * This ensures the requests don't hang. + * See https://github.com/nestjs/nest/issues/5448 + */ +@Injectable() +export class ExpressResErrorInterceptor implements NestInterceptor { + private readonly logger = new Logger(ExpressResErrorInterceptor.name); + + intercept(ctx: ExecutionContext, next: CallHandler): Observable { + const res = ctx.switchToHttp().getResponse(); + + return next.handle().pipe( + catchError((err: unknown) => { + if (!res.writableEnded) { + // Send an error response if it hasn't been done so the request doesn't hang. + + this.logger.error(err, 'Uncaught internal error'); + + if (err instanceof HttpException) { + const statusCode = err.getStatus(); + + res.status(statusCode).send({ + message: err.getResponse(), + statusCode, + }); + } else { + res.status(500).send({ + message: 'Internal Server Error', + error: 'Internal Server Error', + statusCode: 500, + }); + } + } + return throwError(() => err); + }), + ); + } +} diff --git a/src/redis/redis.service.ts b/src/redis/redis.service.ts index a217931..4b719a3 100644 --- a/src/redis/redis.service.ts +++ b/src/redis/redis.service.ts @@ -83,7 +83,7 @@ export class RedisService implements OnApplicationShutdown { * @param key Key for the value to get * @returns */ - public async get(key: string): Promise { + public async get(key: string): Promise { this.logger.debug(`Getting key ${key}`); const value = await this._ioredis.get(key); if (!value) { @@ -91,9 +91,9 @@ export class RedisService implements OnApplicationShutdown { } try { - return JSON.parse(value); + return JSON.parse(value) as T; } catch (error) { - return value; + return value as T; } } @@ -283,4 +283,4 @@ export class RedisService implements OnApplicationShutdown { }, }; } -} \ No newline at end of file +} diff --git a/src/s3/s3.module.ts b/src/s3/s3.module.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/s3/s3.service.ts b/src/s3/s3.service.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/user/user.constant.ts b/src/user/user.constant.ts new file mode 100644 index 0000000..8e1f104 --- /dev/null +++ b/src/user/user.constant.ts @@ -0,0 +1,6 @@ +export const USER_NOT_FOUND_ERROR = 'User not found'; + +// Caching Constants for Redis + +export const userCacheTTL = 60 * 60 * 24; // 24 hours +export const userCacheKey = 'ww-auth:user'; diff --git a/src/user/user.module.ts b/src/user/user.module.ts new file mode 100644 index 0000000..5bb8d36 --- /dev/null +++ b/src/user/user.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { User } from '../database/models/user.model'; +import { RedisModule } from '../redis/redis.module'; + +@Module({ + imports: [TypeOrmModule.forFeature([User]), RedisModule], + controllers: [], + providers: [], + exports: [], +}) +export class UserModule {} diff --git a/src/user/user.service.ts b/src/user/user.service.ts new file mode 100644 index 0000000..a9205e9 --- /dev/null +++ b/src/user/user.service.ts @@ -0,0 +1,62 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { User } from '../database/models/user.model'; +import { RedisService } from '../redis/redis.service'; +import { Span } from 'nestjs-otel'; +import { USER_NOT_FOUND_ERROR, userCacheKey } from './user.constant'; + +@Injectable() +export class UserService { + constructor( + @InjectRepository(User) + private readonly userRepository: Repository, + private readonly redisService: RedisService, + ) {} + + /** + * Get a user by their ID + * @param id The user's ID + * @oaram relations The DB relations to include + * @returns Promise The user + * @throws NotFoundException + */ + @Span() + async getUserById(id: string, relations: string[] = []): Promise { + const cacheKey = userCacheKey + id; + const cachedUser = await this.redisService.get(cacheKey); + + if (cachedUser && relations.length === 0) { + return cachedUser; + } + + const relationsToInclude: Record = relations.reduce( + (acc: Record, relation: string) => { + acc[relation] = true; + return acc; + }, + {}, + ); + + const queryBuilder = this.userRepository.createQueryBuilder('user'); + queryBuilder.where('user.id = :id', { id }); + + // TODO: Update once org roles are introduced + for (const relation in relationsToInclude) { + queryBuilder.leftJoinAndSelect(`user.${relation}`, relation); + } + + const user = await queryBuilder.getOne(); + + if (user && relations.length === 0) { + await this.redisService.set(cacheKey, user); + } + + if (!user) { + throw new NotFoundException(USER_NOT_FOUND_ERROR); + } + + return user; + } +}