Roll With IT

tamakiのIT日記

CI 環境構築で直面した E2E テスト自動化の壁 〜Google の bot 対策による脅威のディフェンス力〜

はじめに

Next.js と Rails を使ってオリジナルの Web アプリを開発しています。

github.com

Playwright を導入し E2E テストの実装は問題なくできましたが、その後 CI でのテストの自動化がうまくいかず数日ハマってしまいました。今回の記事ではその原因と結果についてまとめています。

Playwright 導入については以下記事にまとめています。

shirotamaki.hatenablog.com

CI 環境構築

ローカル環境下でパスした E2E テストの自動化に取り組みました。

GitHub Actions に yml ファイルを用意し自動化のためのワークフローを記述しました。

name: "Frontend E2E Test"

on:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]

jobs:
  e2e-test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:14.8
        ports:
          - "5432:5432"
        env:
          POSTGRES_DB: rails_test
          POSTGRES_USER: rails
          POSTGRES_PASSWORD: password
    env:
      RAILS_ENV: test
      DATABASE_URL: "postgres://rails:password@localhost:5432/rails_test"
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - name: Install Ruby and gems
        uses: ruby/setup-ruby@55283cc23133118229fd3f97f9336ee23a179fcf # v1.146.0
        with:
          ruby-version: "3.2.1"
          bundler-cache: true
      - name: Install libvips
        run: sudo apt install -y libvips
      working-directory: ./backend
      - name: Install dependencies
        run: |
          cd backend
          bundle install
        working-directory: ./backend
      - name: Set up database schema and Start Rails server
        run: |
          bundle exec rails db:schema:load
          bundle exec rails db:reset RAILS_ENV=test
          bundle exec rails s &
          sleep 30
        working-directory: ./backend
      - name: Setup Node.js 18.x
        uses: actions/setup-node@v3
        with:
          node-version: 18.x
      - name: Install dependencies
        run: npm ci
        working-directory: ./frontend
      - name: Install Playwright Browsers
        run: npx playwright install --with-deps
        working-directory: ./frontend
      - name: Start Next.js server
        run: |
          npm run dev &
          sleep 30
        working-directory: ./frontend
      - name: Run Playwright tests
        run: npx playwright test
        working-directory: ./frontend/playwright

しかし、エラーが発生しテストが回りません。

Error occurred during API request: AxiosError: connect ECONNREFUSED ::1:3000

原因と結論

今回発生したエラーの原因、そして結論を説明します。

今回エラーが発生している箇所は、Google ログイン認証のステップであることがわかりました。

テスト用に準備した Google アカウントの Email を利用しログインを実行する際、Google 側の bot 対策に引っかかり Google ログイン認証を通過できない状況です。ローカル環境では下記エラーは起きません。しかし、GitHub Actions CI 環境下においてはエラーが発生する状況です。


bot と疑われログインが拒否された時にブラウザに表示されるエラー内容:

error6_5_png.png

テスト用アカウントに届いた Google からの通知内容:

重大なセキュリティ通知_-_test_taro_outdoor_heart_sutra_gmail_com_-_Gmail.png

対応として、 bot と認定されないように Google 認証を行う方法、また Google 認証ログイン自体をモック化し対応する方法も検討しましたが、どちらも有効な手立てが見つかりませんでした。

そのため、CI 環境下での E2E テストは断念する結論に至りました。

E2E テストは、ローカル環境下では問題なくパスできるため、動作確認は担保されていると判断したためです。

デバッグ

結果としては、E2E テストを CI 環境下では実施しない判断となりましたが、「CI 環境構築において Playwright を使った E2E テスト」の実装に伴って取り組んだ内容を記録として残します。

環境変数の管理

next サーバーで使用する環境変数が正しく設定ができていなかったため見直しました。

// .github/workflows/frontend-test.yml

      - name: Start Next.js server
        run: |
          npm run dev &
        env:
          GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
          GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
          NEXTAUTH_URL: ${{ secrets.NEXTAUTH_URL }}
          NEXTAUTH_URL_INTERNAL: ${{ secrets.NEXTAUTH_URL_INTERNAL }}
          NEXT_PUBLIC_GEOCODING_API_KEY: ${{ secrets.NEXT_PUBLIC_GEOCODING_API_KEY }}
          NEXT_PUBLIC_GOOGLE_MAPS_API_KEY: ${{ secrets.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY }}
          NEXT_PUBLIC_RAILS_API_URL: ${{ secrets.NEXT_PUBLIC_RAILS_API_URL }}
          NEXT_SECRET: ${{ secrets.NEXT_SECRET }}
          RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
        working-directory: ./frontend

Notification_Center.png

環境変数は取得できているか?

環境変数にちゃんと欲しい値が渡っているか確認しました。

console.log で確認する例:

// frontend/playwright/global-setup.ts

import { userEmail, userPassword, baseURL } from "playwright/config/index";

console.log("========================================================");
console.log(userEmail);
console.log(userPassword);
console.log(baseURL);
console.log("========================================================");

ひとつ注意点ですが、ローカルではちゃんと値の表示は確認できますが、GitHub Actions の secrets に保存した値は console に出力したときに、***とマスクされて出力されます。GitHub 上の仕様かと思われます。

Run npx playwright test
========================================================
***
***
***
========================================================

デバッグ用で撮影したスクショを GitHub に保存する設定

ローカル環境下においてはスクショを保存し確認できていたのですが、GitHub 上で確認ができておらずデバッグを困難にしていました。

下記のプログラムで GitHub 上でも確認が可能になります。

参考: ワークフロー データを成果物として保存する - GitHub Docs

// .github/workflows/frontend-test.yml

    - name: Archive screenshots
        uses: actions/upload-artifact@v3
        with:
          name: screenshots
          path: ./frontend/playwright/screenshots
        if: always()

全画面をスクショする場合、以下を任意の箇所へ配置しておきます。

await page.screenshot({
  path: "playwright/screenshots/error.png",
  fullPage: true,
});

GitHub 上で確認する時:

Notification_Center.png

上記のif: always()ですが、他のジョブが成功したかに関わらず、完了後に常に実行されます。

参考: GitHub Actions のワークフロー構文 - GitHub Docs

Cypress から Playwrightに乗り換えた

はじめに

Next.jsとRailsを使ってオリジナルのWebアプリを開発しています。

github.com

当初はE2Eテストを実装するためにCypressを選択しましたが、Google認証が突破できずに導入を断念しました。その後、Playwrightを導入しGoogle認証を突破できたので、今回の記事はその経緯をまとめた内容となります。

Cypressの導入から使用を断念するまでの経緯

Cypress を選択した理由

周りの先輩プログラマが多く採用していること、またテストライブラリでは現時点で一番人気で情報も多いことが選択した理由です。今回フロントエンドのE2Eテストが初めてなのもあり、機能面や各ライブリの特徴、書き心地など拘りはなく、学習コストが低く最速でE2Eテストが書け、かつメンテナンスも定期的に行われており、問題が起きにくいことだけをポイントに選択しました。

Cypressをインストールする

$ npm install cypress --save-dev

