Skip to content

Commit 3a7f517

Browse files
authored
refactor(api): update authentication handling and context types (RocketChat#39342)
1 parent c68ac53 commit 3a7f517

6 files changed

Lines changed: 107 additions & 58 deletions

File tree

apps/meteor/app/api/server/ApiClass.ts

Lines changed: 13 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { IMethodConnection, IUser, RequiredField } from '@rocket.chat/core-typings';
1+
import type { IMethodConnection, IUser } from '@rocket.chat/core-typings';
22
import type { Route, Router } from '@rocket.chat/http-router';
33
import { License } from '@rocket.chat/license';
44
import { Logger } from '@rocket.chat/logger';
@@ -41,6 +41,8 @@ import type {
4141
} from './definition';
4242
import { getUserInfo } from './helpers/getUserInfo';
4343
import { parseJsonQuery } from './helpers/parseJsonQuery';
44+
import { authenticationMiddlewareForHono } from './middlewares/authenticationHono';
45+
import type { APIActionContext } from './router';
4446
import { RocketChatAPIRouter } from './router';
4547
import { license } from '../../../ee/app/api-enterprise/server/middlewares/license';
4648
import { isObject } from '../../../lib/utils/isObject';
@@ -57,7 +59,7 @@ const logger = new Logger('API');
5759
// We have some breaking changes planned to the API.
5860
// To avoid conflicts or missing something during the period we are adopting a 'feature flag approach'
5961
// TODO: MAJOR check if this is still needed
60-
const applyBreakingChanges = shouldBreakInVersion('9.0.0');
62+
export const applyBreakingChanges = shouldBreakInVersion('9.0.0');
6163
type MinimalRoute = {
6264
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
6365
path: string;
@@ -166,7 +168,7 @@ export class APIClass<TBasePath extends string = '', TOperations extends Record<
166168

167169
private _routes: { path: string; options: Options; endpoints: Record<string, string> }[] = [];
168170

169-
public authMethods: ((routeContext: GenericRouteExecutionContext) => Promise<IUser | undefined>)[];
171+
public authMethods: ((routeContext: APIActionContext) => Promise<IUser | undefined>)[];
170172

171173
protected helperMethods: Map<string, () => any> = new Map();
172174

@@ -248,7 +250,7 @@ export class APIClass<TBasePath extends string = '', TOperations extends Record<
248250
return parseJsonQuery(routeContext);
249251
}
250252

251-
public addAuthMethod(func: (routeContext: GenericRouteExecutionContext) => Promise<IUser | undefined>): void {
253+
public addAuthMethod(func: (routeContext: APIActionContext) => Promise<IUser | undefined>): void {
252254
this.authMethods.push(func);
253255
}
254256

@@ -825,37 +827,9 @@ export class APIClass<TBasePath extends string = '', TOperations extends Record<
825827
this.queryFields = options.queryFields;
826828
this.logger = logger;
827829

828-
const user = await api.authenticatedRoute(this);
829-
830-
const isUserWithUsername = (user: IUser | null): user is RequiredField<IUser, 'username'> => {
831-
return user !== null && typeof user === 'object' && 'username' in user && user.username !== undefined;
832-
};
833-
834-
this.user = user!;
835-
this.userId = this.user?._id;
836830
const authToken = this.request.headers.get('x-auth-token');
837831
this.token = Accounts._hashLoginToken(String(authToken))!;
838832

839-
const shouldPreventAnonymousRead = !this.user && options.authOrAnonRequired && !settings.get('Accounts_AllowAnonymousRead');
840-
const shouldPreventUserRead = !this.user && options.authRequired;
841-
842-
if (shouldPreventAnonymousRead || shouldPreventUserRead) {
843-
const result = api.unauthorized('You must be logged in to do this.');
844-
// compatibility with the old API
845-
// TODO: MAJOR
846-
if (!applyBreakingChanges) {
847-
Object.assign(result.body, {
848-
status: 'error',
849-
message: 'You must be logged in to do this.',
850-
});
851-
}
852-
return result;
853-
}
854-
855-
if (user && !options.userWithoutUsername && !isUserWithUsername(user)) {
856-
throw new Meteor.Error('error-unauthorized', 'Users must have a username');
857-
}
858-
859833
const objectForRateLimitMatch = {
860834
IPAddr: this.requestIp,
861835
route: api.getFullRouteName(route, this.request.method.toLowerCase()),
@@ -961,6 +935,12 @@ export class APIClass<TBasePath extends string = '', TOperations extends Record<
961935
this.router[method.toLowerCase() as 'get' | 'post' | 'put' | 'delete'](
962936
`/${route}`.replaceAll('//', '/'),
963937
{ ..._options, tags } as TypedOptions,
938+
authenticationMiddlewareForHono(this, {
939+
authRequired: options.authRequired,
940+
authOrAnonRequired: options.authOrAnonRequired,
941+
userWithoutUsername: options.userWithoutUsername,
942+
logger,
943+
}),
964944
license(_options as TypedOptions, License),
965945
(operations[method as keyof Operations<TPathPattern, TOptions>] as Record<string, any>).action,
966946
);
@@ -978,7 +958,7 @@ export class APIClass<TBasePath extends string = '', TOperations extends Record<
978958
});
979959
}
980960

