Next.js + TypeScript + Laravel Passportで実装するOAuth2認証

2021/07/21に公開

今回は認証サーバとしてLaravelを使った場合を想定したOAuth2の認可コードフローに基づいた認証機能の実装をしていきます。

Next.jsでは認証を楽に作れるNextAuthというライブラリがあるので、今回はそれを使っていきますが、NextAuthはパスワードなしの認証(Passwordless Authentication)を推奨しているということもあり、OAuth2の認可コードフロー実装に関する情報が少ないように思えたので備忘録も兼ねて記事として残してみます。

今回Laravel側で認証基盤を実装するためのものとしてLaravel Passportを使います。

OAuthで定義されている基本的な認証方法の実装に対応していて、なおかつマルチクライアント対応もしやすいので、個人的に開発でよく使います。
以下の記事で詳しくまとまっているので興味のある方はこちらもぜひ。
https://qiita.com/zaburo/items/65de44194a2e67b59061

Laravel側の実装

まずはLaravel側でクライアントの発行を行います。Laravel側で以下のコマンドを使ってクライアントの生成を行います。

$ php artisan passport:client --personal

このコマンドで生成されたclient_idとクライアント側(今回はNext.js)のエンドポイントを環境変数に設定します。
こちらは実装の仕方次第では必須ではないのですが、今回の実装では環境変数に入れておく必要があるので追加します。理由は後ほど説明します。

.env
NEXT_APP_URL=http://localhost:3004
NEXT_APP_CLIENT= # 生成されたclientのidが100の場合

config経由で扱えるように追記

config/app.php
  'next_app_url' => env('NEXT_APP_URL'),
  'next_app_client_id' => env('NEXT_APP_CLIENT_ID'),
 ];

続いてLaravel Passportのスキャフォールドですが、こちらは公式のドキュメントやその他記事でも多く記載があるのでここでは割愛します。

https://laravel.com/docs/8.x/passport

続いて認証のMiddleware側で、特定のクライアントからリクエストが来た特の挙動の設定をします。
今回はNext.js側のドメインがhttp://localhost:3000 , Laravelのドメインがhttp://localhost:8000 とすると、Next.jsからのログインのリクエストがあったときに返すログイン画面のURLを http://localhost:8000/login?client_id=nextapp というようにしてクライアントごとのログインの挙動を変えたいので、Laravel側でクライアントごとのログインの挙動を区別できるように実装する必要があります。

app/Http/Middleware/Authenticate.php
<?php

namespace App\Http\Middleware;

use Illuminate\Auth\Middleware\Authenticate as Middleware;
use Illuminate\Support\Arr;

class Authenticate extends Middleware
{
    /**
     * Get the path the user should be redirected to when they are not authenticated.
     *
     * @param \Illuminate\Http\Request $request
     * @return string
     */
    protected function redirectTo($request)
    {
        if (!$request->expectsJson()) {
            if ($request->path() == "oauth/authorize" && $request->query('client_id') && $request->query('client_id') == \Config::get('app.next_app_client_id')) {
                return route('login', ['client_id' => 'nextapp']);
            }
            return route('login');
        }
    }
}

これでclient_idが環境変数に先程設定したNEXT_APP_CLIENT_IDと一致していれば、ログイン画面のURLのパラメータに?client_id=nextappを設定することが出来ます。