動作確認する

各種設定後にサンプルテストで動作確認を行います。

まずはチュートリアル等を参考にサンプルのテストを書いてみました。問題なく動作しテストが書けることを確認しました。

Cypressに持った第一印象は、「Jest、RSpecと同じような形式の構文なのでわかりやすい」でした。Cypress Studioという便利なツールもあり、「超便利!」とテンションが上がり、この時点ではCypress一択で他は検討する余地もないなと思っていました。

ハマった内容

私が開発しているアプリは、next-authを利用しGoogle 認証ログイン機能を実装しています。

shirotamaki.hatenablog.com

そのため、E2Eテストを行う上で、まずはGoogle 認証を行いログインする必要があります。ログインが成功しないとアプリの機能を利用できない仕様になっており、まずは最優先でGoogle認証ログインを突破する必要がありました。

しかし簡単そうに見えたこの問題が予想以上に難しく、Google 認証ログインが突破できずハマってしまいました。

試したこと

E2Eテストの目的は、あくまでアプリの使用フロー(ユーザーが実際に操作する部分)をシミュレーションすることです。next-authが提供しているGoogle認証自体はGoogleが提供しているサービスなのでテストは不要と判断しました。この方針を元にログインすることを目的に解決策を探してみました。

Best Practice: Only test websites that you control. Best Practices | Cypress Documentation

試したこと:その1

まずは公式サイトに掲載されているGoogle Authentication の内容を参考に試しました。

docs.cypress.io

カスタム Cypress コマンドを使用してプログラムで Google で認証する方法について書かれています。Cypressの各テストは、同じオリジンのドメインを訪問するように制限されているため、サードパーティGoogle)のログインページを訪問してテストすることは、サードパーティ(Google) の認証APIとプログラムでやり取りしてユーザーをログインさせることで回避できるようです。しかし、ドキュメントの内容に倣い、Google OAuth 2.0 Playground を使用しリフレッシュトークンを取得し、認証情報の設定など行いましたが動作しません。

タイトルから自分が今抱えている問題に対する解決策だと思われましたが上手くいきませんでした。

試したこと:その2

next-authのドキュメント

next-auth.js.org

cypress-social-logins - npmを使用することで、Cyptessをテストできるとあります。しかしnpmのサイトを見ると「CI環境ではうまく動作しません」とありました。

This plugin doesn't work well in a CI environment, due to the anti-fraud detection mechanisms employed by the likes of Google, GitHub etc.

CI環境で動作しないのは問題ではないか?とモヤモヤしながらもまずは試してみることに。しかしここでも問題は解決できませんでした。

試したこと:その3

自分と同じ問題を抱えているIssueを発見!

github.com

I followed this documentation and it isn't quite enough for me to figure out how to use Cypress with NextAuth: Testing with Cypress | NextAuth.js

「ログインフローをテストするのではなく、認証ウォールの後ろに隠されたページにアクセスできるようにセッションをスタブするだけです」と、回答が付いており、まさに自分が知りたかった内容です。解決策としてサンプルコードも付いており早速試してみました。しかし、version 12.0.0 に関するエラーが頻出し問題を解決することができませんでした。

わかったこと

上記1〜3を試しましたが、Google 認証を突破することはできませんでした。しかし試したことでわかったことがあります。

現在のCypressの最新バージョン は12.17.2。バージョンが10以下、10、10以上とざっくり分けてこの3パターンで大きな違いがあることがわかりました。上記見つけた解決策と自分の環境でピッタリと合うものがないことも、問題が解決しない原因である可能性があることがわかりました。

開発環境を合わせて対応する案も検討しましたが、他で不具合が起こることを懸念し断念することにしました。

またテストコード内で異なるオリジン間へのアクセスがリジェクトされるようで、これも原因かもしれないと思いましたが、version 12で複数のオリジンに対応したようです。

結果として、明確な原因がわからないことがわかりました。

As of Cypress v12.0.0, Cypress has the capability to visit multiple origins in a single test via the cy.origin() command!

代替案を探す

「わからないことがわかりました」と、「無知の知」を気取ってみたものの、結局は何も解決していない状況なのは変わりません。

Cypressを途中まで実装していたので、またやり直しか...とテンションが下がっていましたが、気持ちを切り替え、まずは最初からフラットに考えてみることにしました。

目的はE2Eテストを実装することです。Cypressに拘る理由はありません。

Google 認証 を突破すること、まずは問題を解決するための目的を明確にし(CypressでE2Eテストするではなく、テストライブラリを利用してE2Eテストする)テストライブラリの選定から見直すことにしました。

色々調べた結果、Playwright に勝機が見えたため、Playwrightを選択から導入までの経緯についてもまとめておきたいと思います。

テストライブラリを比較検討する

Playwrightを選択することにはなりましたが、その過程で検討したライブラリの情報も簡単にまとめておきます。

  • Playwright
    • ChromiumFirefoxWebKit(Safari)を同じ API で操作できる
    • 非同期で動 作することによる、テストの高速実行。ブラウザのクラッシュやフリーズに対する強い耐性を持っている
    • MicrosoftOSS として開発が進められ、元 Puppeteer の開発チームのメンバーもいるため、Puppeteer のコードを活 用しながら改良や新機能が追加されている
    • Cypressに次いで人気があり、Cypress との比較で一番最初に名前が上がるライブラリ。
  • Puppeteer
    • Puppeteer とは ChromeChromium(Chrome のベースとなる OSS プロジェクト)を操作するための Node ライブラリ。GoogleChrome DevTools チームが開発している OSS になる。
  • Selenium
    • この中では一番古参のライブラリ。元々は Web アプリケーションの UI テストや JavaScript のテストの目的で開発されたが、テスト以外にもタスクの自動化や Web サイトのクローリングなど様々な用途で利用されている。
    • セットアップが難しい。学習コストが高い。大規模サービス向け? 高機能すぎるので選択肢からは除外。
  • TestCafe
    • WebDriverや他のサードパーティ製のプラグインは不要。
    • インストールとセットアップは容易。
    • パっと見良さそうだが情報が少ない。Microsoftにより開発、メンテナンスされているPlaywrightの方が人気があり息が長そう。

参考

Playwright に乗り換える

Playwright のセットアップ

Playwrightをセットアップします。

インストールする

$ npm init playwright@latest

オプション選択

Choose between TypeScript or JavaScript (default is TypeScript)  TypeScript
Name of your Tests folder (default is tests or e2e if you already have a tests folder in your project) e2e
Add a GitHub Actions workflow to easily run tests on CI  true
Install Playwright browsers (default is true) true

作成されるファイル

  • ./e2e/example.spec.ts - Example end-to-end test
  • ./tests-examples/demo-todo-app.spec.ts - Demo Todo App end-to-end tests
  • ./playwright.config.ts - Playwright Test configuration

その後ファイル構成を整理し以下の配置に変更しました。

├── playwright
│   ├── config
│   │   └── index.ts
│   ├── global-setup.ts
│   ├── screenshots
│   │   ├── example.png
│   ├── setup
│   │   └── storage-state.json
│   └── tests
│       └── e2e
│           ├── foobar.spec.ts
├── playwright-report
│   └── index.html
├── playwright.config.ts

