Skip to content

Commit e727a6e

Browse files
committed
feat(user.controller): add new endpoints for managing claims and roles
Refactor the existing `setClaims` endpoint to `setRoleClaims` to distinguish between generic claims and role-based claims. Introduce a new `setClaims` endpoint for setting generic claims. Add corresponding get endpoints for retrieving both roles and claims, enhancing clarity in API usage. docs(README): update documentation with new role claims functionality Update the README to reflect changes in API calls such as `setClaimsRoleBase` and details for handling custom claims with `rolesClaimKey`. Clarify examples provided for role-based access control to match the updated function signatures. test(app-local-validation.e2e-spec.ts): enhance e2e tests with local environment variable support Introduce `FIREBASE_TEST_USER_LOCAL` environment variable for improved test isolation. Add additional test cases for new set and get claims functions to ensure comprehensive coverage. fix(firebase.guard): refactor token verification and role handling logic Improve token verification by abstracting logic into `verifyToken` for cleaner error handling. Refactor role validation to a dedicated method `handleRoleValidation` to modularize complex logic and improve maintainability. miscellaneous: - Add mock for custom claims to support testing scenarios. - Update the FirebaseProvider to handle different keys for roles, supporting flexible claim structures.
1 parent 0c99af6 commit e727a6e

File tree

12 files changed

+274
-93
lines changed

12 files changed

+274
-93
lines changed

.github/workflows/test.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ jobs:
1313
node-version: [ 20.x, 22.x ]
1414
env:
1515
FIREBASE_TEST_USER: ${{ secrets.FIREBASE_TEST_USER }}
16+
FIREBASE_TEST_USER_LOCAL: ${{ secrets.FIREBASE_TEST_USER_LOCAL }}
1617
FIREBASE_SERVICE_ACCOUNT_BASE64: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_BASE64 }}
1718
FIREBASE_CLIENT_BASE64: ${{ secrets.FIREBASE_CLIENT_BASE64 }}
1819

README.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import { FirebaseAuthGuard } from '@alpha018/nestjs-firebase-auth';
5555
checkRevoked: true, // Set to true if you want to check for revoked Firebase tokens
5656
validateRole: true, // Set to true if you want to validate user roles
5757
useLocalRoles: true, // Set to true if you want to validate user roles locally without firebase call
58+
rolesClaimKey: 'user_roles' // Set the name of the key within the Firebase custom claims that stores user roles
5859
},
5960
},
6061
}),
@@ -74,6 +75,7 @@ import { FirebaseAuthGuard } from '@alpha018/nestjs-firebase-auth';
7475
| `auth.config.checkRevoked` | `boolean` | Optional | Set to `true` to check if the Firebase token has been revoked. Defaults to `false`. |
7576
| `auth.config.validateRole` | `boolean` | Optional | Set to `true` to validate user roles using Firebase custom claims. Defaults to `false`. |
7677
| `auth.config.useLocalRoles` | `boolean` | Optional | Set to `true` to validate user roles using local custom claims inside the JWT token. Defaults to `false`. **Note:** If you update the claims, previously issued tokens may still contain outdated roles and remain valid. |
78+
| `auth.config.rolesClaimKey` | `string` | Optional | The name of the key within the Firebase custom claims that stores user roles. Defaults to `'roles'`. This allows you to customize the property name for roles in your custom claims object. |
7779

7880

