Skip to content

Commit

Permalink
feat(firebase): enhance Firebase integration with improved decorators…
Browse files Browse the repository at this point in the history
… and guards
  • Loading branch information
Alpha018 committed Sep 3, 2024
1 parent 08caebd commit 3d9bf45
Show file tree
Hide file tree
Showing 9 changed files with 179 additions and 11 deletions.
57 changes: 50 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

## Installation
```bash
$ npm i @alpha018/nestjs-firebase-auth
$ npm i @alpha018/nestjs-firebase-auth firebase-admin
```

## Usage
Expand Down Expand Up @@ -75,9 +75,11 @@ import { FirebaseAuthGuard } from '@alpha018/nestjs-firebase-auth';
### Auth Guard Without Role Validation
To protect an endpoint without validating user roles, use the Auth Guard to ensure the Firebase user's token is valid.
```ts
import { FirebaseGuard, FirebaseProvider } from '@alpha018/nestjs-firebase-auth';

export class AppController {
constructor(
private readonly firebaseService: FirebaseProvider,
private readonly firebaseProvider: FirebaseProvider,
) {}

@UseGuards(FirebaseGuard) // This line protects your endpoint. If `validateRole` is enabled, it also validates the user's role.
Expand All @@ -92,6 +94,8 @@ export class AppController {

To enforce role-based access control, you need to set custom claims in Firebase. Here's how you can set custom claims:
```ts
import { FirebaseProvider } from '@alpha018/nestjs-firebase-auth';

