This is part two of push notifications series with Firebase. In part one, I showed how to enable push notifications in expo, and verify the setup. That part was mainly about getting the configuration right. In this article, I'll show you how to send push notifications from a NestJS backend.

Steps will be:

  • Preparing the server and adding firebase middleware
  • Storing device tokens
  • Sending a global push notification from the server

Firebase middleware

There's a nestjs firebase addon that adds firebase middleware, but I chose to add my own logic instead. I figured there's not much logic to add that I need, so I'd rather skip on using extra dependency, except firebase-admin.

You need service account access. Go to service accounts for your firebase project, and select the one that says firebase-adminsdk. Create a new key and save the file in the root of nestjs project.

My firebase service:

@Injectable()
export class FirebaseService implements OnModuleInit {
  private app: admin.app.App;

  constructor() {}

  onModuleInit(): any {
    const serviceAccountPath = './firebase.json';
    if (!fs.existsSync(serviceAccountPath)) {
      throw new Error(
        'Firebase service account file not found at path: ' +
          serviceAccountPath,
      );
    }
    const app = admin.initializeApp({
      credential: admin.credential.cert(serviceAccountPath),
    });
    this.app = app;
  }

  auth = () => admin.auth(this.app);
  messaging = () => admin.messaging(this.app);
}

I want my request object to have access to firebase decoded token object, and that's why I have this typing:

import { DecodedIdToken } from 'firebase-admin/auth';

declare global {
  namespace Express {
    interface Request {
      user: DecodedIdToken;
    }
  }
}

typings.d.ts

Every typing needs an implementation, and implementation is in the middleware:

import {
  Injectable,
  NestMiddleware,
  UnauthorizedException,
} from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { FirebaseService } from './firebase.service';

@Injectable()
export default class FirebaseAuthMiddleware implements NestMiddleware {
  constructor(private firebase: FirebaseService) {}
  async use(req: Request, _res: Response, next: NextFunction) {
    const header = req.headers.authorization;
    if (!header?.startsWith('Bearer ')) {
      throw new UnauthorizedException('Unauthorized');
    }
    const token = header.split(' ')[1];
    if (!token) {
      throw new UnauthorizedException('Unauthorized');
    }
    try {
      req.user = await this.firebaseService.auth().verifyIdToken(token);
    } catch {
      throw new UnauthorizedException('Unauthorized');
    }

    next();
  }
}

This middleware checks for authorization header and verifies it with firebase. As a result of verification, I get a decoded token which is injected into request object.

For push notifications to work from the server, I need to register and keep a record of user device tokens. Since I never trust client inputs, I need request validation.

There's a nestjs-zod you can use if you like to skip this step. Similar like with firebase, logic that I need is way too simple for me to add extra dependency:

import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
import { ZodType, ZodError } from 'zod';

@Injectable()
export class ZodValidationPipe implements PipeTransform {
  constructor(private readonly schema: ZodType<any>) {}

  transform(value: unknown) {
    const result = this.schema.safeParse(value);

    if (!result.success) {
      const formattedErrors = this.formatZodErrors(result.error);
      throw new BadRequestException({
        message: 'Validation failed',
        errors: formattedErrors,
      });
    }

    return result.data;
  }

  private formatZodErrors(error: ZodError) {
    const errors = error.flatten();

    return {
      fieldErrors: errors.fieldErrors,
      formErrors: errors.formErrors,
    };
  }
}

zod-validation.pipe.ts

This class processes input with the zod schema parser. If input doesn't comply with schema, server returns bad request response. I loove zod, it plays really nice with typescript. Take this for example:

export const RegisterDeviceTokenSchema = z.object({
  token: z.string(),
  platform: z.enum(['ios', 'android']),
});
export type RegisterDeviceToken = z.infer<typeof RegisterDeviceTokenSchema>;

No need to type interfaces, it's inferred from the zod schema. Awesome!

Handling Device Tokens