VSCode拡張機能をインストールする

Playwright Test for VSCode

package.json の scripts に Playwright を追加する

// frontend/package.json

"test:e2e": "playwright test",

playwright.config.ts に baseURL を設定する

// frontend/playwright.config.ts

baseURL: 'http://localhost:4000',

参考

Playwright を使用しGoogle 認証の突破を試みる

まずは公式ドキュメントで案内されている内容に従います。

playwright.dev

Playwright は、BrowserContext と呼ばれる分離された環境でテストが実行されています。この分離されたブラウザの実行環境により再現性が向上し、連鎖的なテストの失敗が防止されています。

テストでは既存の認証済み状態をロードするできます。これにより、各テストで毎回認証する必要がなくなり、テストの実行速度が向上します。

具体的な認証手順は、認証済みのブラウザ状態を生成し、これをplaywright/.authディレクトリのファイルに保存します。その後、テストの度に認証済みのブラウザを再利用し、認証済みの状態でテストをスタートすることができます。

試してみる

必要なディレクトリやファイルを準備し、ドキュメントに倣い設定ファイルを準備します。

問題発生

しかしここで問題発生。

公式で取り扱っている実装例がGitHubの認証パターンになり、Google認証には対応していませんでした。GitHubのユーザー名やパスワードをGoogle に置き換えてみたが上手くいきません。

解決策

その後解決策を探している中でこちらの記事を見つけました。

adequatica.medium.com

自分と同じ問題に対する解決策が記載されており期待できそうなので試してみることにしました。

Google 認証のログインに成功!

見つけた解決策を元に実装を試みました。Google 認証のログインに成功しました!

APIを通じて認証する方がUIを通じて認証するよりも速いようですが非常に複雑で難しいのが難点です。

しかしここで紹介されている認証手順は至ってシンプルです。Googleのサインインフォームを通して、ユーザー名とパスワードで認証する方法になります。

まずGlobal Setup が必要になります。globalSetupに設定を加えることで、すべてのテストを実行する前に設定項目を実行することができます。

Global Setup 関数内で行う手順:

  1. puppeteer-extra-plugin-stealthを使って、playwright-extra経由で新しいブラウザページを開始する。そのためにnpmをインストールし以下設定が必要。
// frontend/playwright/global-setup.ts

import { chromium } from 'playwright-extra'
import StealthPlugin from 'puppeteer-extra-plugin-stealth'

chromium.use(StealthPlugin())
  1. テスト対象のサイトのログインページに移動する。
  2. Googleサインインフォームを開く
  3. ユーザーの認証情報を入力する
  4. テスト済みのサイトを開く(リダイレクトを待つ)
  5. storageStateメソッドでログイン状態を保存する
  6. ブラウザを閉じる

Global Setupのファイルを指定、Google認証情報を保存する場所を指定

テストをスタートする前に実行するファイルを指定します。

// frontend/playwright.config.ts

globalSetup: process.env.SKIP_AUTH ? '' : './playwright/global-setup',

Google 認証情報を保存する場所を指定します。現在のcookies とlocal storage snapshot を保存する場所になります。

// frontend/playwright.config.ts

storageState: './playwright/setup/storage-state.json',

必要なプラグインをインストールする

$ npm install playwright-extra
$ npm install puppeteer-extra-plugin-stealth

Global Setup用のファイルを用意する

global-setup.ts にGoogle 認証をログインするために必要なコードを記述します。

playwright/setup/storage-state.jsonGoogle 認証情報を保存します。

このファイルに記述した内容がテストをスタートする前に実行されるので、Google 認証ログインした状態でテストを始めることができます。

// frontend/playwright/global-setup.ts

import { chromium } from 'playwright-extra'
import StealthPlugin from 'puppeteer-extra-plugin-stealth'
import { userEmail, userPassword } from 'playwright/config/index'

chromium.use(StealthPlugin())

const baseURL = 'http://localhost:4000'

const authFile = 'playwright/setup/storage-state.json'

async function globalSetup(): Promise<void> {
  const browser = await chromium.launch({ headless: true })

  const page = await browser.newPage()

  // Open log in page on tested site

  await page.goto(baseURL)
  await page.getByText('Googleでログインして始める').click()
  // Click redirects page to Google auth form,
  // parse https://accounts.google.com/ page
  const html = await page.locator('body').innerHTML()

  // Determine type of Google sign in form
  if (html.includes('aria-label="Google"')) {
    // Old Google sign in form
    await page.fill('#Email', userEmail)
    await page.locator('#next').click()
    await page.fill('#password', userPassword)
    await page.locator('#submit').click()
  } else {
    // New Google sign in form
    await page.fill('input[type="email"]', userEmail)
    await page.locator('#identifierNext >> button').click()
    await page.fill('#password >> input[type="password"]', userPassword)
    await page.locator('button >> nth=1').click()
  }

  // Wait for redirect back to tested site after authentication
  await page.waitForURL(baseURL)
  // Save signed in state
  await page.context().storageState({ path: authFile })

  await browser.close()
}

export default globalSetup

テストで使用するブラウザを設定する

chromiumfirefoxを設定します。

デフォルトだとヘッドレスモードでブラウザを実行する設定になっています。起動オプションとしてheadless: falseを設定しておくとよいと思います。

// frontend/playwright.config.ts

  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },

    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        storageState: './playwright/setup/storage-state.json',
        headless: false,
      },
      dependencies: ['setup'],
    },

    {
      name: 'firefox',
      use: {
        ...devices['Desktop Firefox'],
        storageState: './playwright/setup/storage-state.json',
        headless: false,
      },
      dependencies: ['setup'],
    },
  ],

テストを実行してみる

Google 認証ログイン後の状態からスタートできるので、問題なくE2Eテストが実行できます。

// frontend/playwright/tests/example.spec.ts

import { test, expect, Page } from '@playwright/test'

let page: Page

test.beforeAll(async ({ browser }) => {
  page = await browser.newPage()
})

test.afterAll(async () => {
  await page.close()
})

test.describe('Sing in on home page', async () => {
  test.describe.configure({ mode: 'serial' })

  test.beforeAll(async () => {
    await page.goto('/')
  })

  test('example test', async () => {
    await page.goto('/about')
    await page.waitForURL('http://localhost:4000/about')
    const content = await page.textContent('h1')
    expect(content).toContain('アウトドア')

    await page.goto('/mypage')
    await page.waitForURL('http://localhost:4000/mypage')
    const content2 = await page.textContent('li')
    expect(content2).toContain('ユーザ名:テスト太郎')

    await page.screenshot({ path: 'playwright/screenshots/example.png' })
  })
})

認証情報を保存したファイルなどは .gitignore に追加しておく

# frontend/.gitignore

# playwright
/playwright/setup/
/playwright/screenshots/
/test-results/
/playwright-report/

おわりに

