Laravel PassportでOAuthのAuthorization Code Flow with PKCEを実装する

Published:

Updated:

Laravel Advent Calendar 2024 22日目の記事です。

LaravelでAPI認証する方法の一つに、Laravel Passportがあります。 Laravel Passportを利用することで、簡単にOAuthにそった認証が実装できます。

Laravel Passportを使ってAuthorization Code Flow with PKCEを使った認証方法の実装を解説します。

Laravel Passportのセットアップ

インストール

Artisanコマンドでインストールします。

php artisan install:api --passport

インストールの最後に「Would you like to use UUIDs for all client IDs?」と聞かれますので、クライアントIDをUUIDにする場合はyesにします。 次に、パスポートで利用する暗号キーを生成します。

php artisan passport:keys

また、管理用のテーブルがいくつか追加されるので、マイグレーションをします。

php artisan migrate

App\Models\Userモデルに、Laravel\Passport\HasApiTokensトレイトを追加します。

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Passport\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens, Notifiable;
}

最後に、config/auth.phpファイルのガードに、passportドライバーを使う設定をします。

'guards' => [
    'web' => [
        'driver' => 'session',
        'provider' => 'users',
    ],
    'api' => [
        'driver' => 'passport',
        'provider' => 'users',
    ],
],

クライアント管理

APIと連携するために、まずはAPIを利用するアプリケーション、クライアントの登録をします。 クライアントはArtisanコマンドで登録できます。

php artisan passport:client --public

KPCEを使うため、publicオプションを付けます。

いくつか聞かれるので回答します。

Which user ID should the client be assigned to?

クライアントを作成したユーザー(管理者)を指定します。空のままで大丈夫です。

What should we name the client?

クライアント名を設定します。

Where should we redirect the request after authorization?

認可されたあとの、コールバック先URLを指定します。

表示されたクライアントIDは、あとで必要になるのでメモしておきます。

エンドポイントの設定(オプション)

初期状態では、すべてのフローのエンドポイントが出ています。 セキュリティーを考慮し、Authorization Code Grant with KPCEで使うエンドポイントだけにします。

Passportによるエンドポイントの自動追加をやめるには、サービスプロバイダーにPassport::ignoreRoutes()を追加します。

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Laravel\Passport\Passport;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        Passport::ignoreRoutes();
    }

その後、routes/web.phpに次のルートを追加します。

Route::group([
    'as' => 'passport.',
    'prefix' => config('passport.path', 'oauth'),
    'namespace' => '\Laravel\Passport\Http\Controllers',
], function () {
    Route::post('/token', [
        'uses' => 'AccessTokenController@issueToken',
        'as' => 'token',
        'middleware' => 'throttle',
    ]);

    Route::get('/authorize', [
        'uses' => 'AuthorizationController@authorize',
        'as' => 'authorizations.authorize',
        'middleware' => 'web',
    ]);
});

Access TokenとRefresh Tokenを受け取るまで

ユーザーが認可を受けるまでのフローは次のとおりです。

sequenceDiagram
    box ユーザー
    participant クライアント
    end
    box 認可サーバー
    participant 認可エンドポイント
    participant トークンエンドポイント
    end
    box アプリケーションサーバー
    participant リソースエンドポイント
    end

    クライアント-->>クライアント: 1. State、Code Verifier、Code Challengeを生成

    クライアント->>認可エンドポイント: 2. 認可リクエスト<br>(State、Code Challenge)
    認可エンドポイント->>クライアント: 3. ログイン画面
    クライアント->>認可エンドポイント: 4. 認証
    認可エンドポイント->>クライアント: 5. 認可画面
    クライアント->>認可エンドポイント: 6. 認可
    認可エンドポイント->>クライアント: 7. Callback URLにリダイレクト<br>(State、Code)
    クライアント-->>クライアント: 8. Stateを検証

    クライアント->>トークンエンドポイント: 9. トークンリクエスト<br>(Code、Code Verifier)
    トークンエンドポイント-->>トークンエンドポイント: 10. Code、Code Verifierを検証
    トークンエンドポイント->>クライアント: 11. トークン発行<br>(Access Token、Refresh Token)

認可リクエストに必要なパラメーターを作成(ステップ1)

認可リクエストを送信するには、まずクライアント側でState、Code Verifier、Code Challengeを生成します。

State

CSRFを防ぐために利用するランダムな32文字の文字列です。Cookieやローカルストレージに保存しておきます。

Code Verifier

PKCEフローで利用するランダムな128文字の文字列です。Cookieやローカルストレージに保存しておきます。

Code Challenge

Code VerifierをSHA-256 でハッシュ化し、Base64URLエンコードしたものです。コールバック後にCode Verifierと一致するか検証します。

Javascriptでは、次のようになります。

function createRandomString(num) {  
    return [...Array(num)].map(() => Math.random().toString(36).charAt(2)).join('');  
}

async function generateCodeChallenge(codeVerifier) {
  const encoder = new TextEncoder();
  const data = encoder.encode(codeVerifier);
  const hash = await crypto.subtle.digest('SHA-256', data);
  return base64UrlEncode(new Uint8Array(hash));
}

