From dec05e5e87448ac95689b0d5b41532b0c113f0f2 Mon Sep 17 00:00:00 2001 From: jonas_florencio <79267723+jonasflorencio@users.noreply.github.com> Date: Wed, 8 Jul 2026 08:58:52 -0300 Subject: [PATCH 1/2] fix: users.CreateToken endpoint lacks user-generate-access-token permission (#40768) Co-authored-by: Julio Araujo --- .changeset/quiet-lizards-jump.md | 5 ++ apps/meteor/app/api/server/v1/users.ts | 2 +- .../app/lib/server/methods/createToken.ts | 9 ++- .../lib/server/methods/createToken.spec.ts | 78 +++++++++++++++++++ 4 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 .changeset/quiet-lizards-jump.md create mode 100644 apps/meteor/tests/unit/app/lib/server/methods/createToken.spec.ts diff --git a/.changeset/quiet-lizards-jump.md b/.changeset/quiet-lizards-jump.md new file mode 100644 index 0000000000000..f9858cac0d1f9 --- /dev/null +++ b/.changeset/quiet-lizards-jump.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Ensures the `users.CreateToken` endpoint checks for the `user-generate-access-token` permission when generating a login token for another user diff --git a/apps/meteor/app/api/server/v1/users.ts b/apps/meteor/app/api/server/v1/users.ts index 0de38daf541bf..7986ab790105f 100644 --- a/apps/meteor/app/api/server/v1/users.ts +++ b/apps/meteor/app/api/server/v1/users.ts @@ -842,7 +842,7 @@ const usersEndpoints = API.v1.post( async function action() { const user = await getUserFromParams(this.bodyParams); - const data = await generateAccessToken(user._id, this.bodyParams.secret); + const data = await generateAccessToken(user._id, this.bodyParams.secret, this.user); return API.v1.success({ data }); }, diff --git a/apps/meteor/app/lib/server/methods/createToken.ts b/apps/meteor/app/lib/server/methods/createToken.ts index 63de5b98f4210..0b8939190d7b6 100644 --- a/apps/meteor/app/lib/server/methods/createToken.ts +++ b/apps/meteor/app/lib/server/methods/createToken.ts @@ -1,4 +1,5 @@ -import { MeteorError, User } from '@rocket.chat/core-services'; +import { Authorization, MeteorError, User } from '@rocket.chat/core-services'; +import type { IUser } from '@rocket.chat/core-typings'; import { Accounts } from 'meteor/accounts-base'; declare module '@rocket.chat/ddp-client' { @@ -10,11 +11,15 @@ declare module '@rocket.chat/ddp-client' { const { CREATE_TOKENS_FOR_USERS_SECRET } = process.env; -export async function generateAccessToken(userId: string, secret: string) { +export async function generateAccessToken(userId: string, secret: string, caller: IUser) { if (secret !== CREATE_TOKENS_FOR_USERS_SECRET) { throw new MeteorError('error-not-authorized', 'Not authorized'); } + if (caller._id !== userId && !(await Authorization.hasPermission(caller, 'user-generate-access-token'))) { + throw new MeteorError('error-not-authorized', 'Not authorized'); + } + const token = Accounts._generateStampedLoginToken(); Accounts._insertLoginToken(userId, token); diff --git a/apps/meteor/tests/unit/app/lib/server/methods/createToken.spec.ts b/apps/meteor/tests/unit/app/lib/server/methods/createToken.spec.ts new file mode 100644 index 0000000000000..c231e0e11f329 --- /dev/null +++ b/apps/meteor/tests/unit/app/lib/server/methods/createToken.spec.ts @@ -0,0 +1,78 @@ +import { expect } from 'chai'; +import { beforeEach, describe, it } from 'mocha'; +import p from 'proxyquire'; +import sinon from 'sinon'; + +const hasPermissionStub = sinon.stub(); + +const AccountsMock = { + _generateStampedLoginToken: sinon.stub(), + _insertLoginToken: sinon.stub(), +}; + +const UserMock = { + ensureLoginTokensLimit: sinon.stub(), +}; + +const AuthorizationMock = { + hasPermission: hasPermissionStub, +}; + +class MeteorErrorMock extends Error { + error: string; + + constructor(error: string, reason: string) { + super(reason); + this.error = error; + } +} + +process.env.CREATE_TOKENS_FOR_USERS_SECRET = 'V@9#mK2$pL8!nQ5^rT1&wX6*jY3%uZ7'; + +const { generateAccessToken } = p.noCallThru().load('../../../../../../app/lib/server/methods/createToken.ts', { + '@rocket.chat/core-services': { Authorization: AuthorizationMock, MeteorError: MeteorErrorMock, User: UserMock }, + 'meteor/accounts-base': { Accounts: AccountsMock }, +}); + +describe('generateAccessToken', () => { + beforeEach(() => { + hasPermissionStub.reset(); + AccountsMock._generateStampedLoginToken.reset(); + AccountsMock._insertLoginToken.reset(); + UserMock.ensureLoginTokensLimit.reset(); + }); + + it('should throw if secret does not match', async () => { + await expect(generateAccessToken('targetId', 'wrong-secret', { _id: 'callerId' })).to.be.rejectedWith('Not authorized'); + }); + + it('should throw if caller targets another user and lacks user-generate-access-token permission', async () => { + hasPermissionStub.resolves(false); + + await expect(generateAccessToken('targetId', 'V@9#mK2$pL8!nQ5^rT1&wX6*jY3%uZ7', { _id: 'callerId' })).to.be.rejectedWith( + 'Not authorized', + ); + sinon.assert.calledOnceWithExactly(hasPermissionStub, { _id: 'callerId' }, 'user-generate-access-token'); + }); + + it('should succeed if caller targets another user and has user-generate-access-token permission', async () => { + hasPermissionStub.resolves(true); + AccountsMock._generateStampedLoginToken.returns({ token: 'abc123' }); + UserMock.ensureLoginTokensLimit.resolves(); + + const result = await generateAccessToken('targetId', 'V@9#mK2$pL8!nQ5^rT1&wX6*jY3%uZ7', { _id: 'callerId' }); + + expect(result).to.deep.equal({ userId: 'targetId', authToken: 'abc123' }); + sinon.assert.calledOnceWithExactly(hasPermissionStub, { _id: 'callerId' }, 'user-generate-access-token'); + }); + + it('should succeed without permission check when caller generates token for themselves', async () => { + AccountsMock._generateStampedLoginToken.returns({ token: 'abc123' }); + UserMock.ensureLoginTokensLimit.resolves(); + + const result = await generateAccessToken('userId', 'V@9#mK2$pL8!nQ5^rT1&wX6*jY3%uZ7', { _id: 'userId' }); + + expect(result).to.deep.equal({ userId: 'userId', authToken: 'abc123' }); + sinon.assert.notCalled(hasPermissionStub); + }); +}); From 52003bef1610b050adfc15ec9d8c9fc7e438f545 Mon Sep 17 00:00:00 2001 From: Julio Araujo Date: Fri, 10 Jul 2026 02:38:39 +0200 Subject: [PATCH 2/2] fix: pass caller id to Authorization.hasPermission in generateAccessToken On this version, Authorization.hasPermission only accepts a userId string, unlike newer branches where an IUser overload exists. Co-Authored-By: Claude Sonnet 5 --- apps/meteor/app/lib/server/methods/createToken.ts | 2 +- .../tests/unit/app/lib/server/methods/createToken.spec.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/meteor/app/lib/server/methods/createToken.ts b/apps/meteor/app/lib/server/methods/createToken.ts index 0b8939190d7b6..6dab21fd8b51a 100644 --- a/apps/meteor/app/lib/server/methods/createToken.ts +++ b/apps/meteor/app/lib/server/methods/createToken.ts @@ -16,7 +16,7 @@ export async function generateAccessToken(userId: string, secret: string, caller throw new MeteorError('error-not-authorized', 'Not authorized'); } - if (caller._id !== userId && !(await Authorization.hasPermission(caller, 'user-generate-access-token'))) { + if (caller._id !== userId && !(await Authorization.hasPermission(caller._id, 'user-generate-access-token'))) { throw new MeteorError('error-not-authorized', 'Not authorized'); } diff --git a/apps/meteor/tests/unit/app/lib/server/methods/createToken.spec.ts b/apps/meteor/tests/unit/app/lib/server/methods/createToken.spec.ts index c231e0e11f329..c398906726f99 100644 --- a/apps/meteor/tests/unit/app/lib/server/methods/createToken.spec.ts +++ b/apps/meteor/tests/unit/app/lib/server/methods/createToken.spec.ts @@ -52,7 +52,7 @@ describe('generateAccessToken', () => { await expect(generateAccessToken('targetId', 'V@9#mK2$pL8!nQ5^rT1&wX6*jY3%uZ7', { _id: 'callerId' })).to.be.rejectedWith( 'Not authorized', ); - sinon.assert.calledOnceWithExactly(hasPermissionStub, { _id: 'callerId' }, 'user-generate-access-token'); + sinon.assert.calledOnceWithExactly(hasPermissionStub, 'callerId', 'user-generate-access-token'); }); it('should succeed if caller targets another user and has user-generate-access-token permission', async () => { @@ -63,7 +63,7 @@ describe('generateAccessToken', () => { const result = await generateAccessToken('targetId', 'V@9#mK2$pL8!nQ5^rT1&wX6*jY3%uZ7', { _id: 'callerId' }); expect(result).to.deep.equal({ userId: 'targetId', authToken: 'abc123' }); - sinon.assert.calledOnceWithExactly(hasPermissionStub, { _id: 'callerId' }, 'user-generate-access-token'); + sinon.assert.calledOnceWithExactly(hasPermissionStub, 'callerId', 'user-generate-access-token'); }); it('should succeed without permission check when caller generates token for themselves', async () => {