😽

[NestJS]Googleでログインして取得したメールアドレスとIDを使ってJWT認証を行う

2022/12/21に公開約17,000字

プロジェクトの作成とライブラリのインストール

以下のコマンドでまずは新規にプロジェクトを作成します。

nest new google-login
cd google-login

次に必要なライブラリをインストールします。

npm i @nestjs/passport passport passport-google-oauth20
npm i -D @types/passport-google-oauth20

Google アプリケーションを作成する

https://console.cloud.google.com/ を開き、左側のメニューから「API とサービス」を選びます。

左側の「認証情報」をクリックしてから、上の方にある「+認証情報を作成」→「OAuth クライアント ID」をクリックします。

「OAuth 同意画面」を一度も触ってない場合は、「OAuth クライアント ID の作成」という画面が出てくるので、「同意画面を設定」をクリックしていきます。

「OAuth 同意画面」で「外部」を選択して「作成」をクリックします。

「OAuth 同意画面」を編集していきます。

  • アプリ名: nextjs-example
  • ユーザーサポートメール: 自分のメールアドレス
  • デベロッパーの連絡先情報: 自分のメールアドレス

を設定しました。

スコープには /auth/userinfo.email/auth/userinfo.profileopenid を設定しました。

もう一度、左メニューの「認証情報」をクリックして、次に上に表示される「+認証情報を作成」→ OAuth クライアント ID の作成 をクリックします。

アプリケーションの種類は「ウェブアプリケーション」を選びます。

  • 承認済みの Javascript 生成元: http://localhost:3000
  • 承認済みのリダイレクト URI: http://localhost:3000/auth/redirect

とします。

ここで「作成」をクリックすると、「クライアント ID」と「クライアントシークレット」が表示されるので、コピーして保存しておきます。

.env にクライアント ID とクライアントシークレットを設定する

環境変数に先ほどダウンロードしたクライアント ID とシークレットを設定します。

touch .env
.env
GOOGLE_CLIENT_ID=xxxx
GOOGLE_CLIENT_SECRET=xxx
GOOGLE_AUTH_CALLBACK_URL=http://localhost:3000/auth/redirect

前準備の続き

以下のコマンドを実行して、認証用のモジュールを作成します。

nest g res auth --no-spec

? What transport layer do you use? REST API
? Would you like to generate CRUD entry points? Yes

環境変数を読み込むための ConfigModule も一緒にインストールします。

npm i --save @nestjs/config

src/app.module.ts に ConfigModule を設定します。

src/app.module.ts
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AuthModule } from './auth/auth.module';
import { ConfigModule } from '@nestjs/config';
import configuration from './config/configuration';