フロントエンドの技術は、バックエンド以上に改善、更新されるスピードが速く感じます。今回問題で取り上げたケースでは、CypressからPlaywrightに乗り換えましたが、今後はその逆もあるかと思いますし、またより優れたライブラリの登場でまったく違う景色になっている可能性も十分に考えられます。

今回のケースが多くの方に該当するかはわかりませんが、少しでも同じ問題に直面している方の参考になれば幸いです。

最後までお読みいただきありがとうございました!

Vercelに設定した環境変数を更新した時は再デプロイを忘れずに!

前回に続き「忘れずに!」シリーズの第2弾Death 🤘

タイトルの通りになりますが、今回ハマったポイントについて書きたいと思います。

前回のブログで取り上げた環境変数の設定をふまえて説明したいと思います。

403 Forbiddenエラー

本番環境にデプロイ後、動作確認を行うと「Access Denied」とブラウザに表示され、403 Forbiddenエラーが返ってきました。

403 Forbidden - HTTP | MDN

環境変数の設定ミスを疑い調べたところ、変数名の付け方に誤りが見つかりました。

以下、Vercel側で環境変数を設定し直しました。

前回のブログで取り上げた内容に従い修正を行い、「ヨシ!これで問題ないだろう」と思い、いざVercelを立ち上げ本番環境で確認しましたが、エラーが改善されません....😭

原因

Vercel側で設定する環境変数の値をデプロイ後に更新したことにより、正しい環境変数が本番環境に反映されていないのが原因でした。

Next.jsで扱っている環境変数は、デプロイする度に再設定が行われ本番環境に反映されるしくみです。環境変数を変更しただけでは、本番環境には反映されません。

そのため環境変数を更新した場合は、必ず再デプロイ(再ビルド)し、環境変数を本番環境へ反映させる必要があります。

Vercelに設定した環境変数を更新した時は再デプロイを忘れないように注意しましょう!

ブラウザへ環境変数を公開したい時は`NEXT_PUBLIC_`を忘れずに!

Next.jsでは、環境変数名の前にプレフィックスNEXT_PUBLIC_を付けないとブラウザ側に公開されません。

By default environment variables are only available in the Node.js environment, meaning they won't be exposed to the browser.

In order to expose a variable to the browser you have to prefix the variable with NEXT_PUBLIC_. For example:

NEXT_PUBLIC_ANALYTICS_ID=abcdefghijk

Configuring: Environment Variables | Next.js

正しい環境変数命名

例えば今開発しているプロジェクトの状況ですが、以下の内容で環境変数を設定しています。本番環境(Vercel)に設定している例です。

GOOGLE_CLIENT_ID=google_client_id_sample.com

GOOGLE_CLIENT_SECRET=google_client_secret_sample_1234

NEXT_PUBLIC_GOOGLE_MAPS_API_KEY=google_maps_api_key_sample_1234

NEXT_PUBLIC_API_URL=https://outdoor-heart-sutra-backend.fly.dev

NEXTAUTH_URL=https://outdoor-heart-sutra.vercel.app

NEXT_SECRET=next_secret_sample_1234

ひとつひとつ取り上げていきます。

GOOGLE_CLIENT_ID

GoogleのOAuth2.0クライアントIDになります。過去のブログで取り上げたGoogle APIを設定した時に取得したIDになります。

GOOGLE_CLIENT_SECRET

GoogleのOAuth2.0クライアントシークレットです。上記クライアントIDと一緒に取得したクライアントシークレットです。

NEXT_PUBLIC_GOOGLE_MAPS_API_KEY

Google MapsAPIキーです。

NEXT_PUBLIC_API_URL

Rails APIのURLを設定します。Fly.ioにデプロイしています。

NEXTAUTH_URL

この環境変数は本番環境にデプロイする場合に必要なものです。Vercelから付与されるURLを設定します。

NEXT_SECRET

この値はNextAuth.jsのJWTを暗号化し、メール確認トークンをハッシュ化するために使用される秘密鍵です。

メール確認トークンのハッシュ化は、NextAuth.jsがユーザーのメールアドレスを確認するプロセスをより安全にするための方法で、秘密鍵NEXT_SECRETがこのハッシュ化に使用されます。

あらかじめデフォルト値で設定しておかないと本番環境でエラーが発生します。

Not providing any secret or NEXTAUTH_SECRET will throw an error in production.

Options | NextAuth.js

秘密鍵の作成にはopensslコマンドが便利です。

$ openssl rand -base64 32

誤った環境変数命名

前置きが長くなりましたがここからが本題です。

正しい環境変数名を設定する前、以下のように設定していました。

# Google MapsのAPIキー
GOOGLE_MAPS_API_KEY=google_maps_api_key_sample_1234

# Rails APIのURL
API_URL=https://outdoor-heart-sutra-backend.fly.dev

# 他、環境変数は問題なし

このように環境変数名にプレフィックスNEXT_PUBLIC_を付与していない状態でした。

上記の環境変数名の場合、Google MapsAPIキーもRails APIのパスもフロント側で使用していたため、本番環境で環境変数が反映されずにエラーが発生していました。

Next.jsでは、環境変数NEXT_PUBLIC_を付けないとブラウザ側に公開されない仕様になっています。

サーバーサイドだけでの使用であれば付与せずに問題ないですが、ブラウザ側で使用したい場合は、プレフィックスNEXT_PUBLIC_の付与が必須になります。

ブラウザへ環境変数を公開したい時はプレフィックスNEXT_PUBLIC_の付与を忘れないように注意しましょう!

【後半】Rails APIを叩いてユーザー情報をDBに保存する(Next.js × Railsで実践するアプリ開発)

はじめに

Next.jsとRailsを使ってオリジナルのWebアプリを開発しています。

github.com

ログイン機能を備えたアプリにするために、Google認証でログインする方法を選択しました。この記事では、実装過程において調べた情報や試行錯誤しハマった経験などをまとめたいと思います。

前編、後編の2部構成でお届けします。

前編

Next.jsのNextAuth.jsを使用しGoogle認証ログイン、ログアウトに焦点を当てた内容です。 前編はこちら

後編

ログインしたユーザーの情報をデータベースに保存する方法について書きます。Rails APIを叩いてデータベースに保存する方法です。Next.jsとRailsを使ったユーザー登録と削除についてまとめたいと思います。

技術スタック

フロントエンド:
  • Next.js '13.2.4'
  • 使用するnpm
    • next-auth '4.20.1'
    • axios '1.3.4'
バックエンド:
  • Ruby '3.2.1'
  • Rails '7.0.4.2' API mode
  • 使用するgem
    • rack-cors '2.0.0'
データベース: 
リポジトリ

github.com

Rails APIを叩いてユーザー情報をDBに保存する

Railsのプロジェクトを作成する

rails new

$ rails new . --api --database=postgresql --skip-webpack-install --skip-javascript --skip-test

