🧑‍⚖️

Nest.js × GraphQL に Firebase Auth で Authentication / Authorization を導入

2023/06/19に公開

概要

自社サーバーを書き直すにあたり、Nest.jsFirebase Authを使用したのでメモ

要件

  • クエリを叩く際に Authorization ヘッダーにBearer tokenを渡す
  • tokenからユーザーのroleを取得
  • GraphQLのクエリ単位でroleごとのアクセス権限を設定
  • sdkはFirebase Adminを使用

Nest.js + GraphQL

GraphQLが叩ける環境はできているものとする
https://docs.nestjs.com/graphql/quick-start

initializeApp

admin.initializeApp を叩くと、Firebaseが初期化されます。

src/app.ts
import { NestFactory } from '@nestjs/core';
import admin from 'firebase-admin';
import { AppModule } from './app.module';
import * as serviceAccount from 'path/to/credential.json';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  admin.initializeApp({
    credential: admin.credential.cert(serviceAccount as admin.ServiceAccount),
  });
  await app.listen(8000);
}
bootstrap();

tsconfigのresolveJsonModuletrue にしておくと良いです。

Middleware

requestの Authorization ヘッダーからtokenを取り出し、verifyします。

今回はFirebase AuthのCustom Claimにrole情報を保存していますが、MiddlewareにはDIもできるのでuserIdを用いて別のDBから取得するなど、お好きにユーザー情報を取得してください。

src/auth/auth.middleware.ts
import { HttpStatus, Injectable, NestMiddleware } from '@nestjs/common';
import { HttpException } from '@nestjs/common/exceptions/http.exception';
import { NextFunction, Request, Response } from 'express';
import { ICurrentUser } from './current-user.interface';
import admin from 'firebase-admin';

export type CustomRequest = Request & { user: ICurrentUser };

/** FirebaseLoginをしてユーザー情報をcontextに持たせるMiddleware */
@Injectable()
export class FirebaseAuthMiddleware implements NestMiddleware {
  async use(req: CustomRequest, _: Response, next: NextFunction) {
    const { authorization } = req.headers;
    if (authorization) {
      const token = authorization.slice(7); // remove "Bearer "
      req.user = await admin
        .auth()
        .verifyIdToken(token)
        .then((data) => ({
          id: data.uid,
          email: data.email ?? '',
          role: data.role, // custom claim
        }))
        .catch((err) => {
          throw new HttpException(
            { message: 'Token validation failed', err },
            HttpStatus.UNAUTHORIZED,
          );
        });
    }
    next();
  }
}

ちなみに実装は以下を大いに参考にしました。
https://github.com/abyssparanoia/nestjs-with-firebase/blob/master/src/firebase/middleware.ts

AuthModule

AuthModule を作ります。

src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { FirebaseAuthMiddleware } from './auth.middleware';

@Module({
  providers: [FirebaseAuthMiddleware],
  imports: [],
  exports: [FirebaseAuthMiddleware],
})
export class AuthModule {}

Resolverのあるmoduleにimportし、middlewareを consumerapply します。

src/graphql/graphql.module.ts
import { MiddlewareConsumer, Module } from '@nestjs/common';
import { AuthModule } from '../../auth/auth.module';

@Module({
  imports: [UseCaseModule, AuthModule],
  providers: [
    HumanResolver,
  ],
})
export class GraphqlModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(FirebaseAuthMiddleware).forRoutes('/');
  }
}

Guard

src/auth/auth.guard.ts
import {
  CanActivate,
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { Reflector } from '@nestjs/core';
import { RoleType } from './roles.decolator';
import { ICurrentUser } from './current-user.interface';

/** GraphQLを叩く権限のGuard
 * 実際に使う際は `@Roles` を用いる */
@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const roles = this.reflector.get<RoleType[]>('roles', context.getHandler());
    const ctx = GqlExecutionContext.create(context);
    const req = ctx.getContext().req;

    if (!roles) return true;

    try {
      const user: ICurrentUser = req.user;
      return roles.includes(user.role as RoleType);
    } catch (error) {
      throw new UnauthorizedException('Invalid user');
    }
  }
}

書きやすくするためのCustom Decoratorを定義します。

src/auth/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';

const roles = ['ADMIN', 'DEVELOPER', 'END_USER'] as const;
export type RoleType = (typeof roles)[number];

/** GraphQLを叩く権限のGuard
 * Example:
 * ```
 *  @Resolver(() => Human)
 *  export class HumanResolver {
 *   @Roles('ADMIN', 'DEVELOPER')
 *   @Mutation(() => Human)
 *   async createHuman() {}
 * ```
 *  */
export const Roles = (...roles: RoleType[]) => SetMetadata('roles', roles);

実装は以下を大いに参考にしました。
https://qiita.com/kmatae/items/da60d82dac9164a3855e

あとは適宜importします。

src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { FirebaseAuthMiddleware } from './auth.middleware';
import { RolesGuard } from './auth.guard';

@Module({
  providers: [FirebaseAuthMiddleware, RolesGuard],
  imports: [],
  exports: [FirebaseAuthMiddleware, RolesGuard],
})
export class AuthModule {}
src/graphql/graphql.module.ts
import { MiddlewareConsumer, Module } from '@nestjs/common';
import { AuthModule } from '../../auth/auth.module';
import { APP_GUARD } from '@nestjs/core';
import { RolesGuard } from '../../auth/auth.guard';

@Module({
  imports: [UseCaseModule, AuthModule],
  providers: [
    HumanResolver,
    { provide: APP_GUARD, useClass: RolesGuard },
  ],
})
export class GraphqlModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(FirebaseAuthMiddleware).forRoutes('/');
  }
}

こんな感じで使えます。

src/graphql/human/human.resolver.ts
@Resolver(() => Human)
export class HumanResolver {
  @Roles('ADMIN', 'DEVELOPER')
  @Mutation(() => Human)
  async createHuman() {...}

まとめ

  • middlewareで認証を行い、contextにuserを突っ込んでおく
  • guardの中で取り出して適切にauthorizeする
  • UseGuard は decoratorでwrapしておくと便利

Discussion