Next.js + TypeScript + Laravel Passportで実装するOAuth2認証
今回は認証サーバとしてLaravelを使った場合を想定したOAuth2の認可コードフローに基づいた認証機能の実装をしていきます。
Next.jsでは認証を楽に作れるNextAuthというライブラリがあるので、今回はそれを使っていきますが、NextAuthはパスワードなしの認証(Passwordless Authentication)を推奨しているということもあり、OAuth2の認可コードフロー実装に関する情報が少ないように思えたので備忘録も兼ねて記事として残してみます。
今回Laravel側で認証基盤を実装するためのものとしてLaravel Passportを使います。
OAuthで定義されている基本的な認証方法の実装に対応していて、なおかつマルチクライアント対応もしやすいので、個人的に開発でよく使います。
以下の記事で詳しくまとまっているので興味のある方はこちらもぜひ。
Laravel側の実装
まずはLaravel側でクライアントの発行を行います。Laravel側で以下のコマンドを使ってクライアントの生成を行います。
$ php artisan passport:client --personal
このコマンドで生成されたclient_idとクライアント側(今回はNext.js)のエンドポイントを環境変数に設定します。
こちらは実装の仕方次第では必須ではないのですが、今回の実装では環境変数に入れておく必要があるので追加します。理由は後ほど説明します。
NEXT_APP_URL=http://localhost:3004
NEXT_APP_CLIENT= # 生成されたclientのidが100の場合
config経由で扱えるように追記
'next_app_url' => env('NEXT_APP_URL'),
'next_app_client_id' => env('NEXT_APP_CLIENT_ID'),
];
続いてLaravel Passportのスキャフォールドですが、こちらは公式のドキュメントやその他記事でも多く記載があるのでここでは割愛します。
続いて認証のMiddleware側で、特定のクライアントからリクエストが来た特の挙動の設定をします。
今回はNext.js側のドメインがhttp://localhost:3000 , Laravelのドメインがhttp://localhost:8000 とすると、Next.jsからのログインのリクエストがあったときに返すログイン画面のURLを http://localhost:8000/login?client_id=nextapp というようにしてクライアントごとのログインの挙動を変えたいので、Laravel側でクライアントごとのログインの挙動を区別できるように実装する必要があります。
<?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)にリダイレクトさせるのでその処理を追加で書いていく。
<?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].ts
をpages/api/auth
に追加する。デフォルトで用意されているpages/api
の更に直下に/auth
を作る必要があるので注意。
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;
},
},
});
環境変数も追加する
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
を以下のように実装します。
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側で各コンポーネントの表示を、認証しているときのみに制御したいというケースが多くあると思うので、それを制御するための独自フックを用意します。
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で条件分岐をしています。
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などで切り分ける場合も多いと思うので、段階的な移行にも対応しやすいと思います!
Discussion