import { BadRequestException, Body, Controller, Delete, Get, NotFoundException, Param, Patch, Post, Put, Query, UnauthorizedException, UploadedFile, UseGuards, UseInterceptors, } from '@nestjs/common'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { FileInterceptor } from '@nestjs/platform-express'; import { unlink } from 'fs/promises'; import { Privileges } from 'src/decorators/privileges.decorator'; import { Scopes } from 'src/decorators/scopes.decorator'; import { CurrentUser } from 'src/decorators/user.decorator'; import { OAuth2Guard } from 'src/guards/oauth2.guard'; import { PrivilegesGuard } from 'src/guards/privileges.guard'; import { ScopesGuard } from 'src/guards/scopes.guard'; import { OAuth2Client } from 'src/modules/objects/oauth2-client/oauth2-client.entity'; import { OAuth2ClientURL, OAuth2ClientURLType, } from 'src/modules/objects/oauth2-client/oauth2-client-url.entity'; import { OAuth2ClientService } from 'src/modules/objects/oauth2-client/oauth2-client.service'; import { UploadService } from 'src/modules/objects/upload/upload.service'; import { User } from 'src/modules/objects/user/user.entity'; import { FormUtilityService } from 'src/modules/utility/services/form-utility.service'; import { PaginationService } from 'src/modules/utility/services/paginate.service'; import { TokenService } from 'src/modules/utility/services/token.service'; import { PageOptions } from 'src/types/pagination.interfaces'; import { AdminService } from './admin.service'; import { OAuth2TokenService } from 'src/modules/objects/oauth2-token/oauth2-token.service'; import { Throttle } from '@nestjs/throttler'; const RELATIONS = ['urls', 'picture', 'owner']; const SET_CLIENT_FIELDS = [ 'title', 'description', 'scope', 'grants', 'activated', 'verified', 'urls', ]; const URL_TYPES = ['redirect_uri', 'terms', 'privacy', 'website']; const REQUIRED_CLIENT_FIELDS = ['title', 'grants', 'activated']; @ApiBearerAuth() @ApiTags('admin') @Controller('/api/admin/oauth2') @UseGuards(OAuth2Guard, PrivilegesGuard, ScopesGuard) export class OAuth2AdminController { constructor( private _oaClient: OAuth2ClientService, private _oaToken: OAuth2TokenService, private _service: AdminService, private _paginate: PaginationService, private _form: FormUtilityService, private _token: TokenService, private _upload: UploadService, ) {} @Get('scopes') @Scopes('management') @Privileges(['admin', 'admin:oauth2'], 'self:oauth2') async oauth2Scopes(@CurrentUser() user: User) { const reducedPermissions = !this._service.userHasPrivilege( user, 'admin:oauth2', ); return reducedPermissions ? this._service.removeUnprivileged(this._oaClient.availableScopes) : this._oaClient.availableScopes; } @Get('grants') @Scopes('management') @Privileges(['admin', 'admin:oauth2'], 'self:oauth2') async oauth2Grants(@CurrentUser() user: User) { const reducedPermissions = !this._service.userHasPrivilege( user, 'admin:oauth2', ); return reducedPermissions ? this._service.removeUnprivileged(this._oaClient.availableGrantTypes) : this._oaClient.availableGrantTypes; } @Get('clients') @Scopes('management') @Privileges(['admin', 'admin:oauth2'], 'self:oauth2') async oauth2ClientList( @Query() options: { q?: string } & PageOptions, @CurrentUser() user: User, ) { if (!this._service.userHasPrivilege(user, 'admin:oauth2')) { const unprivileged = await this._oaClient.getClientsByOwner( user, RELATIONS, ); return { pagination: null, list: unprivileged.map((item) => this._oaClient.stripClientInfo(item)), }; } const search = options.q ? decodeURIComponent(options.q) : null; const resultCount = await this._oaClient.searchClientsCount( search, RELATIONS, ); const pagination = this._paginate.paginate(options, resultCount); const [list] = await this._oaClient.searchClients( pagination.pageSize, pagination.offset, search, RELATIONS, ); return { pagination, list: this._form.stripObjectArray( list.map((item) => this._oaClient.stripClientInfo(item)), ['password'], ), }; } // New client @Post('clients') @Scopes('management') @Privileges(['admin', 'admin:oauth2'], 'self:oauth2') async createNewClient( @Body() setter: Partial, @CurrentUser() user: User, ) { const allowedFieldsOnly = this._form.pluckObject(setter, SET_CLIENT_FIELDS); const reducedPermissions = !this._service.userHasPrivilege( user, 'admin:oauth2', ); if (!Object.keys(allowedFieldsOnly).length) { throw new BadRequestException('Required fields are missing'); } if (REQUIRED_CLIENT_FIELDS.some((field) => setter[field] === undefined)) { throw new BadRequestException('Required fields are missing'); } const splitGrants = (allowedFieldsOnly.grants || '') .trim() .split(' ') .filter((item) => item); const splitScopes = (allowedFieldsOnly.scope || '') .trim() .split(' ') .filter((item) => item); let availableGrantTypes = this._oaClient.availableGrantTypes; let availableScopes = this._oaClient.availableScopes; if (reducedPermissions) { availableGrantTypes = this._service.removeUnprivileged(availableGrantTypes); availableScopes = this._service.removeUnprivileged(availableScopes); allowedFieldsOnly.activated = true; } if (!splitGrants.every((grant) => availableGrantTypes.includes(grant))) { throw new BadRequestException('Bad grant types'); } if (!splitScopes.every((scope) => availableScopes.includes(scope))) { throw new BadRequestException('Bad scopes'); } const urls = setter.urls?.slice(); delete allowedFieldsOnly.urls; const client = new OAuth2Client(); Object.assign(client, allowedFieldsOnly); client.client_id = this._token.createUUID(); client.client_secret = this._token.generateSecret(); client.owner = user; await this._oaClient.updateClient(client); if (urls?.length) { await this._oaClient.upsertURLs(client, urls); } return this._oaClient.stripClientInfo(client); } @Get('clients/:id') @Scopes('management') @Privileges(['admin', 'admin:oauth2'], 'self:oauth2') async oauth2Client(@Param('id') id: string, @CurrentUser() user: User) { const client = await this._oaClient.getById(parseInt(id, 10), RELATIONS); if (!client) { throw new NotFoundException('Client not found'); } if (!this._service.userCanEditClient(user, client)) { throw new UnauthorizedException( 'You do not have permission to view this client', ); } return this._oaClient.stripClientInfo(client); } @Patch('clients/:id') @Scopes('management') @Privileges(['admin', 'admin:oauth2'], 'self:oauth2') async updateOauth2Client( @Param('id') id: string, @Body() setter: Partial, @CurrentUser() user: User, ) { const client = await this._oaClient.getById(parseInt(id, 10), []); const reducedPermissions = !this._service.userHasPrivilege( user, 'admin:oauth2', ); if (!client) { throw new NotFoundException('Client not found'); } if (!this._service.userCanEditClient(user, client)) { throw new UnauthorizedException( 'You do not have permission to edit this client', ); } const allowedFieldsOnly = this._form.pluckObject(setter, SET_CLIENT_FIELDS); const urls = allowedFieldsOnly.urls?.slice(); delete allowedFieldsOnly.urls; // Non-admin cannot change the activated or verified status if (reducedPermissions) { delete allowedFieldsOnly.activated; delete allowedFieldsOnly.verified; } if (!Object.keys(allowedFieldsOnly).length) { return this._oaClient.stripClientInfo(client); } const splitGrants = (allowedFieldsOnly.grants || '') .trim() .split(' ') .filter((item) => item); const splitScopes = (allowedFieldsOnly.scope || '') .trim() .split(' ') .filter((item) => item); let availableGrantTypes = this._oaClient.availableGrantTypes; let availableScopes = this._oaClient.availableScopes; if (reducedPermissions) { availableGrantTypes = this._service.removeUnprivileged(availableGrantTypes); availableScopes = this._service.removeUnprivileged(availableScopes); allowedFieldsOnly.activated = true; } if (!splitGrants.every((grant) => availableGrantTypes.includes(grant))) { throw new BadRequestException('Bad grant types'); } if (!splitScopes.every((scope) => availableScopes.includes(scope))) { throw new BadRequestException('Bad scopes'); } Object.assign(client, allowedFieldsOnly); await this._oaClient.updateClient(client); if (urls?.length) { await this._oaClient.upsertURLs(client, urls); } return this._oaClient.stripClientInfo(client); } @Delete('clients/:id') @Scopes('management') @Privileges(['admin', 'admin:oauth2'], 'self:oauth2') async deleteOauth2Client(@Param('id') id: string, @CurrentUser() user: User) { const client = await this._oaClient.getById(parseInt(id, 10), []); if (!client) { throw new NotFoundException('Client not found'); } if (!this._service.userCanEditClient(user, client)) { throw new UnauthorizedException( 'You do not have permission to edit this client', ); } if (client.activated) { throw new BadRequestException('Please deactivate the client first.'); } await this._oaClient.deleteClient(client); return { success: true }; } @Post('clients/:id/new-secret') @Scopes('management') @Privileges(['admin', 'admin:oauth2'], 'self:oauth2') async createNewSecret(@Param('id') id: string, @CurrentUser() user: User) { const client = await this._oaClient.getById(parseInt(id, 10), []); if (!client) { throw new NotFoundException('Client not found'); } if (!this._service.userCanEditClient(user, client)) { throw new UnauthorizedException( 'You do not have permission to edit this client', ); } client.client_secret = this._token.generateSecret(); await this._oaClient.updateClient(client); // security await this._oaToken.wipeClientTokens(client); await this._oaClient.wipeClientAuthorizations(client); return this._oaClient.stripClientInfo(client); } @Delete('clients/:id/authorizations') @Scopes('management') @Privileges(['admin', 'admin:oauth2'], 'self:oauth2') async deleteClientAuthorizations( @Param('id') id: string, @CurrentUser() user: User, ) { const client = await this._oaClient.getById(parseInt(id, 10), []); if (!client) { throw new NotFoundException('Client not found'); } if (!this._service.userCanEditClient(user, client)) { throw new UnauthorizedException( 'You do not have permission to edit this client', ); } await this._oaClient.wipeClientAuthorizations(client); return this._oaClient.stripClientInfo(client); } @Get('clients/:id/urls') @Scopes('management') @Privileges(['admin', 'admin:oauth2'], 'self:oauth2') async oauth2ClientURLs(@Param('id') id: string, @CurrentUser() user: User) { const client = await this._oaClient.getById(parseInt(id, 10), ['urls']); if (!client) { throw new NotFoundException('Client not found'); } if (!this._service.userCanEditClient(user, client)) { throw new UnauthorizedException( 'You do not have permission to edit this client', ); } return client.urls; } @Delete('clients/:id/urls/:url') @Scopes('management') @Privileges(['admin', 'admin:oauth2'], 'self:oauth2') async deleteOAuth2ClientURL( @Param('id') id: string, @Param('url') urlId: string, @CurrentUser() user: User, ) { const client = await this._oaClient.getById(parseInt(id, 10), ['urls']); const parsedURLId = parseInt(urlId, 10); if (!client) { throw new NotFoundException('Client not found'); } if (!this._service.userCanEditClient(user, client)) { throw new UnauthorizedException( 'You do not have permission to edit this client', ); } if (!parsedURLId) { throw new BadRequestException('Invalid URL ID'); } const url = await this._oaClient.getClientURLById(parsedURLId); if (!url) { throw new BadRequestException('Invalid URL'); } client.urls = client.urls.filter((url) => url.id !== parsedURLId); await this._oaClient.deleteClientURL(url); return client; } @Put('clients/:id/urls/:url') @Scopes('management') @Privileges(['admin', 'admin:oauth2'], 'self:oauth2') async updateOAuth2ClientURL( @Param('id') id: string, @Param('url') urlId: string, @Body() setter: { url: string; type: string }, @CurrentUser() user: User, ) { const client = await this._oaClient.getById(parseInt(id, 10), ['urls']); const plucked = this._form.pluckObject(setter, ['url', 'type']); const parsedURLId = parseInt(urlId, 10); if (!client) { throw new NotFoundException('Client not found'); } if (!this._service.userCanEditClient(user, client)) { throw new UnauthorizedException( 'You do not have permission to edit this client', ); } if (!parsedURLId) { throw new BadRequestException('Invalid URL ID'); } if (!setter.url || !setter.type || !URL_TYPES.includes(setter.type)) { throw new NotFoundException('Missing or invalid fields'); } const url = await this._oaClient.getClientURLById(parsedURLId); if (!url) { throw new BadRequestException('Invalid URL'); } Object.assign(url, plucked); await this._oaClient.updateClientURL(url); return url; } @Post('clients/:id/urls') @Scopes('management') @Privileges(['admin', 'admin:oauth2'], 'self:oauth2') async createOAuth2ClientURL( @Param('id') id: string, @Body() setter: { url: string; type: string }, @CurrentUser() user: User, ) { const client = await this._oaClient.getById(parseInt(id, 10), ['urls']); if (!client) { throw new NotFoundException('Client not found'); } if (!this._service.userCanEditClient(user, client)) { throw new UnauthorizedException( 'You do not have permission to edit this client', ); } if (!setter.url || !setter.type || !URL_TYPES.includes(setter.type)) { throw new NotFoundException('Missing or invalid fields'); } const url = new OAuth2ClientURL(); url.client = client; url.type = setter.type as OAuth2ClientURLType; url.url = setter.url; await this._oaClient.updateClientURL(url); return url; } @Throttle(3, 60) @Post('clients/:id/picture') @Scopes('management') @Privileges(['admin', 'admin:oauth2'], 'self:oauth2') @UseInterceptors(FileInterceptor('file')) async uploadClientPictureFile( @CurrentUser() user: User, @Param('id') id: string, @UploadedFile() file: Express.Multer.File, ) { const client = await this._oaClient.getById(parseInt(id, 10), ['picture']); try { if (!client) { throw new NotFoundException('Client not found'); } if (!this._service.userCanEditClient(user, client)) { throw new UnauthorizedException( 'You do not have permission to edit this client', ); } if (!file) { throw new BadRequestException('Picture upload failed'); } const matches = await this._upload.checkImageAspect(file); if (!matches) { throw new BadRequestException( 'Picture should be with a 1:1 aspect ratio.', ); } const upload = await this._upload.registerUploadedFile(file, user); await this._oaClient.updatePicture(client, upload); return { file: upload.file, }; } catch (e) { if (!file.buffer) { await unlink(file.path); } throw e; } } @Delete('clients/:id/picture') @Scopes('management') @Privileges(['admin', 'admin:oauth2'], 'self:oauth2') async deleteClientPictureFile( @Param('id') id: string, @CurrentUser() user: User, ) { const client = await this._oaClient.getById(parseInt(id, 10), ['picture']); if (!client) { throw new NotFoundException('Client not found'); } if (!this._service.userCanEditClient(user, client)) { throw new UnauthorizedException( 'You do not have permission to edit this client', ); } this._oaClient.deletePicture(client); client.picture = null; await this._oaClient.updateClient(client); return this._oaClient.stripClientInfo(client); } }