Skip to content

Commit

Permalink
[NEW][APPS] Allowing apps to register authenticated routes (#25937)
Browse files Browse the repository at this point in the history
<!-- This is a pull request template, you do not need to uncomment or remove the comments, they won't show up in the PR text. -->

<!-- Your Pull Request name should start with one of the following tags
  [NEW] For new features
  [IMPROVE] For an improvement (performance or little improvements) in existing features
  [FIX] For bug fixes that affect the end-user
  [BREAK] For pull requests including breaking changes
  Chore: For small tasks
  Doc: For documentation
-->

<!-- Checklist!!! If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code. 
  - I have read the Contributing Guide - https://github.com/RocketChat/Rocket.Chat/blob/develop/.github/CONTRIBUTING.md#contributing-to-rocketchat doc
  - I have signed the CLA - https://cla-assistant.io/RocketChat/Rocket.Chat
  - Lint and unit tests pass locally with my changes
  - I have added tests that prove my fix is effective or that my feature works (if applicable)
  - I have added necessary documentation (if applicable)
  - Any dependent changes have been merged and published in downstream modules
-->

## Proposed changes (including videos or screenshots)
<!-- CHANGELOG -->
<!--
  Describe the big picture of your changes here to communicate to the maintainers why we should accept this pull request.
  If it fixes a bug or resolves a feature request, be sure to link to that issue below.
  This description will appear in the release notes if we accept the contribution.
-->
Adds adaptations that allow apps to declare an API endpoint that requires authorization from Rocket.Chat prior to executing
<!-- END CHANGELOG -->

## Issue(s)
<!-- Link the issues being closed by or related to this PR. For example, you can use #594 if this PR closes issue number 594 -->

## Steps to test or reproduce
<!-- Mention how you would reproduce the bug if not mentioned on the issue page already. Also mention which screens are going to have the changes if applicable -->

## Further comments
<!-- If this is a relatively large or complex change, kick off the discussion by explaining why you chose the solution you did and what alternatives you considered, etc... -->
PR on Apps-Engine side RocketChat/Rocket.Chat.Apps-engine#523
  • Loading branch information
d-gubert committed Jun 28, 2022
1 parent 957c69d commit 887c133
Show file tree
Hide file tree
Showing 5 changed files with 86 additions and 41 deletions.
33 changes: 33 additions & 0 deletions apps/meteor/app/api/server/middlewares/authentication.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Request, Response, NextFunction } from 'express';

import { Users } from '../../../models/server';
import { oAuth2ServerAuth } from '../../../oauth2-server-config/server/oauth/oauth2-server';

export type AuthenticationMiddlewareConfig = {
rejectUnauthorized: boolean;
};

export const defaultAuthenticationMiddlewareConfig = {
rejectUnauthorized: true,
};

export function authenticationMiddleware(config: AuthenticationMiddlewareConfig = defaultAuthenticationMiddlewareConfig) {
return (req: Request, res: Response, next: NextFunction): void => {
const { 'x-user-id': userId, 'x-auth-token': authToken } = req.headers;

if (userId && authToken) {
req.user = Users.findOneByIdAndLoginToken(userId, authToken);
} else {
req.user = oAuth2ServerAuth(req)?.user;
}

if (config.rejectUnauthorized && !req.user) {
res.status(401).send('Unauthorized');
return;
}

req.userId = req.user?._id;

next();
};
}
17 changes: 12 additions & 5 deletions apps/meteor/app/apps/server/bridges/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,18 @@ import { AppApi } from '@rocket.chat/apps-engine/server/managers/AppApi';
import { RequestMethod } from '@rocket.chat/apps-engine/definition/accessors';

import { AppServerOrchestrator } from '../orchestrator';
import { authenticationMiddleware } from '../../../api/server/middlewares/authentication';

const apiServer = express();

apiServer.disable('x-powered-by');

WebApp.connectHandlers.use(apiServer);

type RequestWithPrivateHash = Request & {
interface IRequestWithPrivateHash extends Request {
_privateHash?: string;
content?: any;
};
}