@Module({
  imports: [
    AuthModule,
    ConfigModule.forRoot({
      isGlobal: true,
      load: [configuration],
    }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

src/config.configuration.ts を作ります。

src/config.configuration.ts
export default () => ({
  google: {
    clientId: process.env.GOOGLE_CLIENT_ID,
    clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    callbackUrl: process.env.GOOGLE_AUTH_CALLBACK_URL,
  },
});

interface を定義する

Google から受け取ったユーザー情報を入れるための interface です。

src/auth/interfaces/user.interface.ts
export interface User {
  id: string;
  email: string;
  firstName: string;
  lastName: string;
  picture: string;
  accessToken: string;
  refreshToken?: string;
}

トークン情報をまとめた interface です。

src/auth/interfaces/token.interface.ts
export interface Token {
  accessToken: string;
  refreshToken: string;
}

Google 認証用のストラテジを作成

mkdir src/auth/strategies
touch src/auth/strategies/google.strategy.ts

ストラテジを作成します。 Injectable() をつけるのを忘れないようにしてください。

src/auth/strategies/google.strategy.ts
import { PassportStrategy } from '@nestjs/passport';
import { Profile, Strategy, VerifyCallback } from 'passport-google-oauth20';
import { User } from '../interfaces/user.interface';
import { ConfigService } from '@nestjs/config';
import { Injectable } from '@nestjs/common';

@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
  constructor(configService: ConfigService) {
    super({
      clientID: configService.get<string>('google.clientId'),
      clientSecret: configService.get<string>('google.clientSecret'),
      callbackURL: configService.get<string>('google.callbackUrl'),
      scope: ['email', 'profile', 'openid'],
      accessType: 'offline',
    });
  }

  async validate(
    accessToken: string,
    refreshToken: string,
    profile: Profile,
    _done: VerifyCallback,
  ): Promise<User> {
    const { id, name, emails, photos } = profile;
    const user: User = {
      id,
      email: emails[0].value,
      firstName: name.givenName,
      lastName: name.familyName,
      picture: photos[0].value,
      accessToken,
      refreshToken,
    };
    // _done(null, user);
    return user;
  }
}

auth.module.tsprovidersGoogleStrategy を設定します。

src/auth/auth.module.ts
import { Module } from "@nestjs/common";
import { AuthService } from "./auth.service";
import { AuthController } from "./auth.controller";
import { GoogleStrategy } from "./strategies/google.strategy";

@Module({
  controllers: [AuthController],
  providers: [AuthService, GoogleStrategy],
})
export class AuthModule {}

次に Guard を作成します。

src/auth/guards/google-oauth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class GoogleOauthGuard extends AuthGuard('google') {}

スコープは以下のものがあるようです。

スコープ 説明
email Google アカウントのメインのメールアドレスを表示する
openid Google で公開されているお客様の個人情報とお客様を関連付ける
profile ユーザーの個人情報の表示(ユーザーが一般公開しているすべての個人情報を含む)

Service の作成

認証サービスを作ります。

src/auth/auth.service.ts
import { Injectable, InternalServerErrorException } from "@nestjs/common";
import { User } from "./interfaces/user.interface";
import { Token } from "./interfaces/token.interface";

@Injectable()
export class AuthService {
  async login(user: User | undefined): Promise<Token> {
    if (user === undefined) {
      throw new InternalServerErrorException(
        `Googleからユーザー情報が渡されていませんね? ${user}`
      );
    }
    console.log("Googleから渡されたユーザーの情報です。", user);

    return {
      accessToken: user.accessToken,
      refreshToken: user.refreshToken,
    };
  }
}

Controller の作成

Google 認証を行うための API を作ります。

src/auth/auth.controller.ts
import { Controller, Get, Req, Request, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthGuard } from '@nestjs/passport';

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @Get()
  @UseGuards(AuthGuard('google'))
  async googleAuth(@Req() _req) {}

  @Get('redirect')
  @UseGuards(AuthGuard('google'))
  redirect(@Request() req) {
    return this.authService.login(req?.user);
  }
}

Google 認証を使ってみる

http://localhost:3000/authをブラウザで開くと、Google のログイン画面に飛ばされます。
Google でログインすると、アクセストークンがブラウザに表示されます。

コンソールには以下のように表示されます。

Googleから渡されたユーザーの情報です。 {
  id: '192719212',
  email: 'xxxx@gmail.com',
  firstName: 'xxx',
  lastName: 'xxxx',
  picture: 'https://lh3.googleusercontent.com/a/21212121=2121',
  accessToken: 'ssss',
  refreshToken: undefined
}

Google ログインと JWT 認証を組み合わせる

Google ログインで取得したユーザーの情報を使って JWT トークンを発行してみます。
ネット上で同じようなサンプルを作っている記事は Google OAuth2 Authentication with NestJS explained の 1 つしか見つけられなかったので、あまり一般的ではないのかもしれません。

無難にやるなら AWS Cognito を使うほうがサンプルが豊富です。

ターミナルで以下のコマンドを実行して、秘密鍵を生成します。

node -e "console.log(require('crypto').randomBytes(256).toString('base64'));"

.envJWT_ACCESS_SECRETJWT_REFRESH_SECRET を追記します。

.env
JWT_ACCESS_SECRET=5f9a+mSLNiK5R6sDEleBN/2pmmnrK+XuFv9drMtra9OJqcRMOuFvwTx9s+UI
JWT_REFRESH_SECRET=CpwWyDXmUmnBuOyNITWvA3W9NjFftWT099olyDOJjnpJh

configuration.tsJWT_ACCESS_SECRETJWT_REFRESH_SECRET を読み込みます。

config/configuration.ts
export default () => ({
  google: {
    clientId: process.env.GOOGLE_CLIENT_ID,
    clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    callbackUrl: process.env.GOOGLE_AUTH_CALLBACK_URL,
  },
  jwt: {
    accessSecret: process.env.JWT_ACCESS_SECRET,
    refreshSecret: process.env.JWT_REFRESH_SECRET,
  },
});

必要なライブラリのインストールをします。

npm i @nestjs/jwt passport-jwt
npm i -D @types/passport-jwt

JWT 認証用のストラテジを作成する

accessToken を validation するためのストラテジです。

src/auth/strategies/access-token.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';

type JwtPayload = {
  sub: string;
  username: string;
};

@Injectable()
export class AccessTokenStrategy extends PassportStrategy(Strategy, 'jwt') {
  constructor(private configService: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: configService.get<string>('jwt.accessSecret'),
    });
  }

  validate(payload: JwtPayload) {
    return payload;
  }
}
src/auth/strategies/refresh-token.strategy.ts
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { Request } from 'express';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class RefreshTokenStrategy extends PassportStrategy(
  Strategy,
  'jwt-refresh',
) {
  constructor(private configService: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: configService.get<string>('jwt.refreshSecret'),
      passReqToCallback: true,
    });
  }

  validate(req: Request, payload: any) {
    const refreshToken = req.get('Authorization').replace('Bearer', '').trim();
    return { ...payload, refreshToken };
  }
}