オプションの説明

  • --api: APIモードでRailsアプリケーションを作成します。不要なミドルウェアやジェネレータを削除します。
  • --database=postgresql: デフォルトのデータベースとしてPostgreSQLを指定します。
  • --skip-webpack-install: Webpackerのインストールをスキップします。Next.jsでフロントエンドを構築するためWebPackは不要です。Rails7ではデフォルトで使用しなくなったっぽいですが、念のため付けておきます。
  • --skip-javascript: JavaScriptのサポートを無効にします。
  • --skip-test: RailsのデフォルトのテストフレームワークであるMinitestをスキップし、代わりにRSpecとCypressを使用することを想定しています。(今回はテストは省略しています)

.gitを削除する

$ rm -rf .git

.gitディレクトリを削除します。.gitを削除するなら最初から--skip-gitすることもできますが、初期の.gitignoreが欲しいのでスキップはせずに後から削除します。

因みにファイル構成は以下のようにしているため.gitはプロジェクトのトップで管理しています。

.
├── .git
│   ├── COMMIT_EDITMSG
│   ├── FETCH_HEAD
│   ├── HEAD
│   ├── config
│   ├── description
│   ├── hooks
│   ├── index
│   ├── info
│   ├── logs
│   ├── objects
│   └── refs
└── next-rails-google-auth
    ├── backend(Rails)
    └── frontend(Next.js)

モジュール名を変更する

初期値だとディレクトリ名のBackendになっています。プロジェクト名に名前を変更しておきます。

# backend/config/application.rb

module NextRailsGoogleAuthApi

データベースを作成しモデルを準備する

最低限必要なカラムだけ設定し作成します。細かい制約、バリデーションなどは省略します。

# backend/db/schema.rb

  create_table "users", force: :cascade do |t|
    t.string "provider"
    t.string "uid"
    t.string "name"
    t.string "email"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
  end

ルーティングを設定する

User登録、削除のためのルーティングを設定します。

# backend/config/routes.rb

 post 'auth/:provider/callback', to: 'api/v1/users#create'
 delete 'users/:email', to: 'api/v1/users#destroy', constraints: { email: %r{[^/]+} }

コントローラーを準備する

createが成功したらhead :okと返します。

当初、ここでredirect_to 'http://localhost:4000/'としてリダイレクト処理をしていました。しかしステータスコード302を返しているのが原因でエラーが出ていました。

今回やりたいことは「ユーザー登録が成功したかどうか?」の確認がしたいだけなのでリダイレクトは不要でした。ステータスコード200を返すだけで十分なので、head :okと変更し、無事ログイン処理は成功しました。

# backend/app/controllers/api/v1/users_controller.rb

module Api
  module V1
    class UsersController < ApplicationController

      def create
        # 引数の条件に該当するデータがあればそれを返す。なければ新規作成する
        user = User.find_or_create_by(provider: params[:provider], uid: params[:uid], name: params[:name], email: params[:email])                      
        if user
          head :ok
        else
          render json: { error: "ログインに失敗しました" }, status: :unprocessable_entity
        end
      rescue StandardError => e
        render json: { error: e.message }, status: :internal_server_error
      end

      def destroy
        user = User.find_by(email: params[:email])
        if user
          user.destroy
        else
          render json: { error: "ユーザーが見つかりませんでした" }, status: :not_found
        end
      rescue StandardError => e
        render json: { error: e.message }, status: :internal_server_error
      end
    end
  end
end

200以外はfalseを返す処理にしています。

// frontend/src/pages/api/auth/[...nextauth].ts

        if (response.status === 200) {
          return true
        } else {
          return false
        }

フロントエンドとバックエンドの連携を設定する

rack-cors

Rails APIは、rack-corsのgemを使って簡単にCORS(オリジン間リソース共有)の設定を行うことができます。このgemを利用することで、Next.js と Rails APIの連携が可能になります。

APIモードでRailsアプリを開発しているので、既にGemfileにrack-corsが記載されています。コメントアウトを解除してbundle installします。

# backend/Gemfile

# Use Rack CORS for handling Cross-Origin Resource Sharing (CORS), making cross-origin AJAX possible
gem "rack-cors"

インストール後、cnfig/initializers/cors.rbファイルに設定内容を記述します。

origins "http://localhost:4000"とすることで、リクエストを受け取れるようにします。"http://localhost:4000"からのリクエストは許可しますよ。という意味です。

今回はNext.js側でhttp://localhost:4000を使っているため指定したいと思います。ちなみに、origins を*とすると、どこからでもRails APIを叩けることになるので危険です。

# backend/config/initializers/cors.rb

Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins "http://localhost:4000"

    resource "*",
            headers: :any,
            methods: [:get, :post, :put, :patch, :delete, :options, :head]
  end
end

axios

axiosは、非同期通信でデータを取得するためのモジュール、ライブラリです。Node.jsで書かれています。ブラウザや node.js で動く Promise ベースの HTTP クライアントです。REST APIを実行したいときなど、このnpmを使うと実装が簡単にできます。

今回は、Rails APIを叩くために利用したいと思います。

前編でインストール済みですが、まだの場合はインストールします。

$ npm install axios

Next.js側からRails APIを叩き、ユーザー情報をDBに保存する

コールバックを追加します。signIn()が実行されたときに呼び出す内容を定義します。

ユーザー情報を取得し、Rails APIに向けてPOSTします。apiUrl変数は、環境変数で管理しているので http://localhost:3000 となります。

// frontend/src/pages/api/auth/[...nextauth].ts

import NextAuth from 'next-auth'
import GoogleProvider from 'next-auth/providers/google'
import axios from 'axios'

const apiUrl = process.env.NEXT_PUBLIC_API_URL

export default NextAuth({
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    }),
  ],
  callbacks: {
    async signIn({ user, account, profile }) {
      const provider = account.provider
      const uid = account.sub
      const name = user.name
      const email = user.email

      try {
        const response = await axios.post(`${apiUrl}/auth/${provider}/callback`, {
          provider,
          uid,
          name,
          email,
        })

        if (response.status === 200) {
          return true
        } else {
          return false
        }
      } catch (error) {
        console.log('エラー', error)
        return false
      }
    },
  },
})

signIn()の引数に渡している変数の中身を確認してみます。

userのid: '109704921881887261253',と accoutのsub: '109704921881887261253'は同じです。どっちがいいのかは分かりませんが、今回はaccount.subで値を取得することにしました。

 // user
{
  id: '109704921881887261253',
  name: 'ネクスト太郎',
  email: 'next.taro.1234@gmail.com',
  image: 'https://lh3.googleusercontent.com/a/AGNmyxaK1E15pp3Qp1MrPG0lH6MBjZK3aVeEBnh3hZ9M=s96-c'
}


// account
{
  provider: 'google',
  type: 'oauth',
  providerAccountId: '109704921881887261253',
  access_token: 'ya29.a0Ael9sCOqoygeGWIZD4vl3xmYzd09PE29hogehogehogehoge',
  expires_at: 1680098462,
  scope: 'openid https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile',
  token_type: 'Bearer',
  id_token: 'eyJhbGciOiJSUzI1NiIsImtpZCI6IjFhYWU4ZDdjOTIwNThiNWVlYTQ1Njg5NWJmODkwODQ1NzFlMzA2ZjMiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI0Mzg1MzU4NTU3MTctZWVkdm00bmYxODU5ZDFnYjJ1czhpdDNydTRhaHRhMWkuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJhdWQiOiI0Mzg1MzU4NTU3MTctZWVkdm00bmYxODU5ZDFnYjJ1czhpdDNydTRhaHRhMWkuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMDk3MDQ5MjE4ODE4ODcyNjEyNTMiLCJlbWFpbCI6Im5leHQudGFyby4xMjM0QGdtYWlsLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJhdF9oYXNoIjoidTh1bF95UkdvWEtUTk81RlRVSndvdyIsIm5hbWUiOiLjg43jgq_jgrnjghogehogehogehoge'
}

