Skip to content

Commit 21eb59e

Browse files
feat(auth): add organization context to JWT tokens and secure organization switching
- Add CHANGE_SELECTED_ORGANIZATION permission to EMPLOYEE role - Add PermissionGuard to /switch-organization endpoint - Add organizationId to JWT access token payload - Add organizationId to JWT refresh token payload - Update getAccessTokenFromRefreshToken to maintain organization context - Secure RequestContext.currentOrganizationId() to read from JWT instead of headers BREAKING CHANGE: JWT tokens now include organizationId field. Clients should handle the new token structure.
1 parent 38aa7d8 commit 21eb59e

File tree

4 files changed

+43
-11
lines changed

4 files changed

+43
-11
lines changed

packages/core/src/lib/auth/auth.controller.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ import {
1818
ISocialAccount,
1919
ISocialAccountExistUser,
2020
IUserSigninWorkspaceResponse,
21-
LanguagesEnum
21+
LanguagesEnum,
22+
PermissionsEnum
2223
} from '@gauzy/contracts';
2324
import { Public } from '@gauzy/common';
2425
import { parseToBoolean } from '@gauzy/utils';
@@ -31,7 +32,8 @@ import {
3132
WorkspaceSigninVerifyTokenCommand
3233
} from './commands';
3334
import { RequestContext } from '../core/context';
34-
import { AuthRefreshGuard, TenantPermissionGuard } from './../shared/guards';
35+
import { AuthRefreshGuard, PermissionGuard, TenantPermissionGuard } from './../shared/guards';
36+
import { Permissions } from './../shared/decorators';
3537
import { UseValidationPipe } from '../shared/pipes';
3638
import { ChangePasswordRequestDTO, ResetPasswordRequestDTO } from './../password-reset/dto';
3739
import { RegisterUserDTO, UserEmailDTO, UserLoginDTO, UserSigninWorkspaceDTO } from './../user/dto';
@@ -381,7 +383,8 @@ export class AuthController {
381383
})
382384
@HttpCode(HttpStatus.OK)
383385
@Post('/switch-organization')
384-
@UseGuards(TenantPermissionGuard)
386+
@UseGuards(TenantPermissionGuard, PermissionGuard)
387+
@Permissions(PermissionsEnum.CHANGE_SELECTED_ORGANIZATION)
385388
@UseValidationPipe({ whitelist: true })
386389
async switchOrganization(@Body() input: SwitchOrganizationDTO): Promise<IAuthResponse | null> {
387390
return await this.authService.switchOrganization(input.organizationId);

packages/core/src/lib/auth/auth.service.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -922,6 +922,7 @@ export class AuthService extends SocialAuthService {
922922
const payload: JwtPayload = {
923923
id: user.id,
924924
tenantId: user.tenantId ?? null,
925+
organizationId: organizationId ?? employee?.organizationId ?? null,
925926
employeeId: employee ? employee.id : null,
926927
role: user.role ? user.role.name : null,
927928
permissions: user.role?.rolePermissions?.filter((rp) => rp.enabled).map((rp) => rp.permission) ?? null
@@ -945,21 +946,23 @@ export class AuthService extends SocialAuthService {
945946
* ID, email, tenant ID, and role. It then generates a refresh token based on this payload.
946947
*
947948
* @param user A partial IUser object containing at least the user's ID, email, and role.
949+
* @param organizationId Optional organization ID to include in the token.
948950
* @returns A Promise that resolves to a JWT refresh token string.
949951
* @throws Logs an error and throws an exception if the token generation fails.
950952
*/
951-
public async getJwtRefreshToken(user: Partial<IUser>) {
953+
public async getJwtRefreshToken(user: Partial<IUser>, organizationId?: ID) {
952954
try {
953955
// Ensure the user object contains the necessary information
954956
if (!user.id || !user.email) {
955957
throw new Error('User ID or email is missing.');
956958
}
957959

958-
// Construct the JWT payload
960+
// Construct the JWT payload with organization context
959961
const payload: JwtPayload = {
960962
id: user.id,
961963
email: user.email,
962964
tenantId: user.tenantId || null,
965+
organizationId: organizationId || user.lastOrganizationId || null,
963966
role: user.role ? user.role.name : null
964967
};
965968

@@ -975,6 +978,9 @@ export class AuthService extends SocialAuthService {
975978
/**
976979
* Get JWT access token from JWT refresh token
977980
*
981+
* Extracts the organization context from the refresh token to maintain
982+
* the user's organization selection across token refreshes.
983+
*
978984
* @returns {Promise<{ token: string } | null>}
979985
*/
980986
async getAccessTokenFromRefreshToken(): Promise<{ token: string } | null> {
@@ -985,8 +991,12 @@ export class AuthService extends SocialAuthService {
985991
// If no user is found, return null
986992
if (!user) return null;
987993

988-
// Get and return the JWT access token for the user
989-
const token = await this.getJwtAccessToken(user);
994+
// Extract organizationId from the current token (refresh token context)
995+
// This ensures the new access token maintains the organization context
996+
const organizationId = RequestContext.currentOrganizationId() || user.lastOrganizationId;
997+
998+
// Get and return the JWT access token for the user with organization context
999+
const token = await this.getJwtAccessToken(user, organizationId);
9901000
return { token };
9911001
} catch (error) {
9921002
// Use console.error for error logging with more descriptive context
@@ -1686,7 +1696,7 @@ export class AuthService extends SocialAuthService {
16861696
// Generate new access and refresh tokens with the new organization context
16871697
const [access_token, refresh_token] = await Promise.all([
16881698
this.getJwtAccessToken(user, organizationId),
1689-
this.getJwtRefreshToken(user)
1699+
this.getJwtRefreshToken(user, organizationId)
16901700
]);
16911701

16921702
// Store the current refresh token with the user

packages/core/src/lib/core/context/request-context.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,14 +194,32 @@ export class RequestContext {
194194
}
195195

196196
/**
197-
* Retrieves the current organization ID from the request headers.
197+
* Retrieves the current organization ID from the JWT token.
198+
* This is secure because it reads from the authenticated token, not from client headers.
198199
* Returns the organization ID if available, otherwise returns null.
199200
*
200201
* @returns {ID | null} - The current organization ID or null if not available.
201202
*/
202203
static currentOrganizationId(): ID | null {
203-
const req = RequestContext.currentRequest();
204-
return (req?.headers?.['organization-id'] as ID) || null;
204+
const requestContext = RequestContext.currentRequestContext();
205+
if (requestContext) {
206+
try {
207+
const token = this.currentToken();
208+
if (token) {
209+
const jwtPayload = verify(token, env.JWT_SECRET) as {
210+
id: string;
211+
organizationId: ID;
212+
};
213+
return jwtPayload.organizationId ?? null;
214+
}
215+
} catch (error) {
216+
if (error instanceof JsonWebTokenError) {
217+
return null;
218+
}
219+
throw error;
220+
}
221+
}
222+
return null;
205223
}
206224

207225
/**

packages/core/src/lib/role-permission/default-role-permissions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,7 @@ export const DEFAULT_ROLE_PERMISSIONS = [
489489
PermissionsEnum.PROJECT_MANAGEMENT_DASHBOARD,
490490
PermissionsEnum.TIME_TRACKING_DASHBOARD,
491491
PermissionsEnum.HUMAN_RESOURCE_DASHBOARD,
492+
PermissionsEnum.CHANGE_SELECTED_ORGANIZATION,
492493
PermissionsEnum.ORG_PROPOSALS_VIEW,
493494
PermissionsEnum.ORG_PROPOSALS_EDIT,
494495
PermissionsEnum.ORG_PROPOSAL_TEMPLATES_VIEW,

0 commit comments

Comments
 (0)