JWT 認証用の Guard を作成する

コントローラーで UseGuard するためのガードを作成します。
accessToken 用のガードと、refreshToken 用のガードです。

src/auth/guards/access-token.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class AccessTokenGuard extends AuthGuard('jwt') {}

refreshToken のガードはその名の通り、トークンを更新する API を呼び出すときに使います。

src/auth/guards/refresh-token.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class RefreshTokenGuard extends AuthGuard('jwt-refresh') {}

AuthService に JWT 認証用のメソッドを作成する

getTokens で accessToken と refreshToken を取得します。
accessToken は 15 分で期限切れするものにします。
refreshToken は 7 日間です。

accessToken の期限が切れたら refreshToken を使って更新します。

src/auth/auth.service.ts
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { User } from './interfaces/user.interface';
import { Token } from './interfaces/token.interface';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class AuthService {
  constructor(
    private jwtService: JwtService,
    private configService: ConfigService,
  ) {}

  async getTokens(userId: string, username: string) {
    const [accessToken, refreshToken] = await Promise.all([
      this.jwtService.signAsync(
        {
          sub: userId,
          username,
        },
        {
          secret: this.configService.get<string>('jwt.accessSecret'),
          expiresIn: '15m',
        },
      ),
      this.jwtService.signAsync(
        {
          sub: userId,
          username,
        },
        {
          secret: this.configService.get<string>('jwt.refreshSecret'),
          expiresIn: '7d',
        },
      ),
    ]);

    return {
      accessToken,
      refreshToken,
    };
  }

  async refreshTokens(userId: string, username: string, refreshToken: string) {
    // ちゃんとやりたい場合は、データベースに保存しておいたハッシュ化された refreshToken と
    // ユーザーから渡された refreshToken をハッシュ化して比較して、一致しない場合は例外を投げる。
    // ここはサンプルなので、 refreshTokens が呼ばれたら新しい accessToken を返す。
    const tokens = await this.getTokens(userId, username);
    return tokens;
  }

  async login(user: User | undefined): Promise<Token> {
    if (user === undefined) {
      throw new InternalServerErrorException(
        `Googleからユーザー情報が渡されていませんね? ${user}`,
      );
    }
    console.log('Googleから渡されたユーザーの情報です。', user);

    const { accessToken, refreshToken } = await this.getTokens(
      user.id,
      user.email,
    );

    return {
      accessToken,
      refreshToken,
    };
  }
}