profile
{
  iss: 'https://accounts.google.com',
  azp: '438535855717-eedvm4nf1859d1gb2us8it3ru4ahta1i.apps.googleusercontent.com',
  aud: '438535855717-eedvm4nf1859d1gb2us8it3ru4ahta1i.apps.googleusercontent.com',
  sub: '109704921881887261253',
  email: 'next.taro.1234@gmail.com',
  email_verified: true,
  at_hash: 'u8ul_yRGoXKTNO5FTUJwow',
  name: 'ネクスト太郎',
  picture: 'https://lh3.googleusercontent.com/a/AGNmyxaK1E15pp3Qp1MrPG0lH6MBjZK3aVeEBnh3hZ9M=s96-c',
  given_name: '太郎',
  family_name: 'ネクスト',
  locale: 'ja',
  iat: 1680094863,
  exp: 1680098463
}

ユーザー情報を削除するコンポーネントも用意しておく

ログイン機能とログアウト機能のコンポーネントはすでに前編で作成済みですが、削除するためのコンポーネントがないので用意します。

// frontend/src/components/DeleteUser.jsx

import React from 'react'
import { useSession, signOut } from 'next-auth/react'
import axios from 'axios'

const DeleteUser = () => {
  const { data: session } = useSession()

  const apiUrl = process.env.NEXT_PUBLIC_API_URL

  const handleDeleteUser = async () => {
    if (!session || !session.user) {
      console.error('セッションが存在しません')
      return
    }

    try {
      const response = await axios.delete(`${apiUrl}/users/${session.user.email}`)

      if (response.status === 204) {
        signOut()
      } else {
        console.error('アカウント削除に失敗しました')
      }
    } catch (error) {
      console.log('エラーだよ全員集合!', error)
    }
  }

  if (session) {
    return (
      <div>
        <button onClick={() => handleDeleteUser()}>アカウントを削除する</button>
      </div>
    )
  }
  return null
}

export default DeleteUser

indexページに、作成したコンポーネントを配置する

  • Login.jsx
  • Logout.jsx
  • DeleteUser.jsx
// frontend/src/pages/index.jsx

import React from 'react'
import Head from 'next/head'
import { useSession } from 'next-auth/react'
import Login from '@/components/Login'
import Logout from '@/components/logout'
import DeleteUser from '@/components/deleteUser'

export default function Home() {
  const { data: session, status } = useSession()

  return (
    <>
      <Head>
        <title>next-rails-google-auth</title>
      </Head>
      <div>
        <h1>next-rails-google-auth</h1>

        {status === 'authenticated' ? (
          <div>
            <p>セッションの期限:{session.expires}</p>
            <p>ようこそ、{session.user.name}さん</p>
            <img src={session.user.image} alt='' style={{ borderRadius: '50px' }} />
            <div>
              <Logout />
            </div>
            <div>
            <DeleteUser />
            </div>
          </div>
        ) : (
          <Login />
        )}
      </div>
    </>
  )
}

実際に動かしてみる

アプリを起動させます。

ログイン画面

Googleログイン認証画面

rails consoleコマンドでデータベースに保存されているか確認してみます。

irb(main):001:0> User.all
  User Load (0.5ms)  SELECT "users".* FROM "users"
=>
[#<User:0x000000010d540688
  id: 5,
  provider: "google",
  uid: "109704921881887261253",
  name: "ネクスト太郎",
  email: "next.taro.1234@gmail.com",
  created_at: Mon, 27 Mar 2023 13:52:50.830761000 UTC +00:00,
  updated_at: Mon, 27 Mar 2023 13:52:50.830761000 UTC +00:00>]

ユーザー情報の登録ができていることが確認できました!

ログイン後の画面

  • ログアウトボタン
    • ログイン画面に戻る。ユーザー情報は削除されない。
  • アカウントを削除するボタン
    • ログイン画面に戻る。ユーザー情報が削除される。

「アカウントを削除する」を実行した後、同じくrails consoleコマンドで確認してみます。

irb(main):002:0> User.all
  User Load (0.4ms)  SELECT "users".* FROM "users"
=> []

ユーザー情報が削除されたことも確認できました!

おわりに

当初はomniauth-google-oauth2を使ってGoogle認証を行う予定でしたが、Next.jsを使う場合、NextAuth.jsの選択肢があることを知り、途中から切り替えて実装しました。

バックエンドでのユーザー管理や認証のロジックを一元化したい場合はomniauth-google-oauth2がおすすめのようですが、特にその予定はなかったのと、Railsではdevise を使ったユーザー認証は経験していたので、今回はフロントエンドでの認証フローを経験してみたいと思いNextAuth.jsを選択しました。

Next.js × Railsアプリ開発をしNextAuth.jsの利用を検討している方の参考になれば幸いです。

最後までお読みいただきありがとうございました!

【前編】NextAuth.jsを使ってGoogleログイン機能を実装する(Next.js × Railsで実践するアプリ開発)

はじめに

Next.jsとRailsを使ってオリジナルのWebアプリを開発しています。

github.com

ログイン機能を備えたアプリにするために、Google認証でログインする方法を選択しました。この記事では、実装過程において調べた情報や試行錯誤しハマった経験などをまとめたいと思います。

前編、後編の2部構成でお届けします。

前編

Next.jsのNextAuth.jsを使用しGoogle認証ログイン、ログアウトに焦点を当てた内容です。

後編

ログインしたユーザーの情報をデータベースに保存する方法について焦点を当てた内容です。Rails APIを叩いてデータベースに保存する方法についてまとめています。

後編はこちら

技術スタック

フロントエンド:
  • Next.js '13.2.4'
  • 使用するnpm
    • next-auth '4.20.1'
    • axios '1.3.4'
バックエンド:
  • Ruby '3.2.1'
  • Rails '7.0.4.2' API mode
  • 使用するgem
    • rack-cors '2.0.0'
データベース: 
リポジトリ

github.com

下準備

Google APIにアクセスできるように準備する

詳細は以下のブログ記事にまとめています。

shirotamaki.hatenablog.com

認証ライブラリのNextAuth.jsを使ってGoogleログイン機能を実装する

Next.jsプロジェクトを作成する

~/blog_code_examples
❯ node -v
v18.14.2

~/blog_code_examples
// @latest最新版を指定プロジェクトを作成する
❯ npx create-next-app@latest

// プロジェクトネームを入力
✔ What is your project named? … frontend

// TypeScriptを導入するか? →Noを選択
✔ Would you like to use TypeScript with this project? … No / Yes