981-
protected async authenticatedRoute(routeContext: GenericRouteExecutionContext): Promise<IUser | null> {
961+
public async authenticatedRoute(routeContext: APIActionContext): Promise<IUser | null> {
982962
const userId = routeContext.request.headers.get('x-user-id');
983963
const userToken = routeContext.request.headers.get('x-auth-token');
984964

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { type IUser, type RequiredField } from '@rocket.chat/core-typings';
2+
import { type Logger } from '@rocket.chat/logger';
3+
import type { MiddlewareHandler } from 'hono';
4+
import { Meteor } from 'meteor/meteor';
5+
6+
import { settings } from '../../../settings/server';
7+
import { applyBreakingChanges, type APIClass } from '../ApiClass';
8+
import { convertHonoContextToApiActionContext, type HonoContext } from '../router';
9+
10+
const isUserWithUsername = (user: IUser | null): user is RequiredField<IUser, 'username'> => {
11+
return user !== null && typeof user === 'object' && 'username' in user && user.username !== undefined;
12+
};
13+
14+
export function authenticationMiddlewareForHono(
15+
api: APIClass<string, Record<string, unknown>>,
16+
options: {
17+
authRequired?: boolean;
18+
authOrAnonRequired?: boolean;
19+
userWithoutUsername?: boolean;
20+
logger: Logger;
21+
},
22+
): MiddlewareHandler {
23+
return async (c: HonoContext, next) => {
24+
const user = await api.authenticatedRoute(convertHonoContextToApiActionContext(c, { logger: options.logger }));
25+
const shouldPreventAnonymousRead = !user && options.authOrAnonRequired && !settings.get('Accounts_AllowAnonymousRead');
26+
const shouldPreventUserRead = !user && options.authRequired;
27+
28+
if (shouldPreventAnonymousRead || shouldPreventUserRead) {
29+
const result = api.unauthorized('You must be logged in to do this.');
30+
// TODO: MAJOR
31+
if (!applyBreakingChanges) {
32+
Object.assign(result.body, {
33+
status: 'error',
34+
message: 'You must be logged in to do this.',
35+
});
36+
}
37+
38+
return c.json(result.body, result.statusCode);
39+
}
40+
41+
if (user && !options.userWithoutUsername && !isUserWithUsername(user)) {
42+
throw new Meteor.Error('error-unauthorized', 'Users must have a username');
43+
}
44+
45+
c.set('user', user);
46+
return next();
47+
};
48+
}
Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
import type { IncomingMessage } from 'node:http';
22

3+
import type { IUser } from '@rocket.chat/core-typings';
34
import type { ResponseSchema } from '@rocket.chat/http-router';
45
import { Router } from '@rocket.chat/http-router';
6+
import { type Logger } from '@rocket.chat/logger';
57
import type { Context } from 'hono';
68

79
import type { TypedOptions } from './definition';
810

9-
type HonoContext = Context<{
11+
export type HonoContext = Context<{
1012
Bindings: { incoming: IncomingMessage };
1113
Variables: {
1214
remoteAddress: string;
1315
bodyParams: Record<string, unknown>;
1416
queryParams: Record<string, unknown>;
17+
user?: IUser | null;
1518
};
1619
}>;
1720

@@ -25,6 +28,7 @@ export type APIActionContext = {
2528
response: any;
2629
route: string;
2730
incoming: IncomingMessage;
31+
logger: Logger;
2832
};
2933

3034
export type APIActionHandler = (this: APIActionContext, request: Request) => Promise<ResponseSchema<TypedOptions>>;
@@ -35,28 +39,43 @@ export class RocketChatAPIRouter<
3539
[x: string]: unknown;
3640
} = NonNullable<unknown>,
3741
> extends Router<TBasePath, TOperations, APIActionHandler> {
38-
protected override convertActionToHandler(action: APIActionHandler): (c: HonoContext) => Promise<ResponseSchema<TypedOptions>> {
42+
protected override convertActionToHandler(
43+
action: APIActionHandler,
44+
logger: Logger,
45+
): (c: HonoContext) => Promise<ResponseSchema<TypedOptions>> {
3946
return async (c: HonoContext): Promise<ResponseSchema<TypedOptions>> => {
40-
const { req, res } = c;
47+
const { req } = c;
4148

4249
const request = req.raw.clone();
4350

44-
const context: APIActionContext = {
45-
requestIp: c.get('remoteAddress'),
46-
urlParams: req.param(),
47-
queryParams: c.get('queryParams'),
48-
bodyParams: c.get('bodyParams'),
49-
request,
50-
path: req.path,
51-
response: res,
52-
route: req.routePath,
53-
incoming: c.env.incoming,
54-
};
51+
const context = convertHonoContextToApiActionContext(c, { logger });
5552

5653
return action.apply(context, [request]);
5754
};
5855
}
5956
}
6057

58+
export const convertHonoContextToApiActionContext = (
59+
c: HonoContext,
60+
options: {
61+
logger: Logger;
62+
},
63+
): APIActionContext => {
64+
const user = c.get('user');
65+
return {
66+
requestIp: c.get('remoteAddress'),
67+
urlParams: c.req.param(),
68+
queryParams: c.get('queryParams'),
69+
bodyParams: c.get('bodyParams'),
70+
request: c.req.raw,
71+
path: c.req.path,
72+
response: c.res,
73+
route: c.req.routePath,
74+
incoming: c.env.incoming,
75+
logger: options.logger,
76+
...(user && { user, userId: user._id }),
77+
};
78+
};
79+
6180
export type ExtractRouterEndpoints<TRoute extends RocketChatAPIRouter<any, any>> =
6281
TRoute extends RocketChatAPIRouter<any, infer TOperations> ? TOperations : never;

apps/meteor/app/integrations/server/api/api.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type { FailureResult, GenericRouteExecutionContext, SuccessResult, Unavai
1616
import { loggerMiddleware } from '../../../api/server/middlewares/logger';
1717
import { metricsMiddleware } from '../../../api/server/middlewares/metrics';
1818
import { tracerSpanMiddleware } from '../../../api/server/middlewares/tracer';
19+
import type { APIActionContext } from '../../../api/server/router';
1920
import type { WebhookResponseItem } from '../../../lib/server/functions/processWebhookMessage';
2021
import { processWebhookMessage } from '../../../lib/server/functions/processWebhookMessage';
2122
import { metrics } from '../../../metrics/server';
@@ -357,7 +358,7 @@ function integrationInfoRest(): { statusCode: number; body: { success: boolean }
357358
}
358359

359360
class WebHookAPI extends APIClass<'/hooks'> {
360-
override async authenticatedRoute(routeContext: IntegrationThis): Promise<IUser | null> {
361+
override async authenticatedRoute(routeContext: APIActionContext): Promise<IUser | null> {
361362
const { integrationId, token } = routeContext.urlParams;
362363
const integration = await Integrations.findOneByIdAndToken<IIncomingIntegration>(integrationId, decodeURIComponent(token));
363364

@@ -369,9 +370,10 @@ class WebHookAPI extends APIClass<'/hooks'> {
369370

370371
routeContext.request.headers.set('x-auth-token', token);
371372

372-
routeContext.request.integration = integration;
373+
const req = routeContext.request as Request & { integration?: IIncomingIntegration };
374+
req.integration = integration;
373375

374-
return Users.findOneById(routeContext.request.integration.userId);
376+
return Users.findOneById(req.integration.userId);
375377
}
376378

377379
override shouldAddRateLimitToRoute(options: { rateLimiterOptions?: RateLimiterOptions | boolean }): boolean {

apps/meteor/ee/server/apps/communication/endpoints/appLogsExportHandler.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { json2csv } from 'json-2-csv';
77
import type { AppsRestApi } from '../rest';
88
import { makeAppLogsQuery } from './lib/makeAppLogsQuery';
99
import { APIClass } from '../../../../../app/api/server/ApiClass';
10-
import type { GenericRouteExecutionContext } from '../../../../../app/api/server/definition';
10+
import type { APIActionContext } from '../../../../../app/api/server/router';
1111

1212
const isErrorResponse = ajv.compile<{
1313
success: false;
@@ -26,7 +26,7 @@ const isErrorResponse = ajv.compile<{
2626
});
2727

2828
class ExportHandlerAPI extends APIClass {
29-
protected override async authenticatedRoute(routeContext: GenericRouteExecutionContext): Promise<IUser | null> {
29+
public override async authenticatedRoute(routeContext: APIActionContext): Promise<IUser | null> {
3030
const { rc_uid, rc_token } = parse(routeContext.request.headers.get('cookie') || '');
3131

3232
if (rc_uid) {

packages/http-router/src/Router.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export type Route = {
7575
};
7676

7777
export abstract class AbstractRouter<TActionCallback = (c: Context) => Promise<ResponseSchema<TypedOptions>>> {
78-
protected abstract convertActionToHandler(action: TActionCallback): (c: Context) => Promise<ResponseSchema<TypedOptions>>;
78+
protected abstract convertActionToHandler(action: TActionCallback, logger: Logger): (c: Context) => Promise<ResponseSchema<TypedOptions>>;
7979
}
8080

8181
type InnerRouter = Hono<{
@@ -118,7 +118,7 @@ export class Router<
118118
{
119119
description: '',
120120
content: {
121-
'application/json': { schema: ('schema' in schema ? schema.schema : schema) as AnySchema },
121+
'application/json': { schema: 'schema' in schema ? schema.schema : schema },
122122
},
123123
},
124124
]),
@@ -185,7 +185,7 @@ export class Router<
185185
...actions: MiddlewareHandlerListAndActionHandler<TOptions, TActionCallback>
186186
): Router<TBasePath, TOperations, TActionCallback> {
187187
const [middlewares, action] = splitArray<MiddlewareHandler, TActionCallback>(actions);
188-
const convertedAction = this.convertActionToHandler(action);
188+
const convertedAction = this.convertActionToHandler(action, logger);
189189

190190
const path = `/${subpath}`.replace('//', '/');
191191
(
@@ -314,21 +314,21 @@ export class Router<
314314
};
315315

316316
if (isContentLess(statusCode)) {
317-
return c.status(statusCode as 101 | 204 | 205 | 304);
317+
return c.status(statusCode);
318318
}
319319
Object.entries(responseHeaders).forEach(([key, value]) => {
320320
if (value) {
321321
c.header(key, String(value));
322322
}
323323
});
324324

325-
return c.body((contentType?.match(/json|javascript/) ? JSON.stringify(body) : body) as any, statusCode as StatusCode);
325+
return c.body(contentType?.match(/json|javascript/) ? JSON.stringify(body) : body, statusCode as StatusCode);
326326
});
327327
this.registerTypedRoutes(method, subpath, options);
328328
return this;
329329
}
330330

331-
protected convertActionToHandler(action: TActionCallback): (c: Context) => Promise<ResponseSchema<TypedOptions>> {
331+
protected convertActionToHandler(action: TActionCallback, _logger: Logger): (c: Context) => Promise<ResponseSchema<TypedOptions>> {
332332
// Default implementation simply passes through the action
333333
// Subclasses can override this to provide custom handling
334334
return action as (c: Context) => Promise<ResponseSchema<TypedOptions>>;

0 commit comments

Comments
 (0)