7981
### Auth Guard Without Role Validation
@@ -96,7 +98,7 @@ export class AppController {
9698

9799
### Auth Guard With Role Validation
98100

99-
To enforce role-based access control, you need to set custom claims in Firebase. Here's how you can set custom claims:
101+
To enforce role-based access control, you need to set role-based custom claims in Firebase. Here's how you can set roles for a user using `setClaimsRoleBase`:
100102
```ts
101103
import { FirebaseProvider } from '@alpha018/nestjs-firebase-auth';
102104

@@ -106,16 +108,16 @@ enum Roles {
106108
}
107109

108110
@Controller('')
109-
export class AppController implements OnModuleInit {
111+
export class AppController {
110112
constructor(
111113
private readonly firebaseProvider: FirebaseProvider,
112114
) {}
113115

114116
@Get()
115-
async setClaims() {
117+
async setUserRoles() {
116118
await this.firebaseProvider.setClaimsRoleBase<Roles>(
117-
'FirebaseUID',
118-
[Roles.ADMIN, ...]
119+
'some-firebase-uid', // The UID of the user you want to set roles for
120+
[Roles.ADMIN]
119121
);
120122
return { status: 'ok' }
121123
}
@@ -213,7 +215,7 @@ export class AppController {
213215
> **Note:** Starting from version `>=1.7.x`, these two decorators are explicitly separated to avoid confusion (see [issue #11](https://github.com/Alpha018/nestjs-firebase-auth/issues/11)):
214216
215217
- `@FirebaseUser()` → Returns the **full decoded token** (`auth.DecodedIdToken`).
216-
- `@FirebaseUserClaims()` → Returns only the **custom claims** (roles/permissions) defined for the user.
218+
- `@FirebaseUserClaims()` → Returns only the **custom role claims** (roles/permissions) defined for the user.
217219

218220
This separation ensures that developers can access both the raw Firebase user object and the role/claims information independently.
219221

src/firebase/constant/firebase.constant.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,6 @@ export const FIREBASE_TOKEN_USER_METADATA = 'FIREBASE_USER_METADATA';
1313
export const FIREBASE_CLAIMS_USER_METADATA = 'FIREBASE_CLAIMS_METADATA';
1414
/** Metadata key for role based authorization decorator */
1515
export const FIREBASE_APP_ROLES_DECORATOR = 'ROLES';
16+
17+
export const FIREBASE_APP_ROLES_DEFAULT_DECORATOR = 'roles';
18+
export const FIREBASE_AUTH_OPTIONS = 'FIREBASE_AUTH_OPTIONS';

src/firebase/decorator/claims.decorator.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,4 @@ export const ClaimsFactory = (data: unknown, ctx: ExecutionContext) => {
1818
/**
1919
* Parameter decorator to access a user’s Firebase claims.
2020
*/
21-
export const FirebaseUserClaims = createParamDecorator(ClaimsFactory);
21+
export const FirebaseRolesClaims = createParamDecorator(ClaimsFactory);

src/firebase/guard/firebase.guard.ts

Lines changed: 74 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -37,64 +37,108 @@ export class FirebaseGuard implements CanActivate {
3737
* @returns A promise that resolves to `true` if the request is authorized, otherwise `false`.
3838
*/
3939
async canActivate(context: ExecutionContext): Promise<boolean> {
40-
const request = context.switchToHttp().getRequest<Request>();
40+
const request = context.switchToHttp().getRequest();
41+
const authConfig = this.config.auth?.config;
42+
4143
const token = this.extractTokenFromRequest(request);
4244

4345
if (!token) {
4446
return false;
4547
}
4648

47-
let decodedToken: DecodedIdToken;
48-
try {
49-
decodedToken = await this.firebaseProvider.auth.verifyIdToken(
50-
token,
51-
this.config.auth?.config?.checkRevoked || false,
52-
);
53-
} catch {
49+
const decodedToken = await this.verifyToken(token, authConfig?.checkRevoked ?? false);
50+
if (!decodedToken) {
5451
return false;
5552
}
5653

57-
request['metadata'] = {
58-
...request['metadata'],
59-
[FIREBASE_TOKEN_USER_METADATA]: {
60-
user: decodedToken,
61-
},
62-
};
54+
this.attachUserToRequest(request, decodedToken);
6355

64-
if (!this.config.auth?.config?.validateRole) {
56+
if (!authConfig?.validateRole) {
6557
return true;
6658
}
6759

68-
const roles = this.reflector.get(FIREBASE_APP_ROLES_DECORATOR, context.getHandler());
60+
return this.handleRoleValidation(
61+
context,
62+
request,
63+
decodedToken,
64+
authConfig?.useLocalRoles ?? false,
65+
);
66+
}
67+
68+
/**
69+
* Handles role-based validation for the request.
70+
* It retrieves the roles required by the route handler, fetches the user's roles,
71+
* and checks if the user has at least one of the required roles.
72+
*
73+
* @param context The execution context, used to access route metadata.
74+
* @param request The incoming HTTP request object.
75+
* @param decodedToken The user's decoded Firebase ID token.
76+
* @param useLocalRoles A flag indicating whether to use roles from the token payload or fetch from Firebase.
77+
* @returns A promise that resolves to `true` if the user is authorized, otherwise `false`.
78+
*/
79+
private async handleRoleValidation(
80+
context: ExecutionContext,
81+
request: any,
82+
decodedToken: DecodedIdToken,
83+
useLocalRoles: boolean,
84+
): Promise<boolean> {
85+
const requiredRoles = this.reflector.get(FIREBASE_APP_ROLES_DECORATOR, context.getHandler());
6986

70-
if (!roles) {
87+
if (!requiredRoles) {
7188
return true;
7289
}
7390

74-
const claims = await this.firebaseProvider.getClaimsRoleBase(
75-
decodedToken,
76-
this.config.auth?.config?.useLocalRoles || false,
77-
);
91+
const userRoles = await this.firebaseProvider.getClaimsRoleBase(decodedToken, useLocalRoles);
92+
this.attachClaimsToRequest(request, userRoles);
93+
94+
if (!userRoles) {
95+
return false;
96+
}
7897

79-
request['metadata'] = {
80-
...request['metadata'],
81-
[FIREBASE_CLAIMS_USER_METADATA]: {
82-
claims: claims,
83-
},
84-
};
98+
const requiredRolesSet = new Set(requiredRoles);
99+
return userRoles.some((role) => requiredRolesSet.has(role));
100+
}
85101

86-
const requiredRoles = new Set(roles);
87-
return claims?.some((role) => requiredRoles.has(role));
102+
/**
103+
* Verifies the Firebase ID token.
104+
* @param token The ID token to verify.
105+
* @param checkRevoked Whether to check if the token has been revoked.
106+
* @returns The decoded token if valid, otherwise `null`.
107+
*/
108+
private async verifyToken(token: string, checkRevoked: boolean): Promise<DecodedIdToken | null> {
109+
try {
110+
return await this.firebaseProvider.auth.verifyIdToken(token, checkRevoked);
111+
} catch {
112+
return null;
113+
}
88114
}
89115

90116
/**
91117
* Extracts a JWT token from the Authorization header.
92118
* @param request The HTTP request object.
93119
* @returns The extracted token or `null` if not present.
94120
*/
95-
private extractTokenFromRequest(request: Request): string | null {
121+
private extractTokenFromRequest(request: any): string | null {
96122
const extractor =
97123
this.config.auth?.config?.extractor || ExtractJwt.fromAuthHeaderAsBearerToken();
98124
return extractor(request);
99125
}
126+
127+
/**
128+
* Attaches the decoded user token to the request metadata.
129+
* @param request The request object.
130+
* @param user The decoded Firebase ID token.
131+
*/
132+
private attachUserToRequest(request: any, user: DecodedIdToken): void {
133+
request.metadata = { ...request.metadata, [FIREBASE_TOKEN_USER_METADATA]: { user } };
134+
}
135+
136+
/**
137+
* Attaches the user's claims to the request metadata.
138+
* @param request The request object.
139+
* @param claims The user's custom claims.
140+
*/
141+
private attachClaimsToRequest(request: any, claims: unknown): void {
142+
request.metadata = { ...request.metadata, [FIREBASE_CLAIMS_USER_METADATA]: { claims } };
143+
}
100144
}

src/firebase/interface/options.interface.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,21 @@ export interface FirebaseAuthStrategyOptions {
4444
* @default false
4545
*/
4646
validateRole?: boolean;
47+
48+
/**
49+
* The name of the key within the Firebase custom claims that stores the user's roles.
50+
*
51+
* This allows you to customize the property name for roles in the custom claims object.
52+
* For example, if you set this to `'permissions'`, the library will look for a `permissions`
53+
* array in the custom claims.
54+
*
55+
* All role-related operations performed by `FirebaseProvider` (such as getting, setting, or preserving roles) will use this key.
56+
*
57+
* @example
58+
* // If rolesClaimKey is 'user_roles', the custom claims might look like:
59+
* // { "user_roles": ["ADMIN", "EDITOR"] }
60+
*
61+
* @default 'roles' (defined by `FIREBASE_APP_ROLES_DEFAULT_DECORATOR`)
62+
*/
63+
rolesClaimKey?: string;
4764
}

src/firebase/provider/firebase.provider.spec.ts

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ describe('FirebaseProvider', () => {
4444
{
4545
useFactory: () => {
4646
const data: FirebaseConstructorInterface = {
47+
auth: {
48+
config: {
49+
rolesClaimKey: 'test',
50+
},
51+
},
4752
base64: Buffer.from(JSON.stringify({ project_id: 'test' })).toString('base64'),
4853
};
4954
return new FirebaseProvider(data);
@@ -107,44 +112,64 @@ describe('FirebaseProvider', () => {
107112
});
108113
});
109114

115+
describe('setClaimsBase', () => {
116+
it('should set custom claims while preserving role claims', async () => {
117+
const uid = 'test-uid';
118+
const newClaims = { premium: true };
119+
const existingRoles = { test: ['user'] };
120+
mockAuth.getUser.mockResolvedValue({ customClaims: existingRoles });
121+
122+
await provider.setClaimsBase(uid, newClaims);
123+
124+
expect(mockAuth.getUser).toHaveBeenCalledWith(uid);
125+
expect(mockAuth.setCustomUserClaims).toHaveBeenCalledWith(uid, {
126+
...newClaims,
127+
...existingRoles,
128+
});
129+
});
130+
});
131+
110132
describe('setClaimsRoleBase', () => {
111133
it('should set custom user claims', async () => {
112134
const uid = 'test-uid';
113135
const claims = ['admin'];
136+
mockAuth.getUser.mockResolvedValue({ customClaims: {} });
114137
mockAuth.setCustomUserClaims.mockResolvedValue(undefined);
115138

116139
await provider.setClaimsRoleBase(uid, claims);
117-
expect(mockAuth.setCustomUserClaims).toHaveBeenCalledWith(uid, { roles: claims });
140+
expect(mockAuth.setCustomUserClaims).toHaveBeenCalledWith(uid, { test: claims });
118141
});
119142
});
120143

121144
describe('getClaimsRoleBase', () => {
122-
it('should get custom user claims', async () => {
123-
const user = userDecode as any as DecodedIdToken;
145+
it('should get claims from local token when localDecode is true', async () => {
124146
const claims = ['admin'];
125-
mockAuth.getUser.mockResolvedValue({
126-
customClaims: { roles: claims },
127-
});
147+
const userWithClaims = { ...userDecode, test: claims } as any as DecodedIdToken;
128148

129-
const result = await provider.getClaimsRoleBase(user, true);
149+
const result = await provider.getClaimsRoleBase(userWithClaims, true);
130150
expect(result).toEqual(claims);
131-
132-
const localResult = await provider.getClaimsRoleBase(user, false);
133-
expect(localResult).toEqual(claims);
151+
expect(mockAuth.getUser).not.toHaveBeenCalled();
134152
});
135153

136-
it('should return undefined if no custom claims', async () => {
154+
it('should get claims from Firebase when localDecode is false', async () => {
137155
const user = userDecode as any as DecodedIdToken;
138-
user.roles = undefined;
139-
mockAuth.getUser.mockResolvedValue({
140-
customClaims: undefined,
141-
});
156+
const claims = ['admin'];
157+
mockAuth.getUser.mockResolvedValue({ customClaims: { test: claims } });
142158

143-
const result = await provider.getClaimsRoleBase(user, true);
144-
expect(result).toBeUndefined();
159+
const result = await provider.getClaimsRoleBase(user, false);
160+
expect(mockAuth.getUser).toHaveBeenCalledWith(user.uid);
161+
expect(result).toEqual(claims);
162+
});
163+
164+
it('should return undefined if no custom claims', async () => {
165+
const user = { ...userDecode } as any as DecodedIdToken;
166+
mockAuth.getUser.mockResolvedValue({ customClaims: null });
145167

146-
const localResult = await provider.getClaimsRoleBase(user, false);
168+
const localResult = await provider.getClaimsRoleBase(user, true);
147169
expect(localResult).toBeUndefined();
170+
171+
const remoteResult = await provider.getClaimsRoleBase(user, false);
172+
expect(remoteResult).toBeUndefined();
148173
});
149174
});
150175
});

src/firebase/provider/firebase.provider.ts

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Injectable } from '@nestjs/common';
55
import * as fa from 'firebase-admin';
66

77
import { FirebaseConstructorInterface } from '../interface/firebase-constructor.interface';
8+
import { FIREBASE_APP_ROLES_DEFAULT_DECORATOR } from '../constant/firebase.constant';
89

910
@Injectable()
1011
/**
@@ -62,26 +63,44 @@ export class FirebaseProvider {
6263
* @returns An array of roles type `T` or `undefined` if no roles are found.
6364
*/
6465
async getClaimsRoleBase<T>(user: DecodedIdToken, localDecode: boolean): Promise<undefined | T[]> {
66+
const rolesKey = this.data.auth.config.rolesClaimKey ?? FIREBASE_APP_ROLES_DEFAULT_DECORATOR;
67+
6568
if (localDecode) {
66-
return user.roles;
69+
return user?.[rolesKey];
6770
}
6871

6972
const { customClaims } = await this.auth.getUser(user.uid);
70-
return customClaims?.roles;
73+
return customClaims?.[rolesKey];
74+
}
75+
76+
/**
77+
* Sets custom claims for a specific Firebase user, preserving any existing role claims.
78+
* This method merges the new claims with any existing custom claims, but ensures that
79+
* the role-specific claim (e.g., 'roles') is not overwritten by this operation.
80+
*
81+
* @param uid The UID of the user to update.
82+
* @param claims An object containing the custom claims to set.
83+
* @returns A promise that resolves once the claims are successfully updated.
84+
*/
85+
async setClaimsBase(uid: string, claims: Record<string, any>): Promise<void> {
86+
const rolesKey = this.data.auth.config.rolesClaimKey ?? FIREBASE_APP_ROLES_DEFAULT_DECORATOR;
87+
const { customClaims } = await this.auth.getUser(uid);
88+
return this.auth.setCustomUserClaims(uid, { ...claims, [rolesKey]: customClaims?.[rolesKey] });
7189
}
7290

7391
/**
74-
* Sets role-based claims for a specific Firebase user.
75-
* This overwrites the user's custom claims with a new `roles` array.
92+
* Sets or overwrites the role-based claims for a specific Firebase user, preserving other custom claims.
93+
* This method merges the new role claims with any existing custom claims by overwriting the value
94+
* of the role-specific key (e.g., 'roles') while keeping all other claims intact.
7695
*
77-
* @template T The type of roles being assigned.
96+
* @template T The type of the elements in the roles array.
7897
* @param uid The UID of the user to update.
79-
* @param claims An array of roles to assign to the user.
98+
* @param claims An array of roles to assign to the user. This will replace any existing roles.
8099
* @returns A promise that resolves once the claims are successfully updated.
81100
*/
82-
setClaimsRoleBase<T>(uid: string, claims: T[]): Promise<void> {
83-
return this.auth.setCustomUserClaims(uid, {
84-
roles: claims,
85-
});
101+
async setClaimsRoleBase<T>(uid: string, claims: T[]): Promise<void> {
102+
const rolesKey = this.data.auth.config.rolesClaimKey ?? FIREBASE_APP_ROLES_DEFAULT_DECORATOR;
103+
const { customClaims } = await this.auth.getUser(uid);
104+
return this.auth.setCustomUserClaims(uid, { ...(customClaims || {}), [rolesKey]: claims });
86105
}
87106
}

0 commit comments

Comments
 (0)