// ESLinstを導入するか? →Yesを選択
✔ Would you like to use ESLint with this project? … No / Yes

// src/ディレクトリを使用するか? →Yesを選択
✔ Would you like to use `src/` directory with this project? … No / Yes

// app/ディレクトリを使用するか? →Noを選択
? Would you like to use experimental `app/` directory with this project? › No / ✔ Would you like to use experimental `app/` directory with this project? … No / Yes

// エイリアスの設定 →デフォルトで@/を設定する
✔ What import alias would you like configured? … @/*

必要なnpmをインストールする

axiosについては後編で言及します。

❯ npm install next-auth axios

API routeの設定を行う

API routeは、Dynamic Routes、Dynamic Routing、動的ルーティングとも呼ばれるしくみのことです。

Routing: Dynamic Routes | Next.js

Dynamic Routingは[props], [...props], [[...props]]と3種類の記述方法があります。

path /foo /foo/bar /foo/bar/baz
/foo/[props].ts
/foo/[...props].ts
/foo/[ [...props] ].ts

pages/api/auth[...nextauth].tsというファイルを作成しDynamic Routingを設定しています。 callbaks:には、signInが実行された時に呼び出す指示を書きます。ここでRails APIを叩いて、アプリにユーザー情報が未登録の場合はユーザー情報を保存する機能を実装したいと思います。バックエンド側の実装については後編で説明します。

以下のプログラムで認証プロバイダーをGoogleに指定します。こうすることでGoogle認証を行うことができます。

// frontend/src/pages/api/auth/[...nextauth].ts

import NextAuth from 'next-auth'
import GoogleProvider from 'next-auth/providers/google'

export default NextAuth({
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    }),
  ],
})

clientIdclientSecretには、次のステップで環境変数を指定します。

.env.localファイルを生成しGoogle認証のために必要な環境変数を設定する

Google API Consoleで設定したクライアントIDとクライアントシークレットを環境変数に設定します。

  • your-google-client-id: クライアントID
  • your-google-client-secret: クライアントシークレット

ついでにNEXTAUTH_URLNEXT_PUBLIC_API_URLも設定しておきます。

NEXTAUTH_URLは、Next.js アプリケーションでNextAuth.jsを使って認証を実装する際に必要な環境変数を指します。この環境変数は、Next.jsアプリケーションのベースURLを指定することになります。Next.jsの開発環境のサーバーはポート番号を変更したのでhttp://localhost:4000 として設定しておきます。

ちなみにこのNEXTAUTH_URL環境変数名は変更不可です。私は環境変数名を変更したことが原因でだいぶハマりました...。

NEXT_PUBLIC_API_URLは、API側のベースURLになります。Railsの開発環境のサーバーのポート番号はhttp://localhost:3000として設定しておきます。

// frontend/.env.local

GOOGLE_CLIENT_ID=<your-google-client-id>
GOOGLE_CLIENT_SECRET=<your-google-client-secret>

NEXTAUTH_URL=http://localhost:4000
NEXT_PUBLIC_API_URL=http://localhost:3000

Session Providerの設定

Session Providerを設定すると、useSession() のインスタンスで、React Context(各レベルで手動でpropsを渡すことなく、ツリー構造全体でデータを受け渡すことができるしくみのこと) を使用して、コンポーネント間でセッションオブジェクトを共有することができます。タブやウィンドウ間でセッションの更新や同期を維持することもできます。

ここで設定することで、アプリ全体でNextAuth.jsのセッション情報を共有することができます。SessionProviderにsessionプロパティを渡すことで、各ページコンポーネントでuseSession()フックを使用してセッション情報にアクセスできるようになります。また、ComponentにpagePropsを渡すことで、現在のページコンポーネントに必要なプロパティが提供されます。

ややこしいですが...要は、Sessionの内容をアプリ全体で確認できるようにするには、SessionProviderの設定が必要なので、そのための設定については_app.tsxに書く必要がありますよ!ということです。

// frontend/src/pages/_app.tsx

import { SessionProvider } from 'next-auth/react'

export default function App({ Component, pageProps: { session, ...pageProps } }) {
  return (
    <SessionProvider session={session}>
      <Component {...pageProps} />
    </SessionProvider>
  )
}

ログイン機能を作成する

components/Login.jsxを作成し、ログイン機能を実装します。

ファイル名はパスカルケースケースにします。Next.jsの命名規則を参考にしました。

以下、コードの動きです。

  • useSession()を使用して、ログインしているかどうかを判定する
  • NextAuth.jsのsignIn()フックを使用して、Googleでログインを実行する
    • useSession()signIn()は、NextAuth.jsが提供しているフックになる
// frontend/src/components/Login.jsx

import React from 'react'
import { useSession, signIn } from 'next-auth/react'

const Login = () => {
  const { data: session, status } = useSession()

  if (status === 'loading') {
    return <div>Loading...</div>
  }

  if (status !== 'authenticated') {
    return (
      <div>
        <p>あなたはログインしていません</p>
        <button onClick={() => signIn('google', null, { prompt: 'login' })}>
          Googleでログイン
        </button>
      </div>
    )
  }
  return null
}

export default Login

毎回ログインする度にユーザーに再認証を要求する

signIn()フックに引数を指定することで、毎回ログインする度にユーザーに再認証を要求することができます。

signIn('google', null, { prompt: 'login' })
  • google

    • 認証プロバイダーとしてGoogleを指定。これにより、ユーザーはGoogleアカウントで認証できる。
  • null

    • 通常、この位置には認証成功後に呼び出されるコールバック関数が設定される。
    • nullが指定されているため、デフォルトのコールバック関数が使用される。
    • 今回はfrontend/src/pages/api/auth/[...nextauth].tsで設定しているsignIn()が呼び出される。※実装は後編で行う。
  • prompt: 'login'

    • オプションオブジェクトで、promptプロパティがloginに設定されている。この設定により、ユーザーがすでに認証済みであっても毎回認証が求められることになる。通常、認証済みのユーザーは再度ログインする必要はないが、このオプションにより毎回ログイン画面に遷移するように設定した。

ログアウト機能を作成する

  • NextAuth.jsのsignOut()フックを使用して、ログアウトする。
// frontend/src/components/Logout.jsx

import React from 'react'
import { useSession, signOut } from 'next-auth/react'

const Logout = () => {
  const { data: session, status } = useSession()

  if (status === 'authenticated') {
    return (
      <div>
        <button onClick={() => signOut()}>ログアウト</button>
      </div>
    )
  }
  return null
}

export default Logout

indexページを用意して、ログイン機能とログアウト機能を実装する

// frontend/src/pages/index.jsx

import React from 'react'
import Head from 'next/head'
import { useSession } from 'next-auth/react'
import Login from '@/components/Login'
import Logout from '@/components/logout'

export default function Home() {
  const { data: session, status } = useSession()

  return (
    <>
      <Head>
        <title>next-rails-google-auth</title>
      </Head>
      <div>
        <h1>next-rails-google-auth</h1>

        {status === 'authenticated' ? (
          <div>
            <p>セッションの期限:{session.expires}</p>
            <p>ようこそ、{session.user.name}さん</p>
            <img src={session.user.image} alt='' style={{ borderRadius: '50px' }} />
            <div>
              <Logout />
            </div>
          </div>
        ) : (
          <Login />
        )}
      </div>
    </>
  )
}

useSession()

useSession()の役割についてもまとめておきます。

NextAuth.jsが提供しているフックです。誰かがサインインしているかどうかを確認する最も簡単な方法になります。

  const { data: session, status } = useSession()

ここはではdataという決まった値に対してsessionという別名を付けています。名前は何でも構わないのですが慣例的にsessionとするようです。

datastatus の 2 つの値を含むオブジェクトを返します。

dataは、3つの値をとることができます。session undefined null

  • セッションがまだ取得されていない場合、データはundefinedになります。
  • セッションの取得に失敗した場合、データはnullになります。
  • セッションの取得に成功した場合、データはsessionとなる。

dataの型、言い換えると…

  • fetch前はundefined
  • fetchに失敗した場合はnull
  • fetchに成功した場合はsession

statusは、 3つの可能なセッション状態に対応する列挙型です。

  • loading ロード中
  • authenticated 認証された
  • unauthenticated 認証されていない

ここまでの結果

無事、Next.js(フロント側)のログイン、ログアウト機能の実装が完了しました。

ログイン画面

Googleログイン認証画面

ログアウト画面

一度認証が完了して許可されている場合(Googleアカウントがすでにアプリに登録されている場合)は、そのままログインが完了します。初めてアプリに対してアクセスするGoogleアカウントの場合は、Googleアカウントへのログイン画面やアカウントの選択画面が表示されるので、Googleアカウントのメールアドレス、パスワードから入力し、場合によっては2段階認証の必要があります。

一旦これで、フロント側だけの実装は完了です。データベースに保存できる実装にはなっていません。

次はRails APIを叩き、データベースにログインユーザーの情報を保存する方法についてまとめたいと思います。

後編へ続きます。

OAuth 2.0を使ってGoogle APIにアクセスする

はじめに

Next.jsとRailsを使ってオリジナルのWebアプリを開発しています。

github.com

今回ログイン機能を備えたアプリにするために、Google認証でログインする方法を選択しました。

この記事では、Google APIの設定手順に焦点を当てています。実際のログイン機能の実装については以下の記事で取り上げます。

OAuthってなに?

まずはここからだと思いますが、いろんなところで紹介されている有名なOAuthの説明記事ですがご紹介。私もこれが一番わかりやすいと思いました。

qiita.com

雑にまとめると、OAuth 2.0とは、アクセストークンを用いてパスワードやIDを入力することなく自作したアプリで GoogleTwitterFacebookなどのプロバイダーと連携させる事ができ、連携先のプロバイダーに保存されている情報にアクセスするためのしくみのことを指します。

ちなみに、OAuth1.0 との違いは、以下の課題を解決するために仕様策定され、2012年にRFCとして発行されたのがOAuth 2.0とのことです。

OAuth 1.0が抱えていた課題

  • 認証と署名の複雑さ
  • デスクトップ/モバイルアプリへの対応が不充分
  • プロバイダのサーバスケールが困難

OAuth2.0を使った認証で何ができるのか?

developers.google.com

OAuth2.0のしくみを利用することで、クライアントから特定のAPIにアクセスするための認証を行うことができます。今回はNext.jsとRailsで開発しているので、ここでいうクライアントはNext.jsのプロジェクトを指します。Next.jsでNextAuth.jsというパッケージを利用し、Google API Consoleで設定したOAuth 2.0認証情報を取得します。この認証情報を使って、Googleアカウントでログインする権限(認可)を得ることができ、特定のGoogleアカウントでWebアプリにログインし利用することが可能になります。

Google認証ログインは、Googleアカウントを使用してアプリに簡単かつ安全にログインできる機能です。この機能のおかげで、Googleアカウントを持っていれば誰でも簡単にWebアプリにログインして利用することができます。

これは、Googleが特定のWebアプリに対してIDとパスワードを教え「認証の手段」を丸ごと渡してしまうのでなく、GoogleアカウントでログインしWebアプリの操作を行える権限を与えること、即ち「認可」だけを渡しているのがポイントです。

通常、Webサービスを利用するには、個別にユーザーIDとパスワードを入力してユーザーを認証する必要がありますが、OAuth2.0のしくみを利用することで、IDやパスワードを入力することなくユーザーを認証することができ、認証したユーザーに対してWebアプリへログインし操作する権限を与えることができます。

「認証」と「認可」は似てますが意味が違います。当初OAuthの理解に苦しみましたが、このキーワードとなる言葉の違いを理解しだいぶスッキリしました。以下のサイトが参考になります。

dev.classmethod.jp

OAuth2.0のしくみを利用するために、まずはGoogle API ConsoleページでGoogle APIの設定を行う必要があります。

以下、手順についてまとめたいと思います。

Google APIの設定手順

1.Google Cloud Platformの設定ページへアクセスする

console.cloud.google.com

2.プロジェクトを作成しnext-rails-google-authと名前を付ける

今回は、Next.jsとRailsを使ってgoogle認証を行うアプリなので、next-rails-google-authとします。

3.メニューの「APIとサービス」>「OAuth同意画面」> User Typeは「外部」を選択して「作成」をクリックする

4.アプリ名、メールアドレスを入力し「保存して次へ」をクリックする

5.「スコープを追加または削除」で「Googleアカウントのメインのメールアドレスの参照」をチェックして「更新」したのち、「保存して次へ」をクリックする

6.「+ADD USERS」をクリックして自分のメールアドレスを追加したら、「保存して次へ」をクリックする

7.「APIとサービス」のメニューの「認証情報」>「認証情報を作成」>「OAuthクライアントID」を選択する

8.アプリケーションの種類、名前、承認済みのJavaScript生成元、承認済みのリダイレクトURIを入力し「作成」をクリックする。

承認済みのJavaScript生成元

フロントエンドのオリジンを入力します。 オリジンとは、URLのスキーム(プロトコル)、ホスト(ドメイン)、ポート番号の組み合わせで定義されたものです。JavaScript生成元であるNext.js側で使われるオリジンを指します。

承認済みのリダイレクトURI

ユーザーがGoogleから認証を受けた後にリダイレクトされるパスのことを指します。今回はNext.jsのnext-authというnpmを使って認証処理を行うので、以下の宛先にリダイレクトするように設定しています。

ドキュメントには、For development: http://localhost:3000/api/auth/callback/googleと書いてありますが、今回はポートを4000に変更しているため、http://localhost:4000/api/auth/callback/googleと指定しています。

// frontend/package.json

    "dev": "next dev -p 4000",

9.「OAuth クライアントを作成しました」とのポップアップにクライアントIDとクライアントシークレットが表示される。コピーして取っておく(ローカルのメモ帳などで管理)

10.「APIとサービス」のメニューの「ライブラリ」からGoogle+ APIを選択して「有効にする」をクリックする

これでGoogle APIの設定は完了です。