Now that I have firebase and zod in place, I can add handlers for registering device tokens. Device tokens are addresses to which one can send a push notification. In this case, firebase cloud messaging generates a new one from time to time, so old tokens need to be cleared.

  @Post('register')
  register(
    @Body(new ZodValidationPipe(RegisterDeviceTokenSchema))
    data: RegisterDeviceToken,
  ) {
    this.deviceTokenService.registerToken(data);
  }

device-tokens.controller.ts

The implementation for registering the token is in the service:

import { Injectable, Logger } from '@nestjs/common';
import { FirebaseService } from '../firebase/firebase.service';
import { RegisterDeviceToken } from './schema';

@Injectable()
export class DeviceTokenService {
  constructor(private readonly firebaseService: FirebaseService) {}
  private readonly logger = new Logger(DeviceTokenService.name);
  private deviceTokens = new Set<string>();

  registerDeviceToken(data: RegisterDeviceToken) {
    this.deviceTokens.add(data.token);
    this.logger.log(`Registered device token: ${data.token}`);
  }

  async broadcastMessage(title: string, message: string) {
    if (this.deviceTokens.size == 0) {
      this.logger.log('No subscribed device tokens to send message to.');
      return;
    }
    const tokens = Array.from(this.deviceTokens);
    this.logger.log(
      `Broadcasting message to ${this.deviceTokens.size} device tokens.`,
    );
    const bachRes = await this.firebaseService
      .messaging()
      .sendEachForMulticast({
        tokens,
        notification: { title, body: message },
        android: { priority: 'high' },
        apns: { payload: { aps: { sound: 'default' } } },
      });

    bachRes.responses.forEach((value, index) => {
      if (!value.success) {
        this.deviceTokens.delete(tokens[index]);
      }
    });
    this.logger.log(
      `Broadcasting message successfully to ${this.deviceTokens.size} device tokens.`,
    );
  }
}

This service is an in-memory push notification registry. It handles storing and removing device tokens, and can broadcast a notification to all registered users.

Sending Global Push Notification

Example of such a broadcast is spamming the users with greetings:

  @Get()
  sayHello(@CurrentUser() user: DecodedIdToken) {
    const message = `${user.name} says hello to everyone!`;
    void this.deviceTokenService.broadcastMessage('Greetings', message);
  }

I can spam the users with greetings if I can get a hold of my id token for my registered user. I have small cli script for it:

import fbAdmin from 'firebase-admin';
import { config } from 'dotenv';
import { initializeAuth, signInWithCustomToken } from 'firebase/auth';
import { initializeApp } from 'firebase/app';
import * as child_process from 'node:child_process';
import serviceAccount from '../firebase.json' with { type: 'json' };

config();

const firebaseId = process.argv[2];
if (!firebaseId) {
  console.error('Missing Firebase UID. Usage: node script.js <firebaseId>');
  process.exit(1);
}

const adminApp = fbAdmin.initializeApp({
  credential: fbAdmin.credential.cert(serviceAccount),
});

const webConfig = child_process
  .execSync(
    `firebase --project ${serviceAccount.project_id} apps:sdkconfig web`,
  )
  .toString()
  .trim();

const app = initializeApp(JSON.parse(webConfig));
const clientAuth = initializeAuth(app);

adminApp
  .auth()
  .createCustomToken(firebaseId)
  .then(async (customToken) => {
    const userCredential = await signInWithCustomToken(clientAuth, customToken);
    const idToken = await userCredential.user.getIdToken();
    console.log(idToken);
  })
  .catch((err) => {
    console.error('Error generating token:', err);
    process.exit(1);
  });

export default {};

get-jwt.js

I need user uid from firebase console, users section. When I call node get-jwt.js firebaseid, script will return me the jwt token:

curl http://localhost:8081/hello -h "Authorization: Bearer: <jwttokenhere>"

Prerequisite for this script to work is to have firebase cli installed and be logged in to the correct account.

curl -sL https://firebase.tools | bash
firebase login

Or you could download firebase web configuration and skip the firebase cli.n

GitHub: https://github.com/amarjanica/firebase-expo-demo