function base64UrlEncode(arrayBuffer) {
    return btoa(String.fromCharCode(...arrayBuffer))
        .replace(/=/g, '')
        .replace(/\+/g, '-')
        .replace(/\//g, '_');
}

const state = createRandomString(32);
const codeVerifier = createRandomString(128);
const codeChallenge = await generateCodeChallenge(codeVerifier);

認可リクエストを送信(ステップ2)

認可エンドポイント/oauth/authorizeに、次のパラメーターを付けてGETリクエストします。

  • client_id
    • クライアント作成時に表示されたクライアントID
  • redirect_uri
    • クライアント作成時に指定したコールバックURL
  • response_type
    • code
  • scope
    • 認可の範囲を指定できます、空にするとすべてになります
  • state
    • 先ほど作成したstate
  • code_challenge
    • 先ほど作成した`codeChallenge
  • code_challenge_method
    • S256

Javascriptでは、次のようになります。

const queryString = transformQueryString({  
    client_id: '396f52d4-e508-468e-9e87-c7fc28b54307',
    redirect_uri: 'http://localhost/auth/callback',
    response_type: 'code',
    scope: '',
    state: state,
    code_challenge: challenge,
    code_challenge_method': 'S256'
});

const authUrl = `http://localhost/oauth/authorize?${queryString}`;

ログインと認可(ステップ3-7)

パラメーターに問題が無ければ、loginルートにリダイレクトされます。

ログイン自体は今までのやり方で問題ありませんが、認証成功後にreturn redirect()->intended();が処理されるようにします(ステップ3-4)。

ログインに成功後、ユーザーにはアプリケーションを認可するかどうかの画面が表示されます。 ユーザーが許可をするとStateとCodeが発行され、コールバック先にリダイレクトします(ステップ5-7)。

Stateを検証(ステップ8)

URLのクエリパラメーターにStateとCodeが入っていますので、それぞれ取り出します。

Javascriptでは、次のようになります。

const urlParams = new URLSearchParams(window.location.search);
const state = urlParams.get('state');
const code = urlParams.get('code');

CSRFの検証のため、ステップ1で作成したStateと受け取ったStateが一致するか検証します。

Access Token、Refresh Tokenを発行(ステップ9-11)

トークンエンドポイント/oauth/tokenに、次のパラメーターを付けてPOSTリクエストします(ステップ9)。

  • client_id
    • クライアント作成時に表示されたクライアントID
  • redirect_uri
    • クライアント作成時に指定したコールバックURL
  • grant_type
    • authorization_code
  • code_verifier
    • ステップ1で作成したCode Verifier
  • code
    • 先ほど受け取ったcode

送られたパラメーターに問題がなければ(ステップ10)、レスポンスBodyにaccess_tokenとrefresh_tokenがあるので取り出します(ステップ11)。

Javascriptでは、次のようになります。

try {
	const response = await axios.post('http://localhost/oauth/token', {
	    client_id: '396f52d4-e508-468e-9e87-c7fc28b54307',
	    redirect_uri: 'http://localhost/auth/callback', 
	    grant_type: 'authorization_code',
	    code_verifier: verifier,
	    code: code
	});
	
	const accessToken = response.access_token
	const refreshToken = response.refresh_token
  } catch (error) {
    throw error;
  }

Access Tokenを使ってAPIを利用

Access Tokenを使って、APIを利用するフローは次のとおりです。

sequenceDiagram
    box ユーザー
    participant クライアント
    end
    box 認可サーバー
    participant 認可エンドポイント
    participant トークンエンドポイント
    end
    box アプリケーションサーバー
    participant リソースエンドポイント
    end

    クライアント->>リソースエンドポイント: 1. リソースをリクエスト<br>(Access Token)
    リソースエンドポイント->>リソースエンドポイント: 2. Access Tokenを検証
    リソースエンドポイント->>クライアント: 3. リソースを返却

Webと同じように、保護するルートにAuthミドルウェアを付けます。

Route::get('/user', function () {
    // ...
})->middleware('auth:api');

APIを利用するときは、AuthorizationヘッダーにBearerトークンを付けてリクエストします。

await axios.get('http://localhost/user', {
	headers: { 'Authorization': 'Bearer ' + accessToken }
})

Access Tokenを再発行

Access Tokenを、リフレッシュするフローは次のとおりです。

sequenceDiagram
    box ユーザー
    participant クライアント
    end
    box 認可サーバー
    participant 認可エンドポイント
    participant トークンエンドポイント
    end
    box アプリケーションサーバー
    participant リソースエンドポイント
    end

    クライアント->>トークンエンドポイント: 1. トークンリクエスト<br>(Code Verifier、Refresh Token)
    トークンエンドポイント-->>トークンエンドポイント: 2. Code Verifier、Refresh Tokenを検証
    トークンエンドポイント->>クライアント: 3. トークン発行<br>(Access Token、Refresh Token)

トークンエンドポイント/oauth/tokenに、次のパラメーターを付けてPOSTリクエストします(ステップ1)。

  • client_id
    • クライアント作成時に表示されたクライアントID
  • grant_type
    • トークンリクエストはrefresh_token
  • refresh_token
    • トークン発行時に受け取ったRefresh Token
  • code_verifier
    • 認可リクエスト時に生成したCode Verifier

送られたパラメーターに問題がなければ(ステップ2)、レスポンスBodyに新しいaccess_tokenとrefresh_tokenがあるので取り出します(ステップ3)。

Javascriptでは、次のようになります。

try {
	const response = await axios.post('http://localhost/oauth/token', {
	    client_id: '396f52d4-e508-468e-9e87-c7fc28b54307',
	    grant_type: 'authorization_code',
        refresh_token: refreshToken,
        code_verifier: codeVerifier,
	});
	
	const accessToken = response.access_token
	const refreshToken = response.refresh_token
  } catch (error) {
    throw error;
  }