続いてログイン後の挙動を実装していきます。今回はNext.jsからのリクエストでのログインが完了したあとはLaravel側(http://localhost:8000) ではなくNext.js側(http://localhost:3000)にリダイレクトさせるのでその処理を追加で書いていく。

app/Http/Controllers/Auth/LoginController.php
<?php

namespace App\Http\Controllers\Auth;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Arr;

class LoginController extends Controller
{

    use AuthenticatesUsers;

    /**
     * Where to redirect users after login.
     *
     * @var string
     */
    protected $redirectTo = '/';

    public function showLoginForm(Request $request)
    {
        // Add request_uri as data.
        // So, when user submit username and password for login then value of request uri will redirect user to login method
        return view('auth.login', array('request_uri' => \Request::getRequestUri()));
    }

    protected function authenticated(Request $request, $user)
    {

        $client_id = $request->get('client_id');
        if ($client_id == 'nextapp') {
            return redirect(\Config::get('app.next_app_url'));
        }

        return redirect('/');
    }

    /**
     * The user has logged out of the application.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return mixed
     */
    protected function loggedOut(Request $request)
    {
        $client_id = $request->get('client_id');
        $params = [];
        if ($client_id) {
            $params['client_id'] = $client_id;
        }
        return redirect(route('login', $params, null));
    }

    public function showLogoutForm()
    {
        return view('auth.logout');
    }
}

こうすることで通常のLaravelでのログインは認証後にhttp://localhost:8000 へリダイレクト、Next.jsからのログインであれば認証後にhttp://localhost:3000 へリダイレクトさせることができる。
これでLaravelの実装は終わり。

Next.js側の実装

続いてNext.jsの実装を行っていきます。今回は冒頭にも触れたとおりNextAuthを使って実装をしていくのでまず最初に必要なパッケージのインストールを行います。

$ yarn add next-auth

続いて認証の処理を行う[...nextauth].tspages/api/authに追加する。デフォルトで用意されているpages/apiの更に直下に/authを作る必要があるので注意。

pages/api/auth/[...nextauth].ts
import NextAuth, { Session, TokenSet } from 'next-auth';
import { decode } from 'jsonwebtoken';

type UserInfo = {
  name: string;
  email: string;
};

type Tokens = {
  accessToken: string;
  accessTokenExpires: number | null;
  refreshToken: string;
  idToken: string | undefined;
  token_type: string;
  expires_in: number;
  access_token: string;
  refresh_token: string;
};

export default NextAuth({
  session: {
    jwt: true,
    maxAge: 30 * 24 * 60 * 60, // 30 days
    updateAge: 24 * 60 * 60, // 24 hours
  },
  jwt: {
    secret: process.env.NEXTAUTH_JWT_SECRET,
  },
  providers: [
    {
      id: 'laravelpassport',
      name: 'Laravel Passport',
      type: 'oauth',
      version: '2.0',
      domain: '',
      scope: '*',
      params: { grant_type: 'authorization_code' },
      accessTokenUrl: process.env.NEXT_PUBLIC_LARAVEL_APP_URL + '/oauth/token',
      requestTokenUrl: process.env.NEXT_PUBLIC_LARAVEL_APP_URL + '/oauth/token',
      authorizationUrl: process.env.NEXT_PUBLIC_LARAVEL_APP_URL + '/oauth/authorize?response_type=code',
      profileUrl: process.env.NEXT_PUBLIC_LARAVEL_APP_URL + '/api/v1/userinfo',
      profile: (profile: UserInfo, tokens: Tokens) => {
        const jwt = tokens.accessToken;
        const sub = decode(jwt).sub;
        return {
          id: sub,
          email: profile.email,
          name: profile.name,
        };
      },
      clientId: process.env.LARAVEL_PASSPORT_CLIENT_ID ?? '',
      clientSecret: process.env.LARAVEL_PASSPORT_CLIENT_SECRET ?? '',
    },
  ],
  callbacks: {
    async jwt(token, user, account, profile, isNewUser) {
      // Add access_token to the token right after signin
      if (account?.accessToken) {
        token.accessToken = account.accessToken;
      }
      return token;
    },
    session(session: any, token) {
      // Set accessToken to session.
      session.accessToken = token.accessToken;
      session.user.id = token.sub;
      return session as any;
    },

    /**
     * @param  {string} url      URL provided as callback URL by the client
     * @param  {string} baseUrl  Default base URL of site (can be used as fallback)
     * @return {string}          URL the client will be redirect to
     */
    async redirect(url: string, baseUrl: string): Promise<string> {
      return url ?? baseUrl;
    },
  },
});

環境変数も追加する

.env
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_JWT_SECRET='xxxxxxxxxxxxxxxxxxxxxxxxxxxx'

NEXT_PUBLIC_LARAVEL_APP_URL='http://localhost:8000'
LARAVEL_PASSPORT_CLIENT_ID='100'
LARAVEL_PASSPORT_CLIENT_SECRET='zzzzzzzzzzzzzzzzzzzzzzzzzzzzzz'

ここで先程Laravel側で発行したclientのidとsecretをそれぞれ渡しているので、Laravel側のMiddlewareでクライアントの区別を行うことが出来ています。

続いて検証のために、認証された状態とまだ認証していない状態でトップページの挙動を変えるために/pages/index.tsxを以下のように実装します。

/pages/index.tsx

import { signIn, signOut, useSession } from 'next-auth/client';

export default function Home() {
  const [session, loading] = useSession();

  return (
    <>
      {!session && (
        <>
          {loading ? (
            <>Loading ...</>
          ) : (
            <>
              Not signed in <br />
              <button onClick={() => signIn()}>Sign in</button>{' '}
            </>
          )}
        </>
      )}
      {session && (
        <>
          Signed in as {session.user?.name} <br />
          AccessToken : {session.accessToken} <br />
          <button onClick={() => signOut()}>Sign out</button>  {' '}
        </>
      )}
    </>
  );
}

これで一通りの実装は完了です。これだけでも認証できるようにはなりましたが、Next.js側で各コンポーネントの表示を、認証しているときのみに制御したいというケースが多くあると思うので、それを制御するための独自フックを用意します。

/hooks/useAuthenticated.tsx
import { useEffect } from 'react';
import { useSession, signIn } from 'next-auth/client';

export const useAuthenticated = () => {
  const [session, loading] = useSession();

  useEffect(() => {
    if (loading) return;

    if (!session) signIn('laravelpassport', { callbackUrl: location.href });
  }, [session, loading]);
};

これをログインしているかどうかを差し込みたいコンポーネントに以下のように記述することでログインしてなければLaravel側のログイン画面にリダイレクトするという挙動をさせることができます。未認証時にリダイレクトさせる前にDOMを返して一瞬画面が一部見える場合があるので、sessionで条件分岐をしています。

/pages/sample.tsx
import React from 'react';
import { useAuthenticated } from '../hooks/useAuthenticated';
import { useSession } from 'next-auth/client';

const Sample: React.FC = () => {
  useAuthenticated();
  const [session, loading] = useSession();

  return (
    <>
    {session && loading === false ? (
      <div>You are already authenticated.</div>
    ) : (<></>)}
    </>
  )
}
 
export default Sample;

以上になります。追加で別クライアントを作成する際にもこの実装で同じように対応することができるのでマルチクライアント対応もしやすいです。

また、プロダクトの立ち上げ当初はLaravelモノリスで、ある程度グロースしたタイミングでフロントエンドとバックエンドをNext.js, Nuxt.jsなどで切り分ける場合も多いと思うので、段階的な移行にも対応しやすいと思います!

AppBrew

Discussion