export class AppApisBridge extends ApiBridge {
appRouters: Map<string, IRouter>;
Expand All @@ -27,7 +28,7 @@ export class AppApisBridge extends ApiBridge {
super();
this.appRouters = new Map();

apiServer.use('/api/apps/private/:appId/:hash', (req: RequestWithPrivateHash, res: Response) => {
apiServer.use('/api/apps/private/:appId/:hash', (req: IRequestWithPrivateHash, res: Response) => {
const notFound = (): Response => res.sendStatus(404);

const router = this.appRouters.get(req.params.appId);
Expand Down Expand Up @@ -73,7 +74,7 @@ export class AppApisBridge extends ApiBridge {
}

if (router[method] instanceof Function) {
router[method](routePath, Meteor.bindEnvironment(this._appApiExecutor(endpoint, appId)));
router[method](routePath, this._authMiddleware(endpoint, appId), Meteor.bindEnvironment(this._appApiExecutor(endpoint, appId)));
}
}

Expand All @@ -85,6 +86,11 @@ export class AppApisBridge extends ApiBridge {
}
}

private _authMiddleware(endpoint: IApiEndpoint, _appId: string): RequestHandler {
const authFunction = authenticationMiddleware({ rejectUnauthorized: !!endpoint.authRequired });
return Meteor.bindEnvironment(authFunction);
}

private _verifyApi(api: IApi, endpoint: IApiEndpoint): void {
if (typeof api !== 'object') {
throw new Error('Invalid Api parameter provided, it must be a valid IApi object.');
Expand All @@ -96,14 +102,15 @@ export class AppApisBridge extends ApiBridge {
}

private _appApiExecutor(endpoint: IApiEndpoint, appId: string): RequestHandler {
return (req: RequestWithPrivateHash, res: Response): void => {
return (req: IRequestWithPrivateHash, res: Response): void => {
const request: IApiRequest = {
method: req.method.toLowerCase() as RequestMethod,
headers: req.headers as { [key: string]: string },
query: (req.query as { [key: string]: string }) || {},
params: req.params || {},
content: req.body,
privateHash: req._privateHash,
user: req.user && this.orch.getConverters()?.get('users')?.convertToApp(req.user),
};

this.orch
Expand Down
12 changes: 5 additions & 7 deletions apps/meteor/app/apps/server/communication/uikit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import { WebApp } from 'meteor/webapp';
import { UIKitIncomingInteractionType } from '@rocket.chat/apps-engine/definition/uikit';
import { AppInterface } from '@rocket.chat/apps-engine/definition/metadata';

import { Users } from '../../../models/server';
import { settings } from '../../../settings/server';
import { Apps, AppServerOrchestrator } from '../orchestrator';
import { UiKitCoreApp } from '../../../../server/sdk';
import { authenticationMiddleware } from '../../../api/server/middlewares/authentication';

const apiServer = express();

Expand Down Expand Up @@ -51,16 +51,14 @@ Meteor.startup(() => {
settings.get('API_Enable_Rate_Limiter') !== true ||
(process.env.NODE_ENV === 'development' && settings.get('API_Enable_Rate_Limiter_Dev') !== true),
});

router.use(apiLimiter);
});

router.use((req, res, next) => {
const { 'x-user-id': userId, 'x-auth-token': authToken, 'x-visitor-token': visitorToken } = req.headers;
router.use(authenticationMiddleware({ rejectUnauthorized: false }));

if (userId && authToken) {
req.body.user = Users.findOneByIdAndLoginToken(userId, authToken);
req.body.userId = req.body.user._id;
}
router.use((req, res, next) => {
const { 'x-visitor-token': visitorToken } = req.headers;

if (visitorToken) {
req.body.visitor = Apps.getConverters()?.get('visitors').convertByToken(visitorToken);
Expand Down
54 changes: 25 additions & 29 deletions apps/meteor/app/oauth2-server-config/server/oauth/oauth2-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Mongo } from 'meteor/mongo';
import { WebApp } from 'meteor/webapp';
import { OAuth2Server } from 'meteor/rocketchat:oauth2-server';
import { Request, Response } from 'express';
import { IUser } from '@rocket.chat/core-typings';
import { OAuthApps } from '@rocket.chat/models';

import { Users } from '../../../models/server';
Expand All @@ -25,6 +26,29 @@ function getAccessToken(accessToken: string): any {
});
}

export function oAuth2ServerAuth(partialRequest: {
headers: Record<string, any>;
query: Record<string, any>;
}): { user: IUser } | undefined {
const headerToken = partialRequest.headers.authorization?.replace('Bearer ', '');
const queryToken = partialRequest.query.access_token;

const accessToken = getAccessToken(headerToken || queryToken);

// If there is no token available or the token has expired, return undefined
if (!accessToken || (accessToken.expires != null && accessToken.expires !== 0 && accessToken.expires < new Date())) {
return;
}

const user = Users.findOne(accessToken.userId);

if (user == null) {
return;
}

return { user };
}

oauth2server.app.disable('x-powered-by');
oauth2server.routes.disable('x-powered-by');

Expand Down Expand Up @@ -57,33 +81,5 @@ oauth2server.routes.get('/oauth/userinfo', function (req: Request, res: Response
});

API.v1.addAuthMethod(function () {
const headerToken = this.request.headers.authorization;
const getToken = this.request.query.access_token;

let token: string | undefined;
if (headerToken != null) {
const matches = headerToken.match(/Bearer\s(\S+)/);
if (matches) {
token = matches[1];
} else {
token = undefined;
}
}
const bearerToken = token || getToken;
if (bearerToken == null) {
return;
}

const accessToken = getAccessToken(bearerToken);
if (accessToken == null) {
return;
}
if (accessToken.expires != null && accessToken.expires !== 0 && accessToken.expires < new Date()) {
return;
}
const user = Users.findOne(accessToken.userId);
if (user == null) {
return;
}
return { user };
return oAuth2ServerAuth(this.request);
});
11 changes: 11 additions & 0 deletions apps/meteor/definition/externals/express.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import 'express';

import { IUser } from '@rocket.chat/core-typings';

declare module 'express' {
// eslint-disable-next-line @typescript-eslint/interface-name-prefix
export interface Request {
userId?: string;
user?: IUser;
}
}

0 comments on commit 887c133

Please sign in to comment.