AuthController に refreshToken 用の API を作る

refreshTokens というメソッドを追加します。
トークンを更新するための API です。

src/auth/auth.controller.ts
import { Controller, Get, Req, Request, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthGuard } from '@nestjs/passport';
import { RefreshTokenGuard } from './guards/refresh-token.guard';

@Controller('auth')
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @Get()
  @UseGuards(AuthGuard('google'))
  async googleAuth(@Req() _req) {}

  @Get('redirect')
  @UseGuards(AuthGuard('google'))
  redirect(@Request() req) {
    return this.authService.login(req?.user);
  }

  @Get('refresh')
  @UseGuards(RefreshTokenGuard)
  async refreshTokens(@Request() req) {
    const userId = req?.user.sub;
    const username = req.user.username;
    const refreshToken = req.user.refreshToken;

    return this.authService.refreshTokens(userId, username, refreshToken);
  }
}

AuthModule で JwtModule を import する

src/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { GoogleStrategy } from './strategies/google.strategy';
import { JwtModule } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { RefreshTokenStrategy } from './strategies/refresh-token.strategy';
import { AccessTokenStrategy } from './strategies/access-token.strategy';

@Module({
  imports: [
    // registerAsync の中で ConfigService を inject するやり方は以下の記事を参照する。
    // https://stackoverflow.com/questions/53426486/best-practice-to-use-config-service-in-nestjs-module
    // https://stackoverflow.com/questions/64337784/nestjs-use-configservice-in-simple-provider-class
    JwtModule.registerAsync({
      inject: [ConfigService],
      useFactory: async (configService: ConfigService) => ({
        secret: configService.get<string>('jwt.accessSecret'),
        signOptions: { expiresIn: '60s' },
      }),
    }),
  ],
  controllers: [AuthController],
  providers: [
    AuthService,
    GoogleStrategy,
    AccessTokenStrategy,
    RefreshTokenStrategy,
  ],
})
export class AuthModule {}

実際に JWT トークンを使ってみる

app.controller.ts accessToken でガードされた API を作ってみます。

src/app.controller.ts
import { Controller, Get, UseGuards } from '@nestjs/common';
import { AppService } from './app.service';
import { AccessTokenGuard } from './auth/guards/access-token.guard';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  getHello(): string {
    return this.appService.getHello();
  }

  @UseGuards(AccessTokenGuard)
  @Get('/secret')
  getGuardContent(): string {
    return '保護されたコンテンツです。';
  }
}

Authorization - BearerToken を設定せずに localhost:3000/secret を叩くと、以下のように 401 が返ってきます。

{
  "statusCode": 401,
  "message": "Unauthorized"
}

localhost:3000 は何もしなくても叩けます。

http://localhost:3000/auth をブラウザで開くと、Google でログイン画面にリダイレクトされます。

ログインすると、ブラウザ上に accessToken と refreshToken が表示されるはずです。

{
  "accessToken": "xxx.xxx.xxx",
  "refreshToken": "xxx.xxx.xxx"
}

ここで取得した accessToken を Authorization - Bearer Token に設定して localhost:3000/secret にリクエストを投げると、

「保護されたコンテンツです。」

というレスポンスが返ってきます。ガードされていたコンテンツを取得できました。

accessToken は 15 分で切れてしまうので、 refreshToken で更新してみましょう。

ブラウザで取得した refreshToken を Authorization - Bearer Token に設定して localhost:3000/auth/refresh を叩きます。

すると、新たに accessTokenrefreshToken がレスポンスとして返されます。

{
  "accessToken": "xxx.xxx.xxx",
  "refreshToken": "xxx.xxx.xxx"
}

更新した accessToken を使って、またコンテンツを取得していきます。

参考

GitHubで編集を提案

Discussion

ログインするとコメントできます