enum Roles {
ADMIN,
USER,
Expand All @@ -100,12 +104,12 @@ enum Roles {
@Controller('')
export class AppController implements OnModuleInit {
constructor(
private readonly firebaseService: FirebaseProvider,
private readonly firebaseProvider: FirebaseProvider,
) {}

@Get()
async setClaims() {
await this.firebaseService.setClaimsRoleBase<Roles>(
await this.firebaseProvider.setClaimsRoleBase<Roles>(
'FirebaseUID',
[Roles.ADMIN, ...]
);
Expand All @@ -116,6 +120,7 @@ export class AppController implements OnModuleInit {

Then, use the Auth Guard with role validation to check if a user has the necessary permissions to access an endpoint:
```ts
import { FirebaseGuard, FirebaseProvider, RolesGuard } from '@alpha018/nestjs-firebase-auth';
enum Roles {
ADMIN,
USER,
Expand All @@ -124,7 +129,7 @@ enum Roles {
@Controller('')
export class AppController {
constructor(
private readonly firebaseService: FirebaseProvider,
private readonly firebaseProvider: FirebaseProvider,
) {}

@RolesGuard(Roles.ADMIN, Roles.USER) // This line checks the custom claims of the Firebase user to protect the endpoint
Expand All @@ -140,6 +145,8 @@ export class AppController {

To retrieve user claims, use the following example:
```ts
import { FirebaseProvider } from '@alpha018/nestjs-firebase-auth';

enum Roles {
ADMIN,
USER,
Expand All @@ -148,18 +155,54 @@ enum Roles {
@Controller('')
export class AppController {
constructor(
private readonly firebaseService: FirebaseProvider,
private readonly firebaseProvider: FirebaseProvider,
) {}

@Get()
async mainFunction() {
const claims = await this.firebaseService.getClaimsRoleBase<Roles>(
const claims = await this.firebaseProvider.getClaimsRoleBase<Roles>(
'FirebaseUID',
);
return claims; // This returns an array of the user's claims
}
}
```

To retrieve Decode ID Token and Claims, use the following example:
```ts
import {
FirebaseGuard,
FirebaseProvider, FirebaseUser, FirebaseUserClaims,
RolesGuard,
} from '@alpha018/nestjs-firebase-auth';

import { auth } from 'firebase-admin';

enum Roles {
ADMIN,
USER,
}

@Controller('')
export class AppController {
constructor(
private readonly firebaseProvider: FirebaseProvider,
) {}

@RolesGuard(Roles.ADMIN, Roles.USER)
@UseGuards(FirebaseGuard)
@Get()
async mainFunction(
@FirebaseUser() user: auth.DecodedIdToken,
@FirebaseUserClaims() claims: Roles[],
) {
return {
user,
claims
};
}
}
```
## Resources

Check out a few resources that may come in handy when working with NestJS:
Expand Down
4 changes: 2 additions & 2 deletions src/firebase/constant/firebase.constant.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
export const FIREBASE_ADMIN_MODULE_OPTIONS = 'FIREBASE_ADMIN_MODULE_OPTIONS';
export const FIREBASE_ADMIN_NAME = 'FIREBASE_ADMIN_NAME';
export const FIREBASE_ADMIN_INJECT = 'FIREBASE_ADMIN_INJECT';
export const FIREBASE_ADMIN_CONFIG = 'FIREBASE_ADMIN_CONFIG';
export const FIREBASE_ADMIN_AUTH_STRATEGY = 'FIREBASE_ADMIN_AUTH_STRATEGY';

export const FIREBASE_USER_METADATA = 'FIREBASE_USER_METADATA';
export const FIREBASE_TOKEN_USER_METADATA = 'FIREBASE_USER_METADATA';
export const FIREBASE_CLAIMS_USER_METADATA = 'FIREBASE_CLAIMS_METADATA';
export const FIREBASE_APP_ROLES_DECORATOR = 'ROLES';
42 changes: 42 additions & 0 deletions src/firebase/decorator/claims.decorator.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { ExecutionContext } from '@nestjs/common';
import { ClaimsFactory } from './claims.decorator';
import { FIREBASE_CLAIMS_USER_METADATA } from '../constant/firebase.constant';

const mockExecutionContext: ExecutionContext = {
getType: jest.fn(),
getArgByIndex: jest.fn(),
getArgs: jest.fn(),
getClass: jest.fn(),
getHandler: jest.fn(),
switchToHttp: jest.fn(),
switchToRpc: jest.fn(),
switchToWs: jest.fn(),
};

describe('Firebase Claims Decorator - Unit Test', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('should extract the claims from the request context in an HTTP context', () => {
const mockClaims = {
claims: 'test_claims',
};

jest.spyOn(mockExecutionContext, 'getType').mockReturnValue('http');

const mockRequest = {
metadata: {
[FIREBASE_CLAIMS_USER_METADATA]: mockClaims,
},
};

jest.spyOn(mockExecutionContext, 'switchToHttp').mockReturnValue({
getRequest: () => mockRequest,
} as any);

const result = ClaimsFactory(null, mockExecutionContext);

expect(result).toEqual(mockClaims);
});
});
10 changes: 10 additions & 0 deletions src/firebase/decorator/claims.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { FIREBASE_CLAIMS_USER_METADATA } from '../constant/firebase.constant';

export const ClaimsFactory = (data: unknown, ctx: ExecutionContext) => {
const context = ctx.switchToHttp();
const request = context.getRequest();
return request.metadata?.[FIREBASE_CLAIMS_USER_METADATA as string];
};

export const FirebaseUserClaims = createParamDecorator(ClaimsFactory);
42 changes: 42 additions & 0 deletions src/firebase/decorator/user.decorator.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { ExecutionContext } from '@nestjs/common';
import { FIREBASE_TOKEN_USER_METADATA } from '../constant/firebase.constant';
import { UserFactory } from './user.decorator';

const mockExecutionContext: ExecutionContext = {
getType: jest.fn(),
getArgByIndex: jest.fn(),
getArgs: jest.fn(),
getClass: jest.fn(),
getHandler: jest.fn(),
switchToHttp: jest.fn(),
switchToRpc: jest.fn(),
switchToWs: jest.fn(),
};

describe('Firebase User Decorator - Unit Test', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('should extract the user from the request context in an HTTP context', () => {
const mockClaims = {
user: 'test_user',
};

jest.spyOn(mockExecutionContext, 'getType').mockReturnValue('http');

const mockRequest = {
metadata: {
[FIREBASE_TOKEN_USER_METADATA]: mockClaims,
},
};

jest.spyOn(mockExecutionContext, 'switchToHttp').mockReturnValue({
getRequest: () => mockRequest,
} as any);

const result = UserFactory(null, mockExecutionContext);

expect(result).toEqual(mockClaims);
});
});
10 changes: 10 additions & 0 deletions src/firebase/decorator/user.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { FIREBASE_TOKEN_USER_METADATA } from '../constant/firebase.constant';

export const UserFactory = (data: unknown, ctx: ExecutionContext) => {
const context = ctx.switchToHttp();
const request = context.getRequest();
return request.metadata[FIREBASE_TOKEN_USER_METADATA as string];
};

export const FirebaseUser = createParamDecorator(UserFactory);
1 change: 0 additions & 1 deletion src/firebase/guard/firebase.guard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ describe('FirebaseGuard', () => {
it('should return true if validateRole is false', async () => {
request.headers.authorization = bearerToken;
firebaseProvider.auth.verifyIdToken.mockResolvedValue({} as DecodedIdToken);
// Configurar validateRole como false
(guard as any).config.auth = { config: { validateRole: false } };

const result = await guard.canActivate(context);
Expand Down
22 changes: 21 additions & 1 deletion src/firebase/guard/firebase.guard.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { CanActivate, ExecutionContext, Inject, Injectable } from '@nestjs/common';
import { FirebaseProvider } from '../provider/firebase.provider';
import { FirebaseConstructorInterface } from '../interface/firebase-constructor.interface';
import { FIREBASE_ADMIN_CONFIG, FIREBASE_APP_ROLES_DECORATOR } from '../constant/firebase.constant';
import {
FIREBASE_ADMIN_CONFIG,
FIREBASE_APP_ROLES_DECORATOR,
FIREBASE_CLAIMS_USER_METADATA,
FIREBASE_TOKEN_USER_METADATA,
} from '../constant/firebase.constant';
import { ExtractJwt } from 'passport-jwt';
import { Reflector } from '@nestjs/core';
import { DecodedIdToken } from 'firebase-admin/lib/auth';
Expand Down Expand Up @@ -34,6 +39,13 @@ export class FirebaseGuard implements CanActivate {
return false;
}

request['metadata'] = {
...request['metadata'],
[FIREBASE_TOKEN_USER_METADATA]: {
user: decodedToken,
},
};

if (!this.config.auth?.config?.validateRole) {
return true;
}
Expand All @@ -45,6 +57,14 @@ export class FirebaseGuard implements CanActivate {
}

const claims = await this.firebaseProvider.getClaimsRoleBase(decodedToken.uid);

request['metadata'] = {
...request['metadata'],
[FIREBASE_CLAIMS_USER_METADATA]: {
claims: claims,
},
};

const requiredRoles = new Set(roles);
return claims?.some((role) => requiredRoles.has(role));
}
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ export * from './firebase/constant/firebase.constant';
export * from './firebase/provider/firebase.provider';
export * from './firebase/guard/firebase.guard';
export * from './firebase/decorator/role.decorator';
export * from './firebase/decorator/user.decorator';
export * from './firebase/decorator/claims.decorator';

0 comments on commit 3d9bf45

Please sign in to comment.