🦌

Azure Easy Auth と Laravel のハイブリッド認証を実装する

に公開

Azure App Service の Easy Auth(組み込み認証)と Laravel のセッションベース認証を併用するシステムを実装したので、本記事では、設計のアプローチ、実装中に直面した課題、そしてその解決策について記述する。

はじめに

企業向け Web アプリケーションにおいて、認証基盤として Microsoft Entra ID を採用するケースは多い。Azure App Service を利用している場合、Easy Auth を有効にするだけでアプリケーションコードを変更せずに認証を実現できる。

しかし、既存のアプリケーションにEasy Authを導入する場合、以下のような要件が生じる。

  • 既存のセッションベース認証を代替手段として残したい。
  • ローカル開発環境など Easy Auth が利用できない環境でも動作させたい。

本記事では、これらの要件を満たしつつ、従来のセッション認証を維持しながら Easy Auth による認証を併用するための設計と実装について解説する。

Easy Auth の仕組み

認証フローの概要

Easy Auth は Azure App Service のプラットフォームレベルで動作する認証機能である。アプリケーションに到達する前にリクエストを検査し、未認証のユーザーを ID プロバイダーにリダイレクトする。

認証が完了すると、Easy Auth は以下の HTTP ヘッダーをリクエストに注入する。

ヘッダー名 説明
X-MS-CLIENT-PRINCIPAL-NAME ユーザーのメールアドレスまたは UPN
X-MS-CLIENT-PRINCIPAL-ID ユーザーの一意識別子(GUID)
X-MS-CLIENT-PRINCIPAL Base64 エンコードされたクレーム情報

重要なのは、これらのヘッダーは外部から偽装できないという点である。

"External requests aren't allowed to set these headers, so they're present only if App Service sets them."

— Work with user identities in Azure App Service authentication

Azure App Service はリクエストを処理する際、外部からの X-MS-* ヘッダーを削除する。そのため、これらのヘッダーが存在する場合は Easy Auth によって設定されたものと信頼できる。

JWT との関係

Easy Auth と聞くと JWT(JSON Web Token)を連想するかもしれないが、Laravel アプリケーションは JWT を直接扱う必要がない。JWT の検証は Azure App Service 側で完結し、アプリケーションには検証済みの結果が HTTP ヘッダーとして渡される。

これは設計上の大きな利点である。アプリケーション側で JWT ライブラリを導入したり、トークンの有効期限を管理したりする必要がない。

設計アプローチ

ミドルウェアによる橋渡し

Easy Auth の認証結果を Laravel のセッション認証に橋渡しするため、カスタムミドルウェアを実装した。

class EasyAuthMiddleware
{
    private const HEADER_NAME = 'X-MS-CLIENT-PRINCIPAL-NAME';

    public function handle(Request $request, Closure $next): Response
    {
        // 既にセッション認証済みならスキップ
        if (Auth::check()) {
            return $next($request);
        }

        // Easy Auth ヘッダーからメールアドレス取得
        $email = $request->header(self::HEADER_NAME);

        if ($email === null) {
            return $next($request);
        }

        // ユーザー検索と自動ログイン
        $user = User::where('email', $email)->first();

        if ($user !== null) {
            Auth::login($user);
        }

        return $next($request);
    }
}

このミドルウェアは Web ミドルウェアグループの先頭に登録する。

// bootstrap/app.php
$middleware->web(prepend: [
    EasyAuthMiddleware::class,
]);

なぜセッション認証を併用するのか

「Easy Auth で認証されているなら、毎回ヘッダーをチェックすれば良いのでは?」という疑問があるかもしれない。今回はセッション認証を併用する理由は以下の通りである。

  1. Easy Authが利用できない環境で従来のログインフォームを継続して利用する必要がある。
  2. 既存のLaravelの認証基盤をそのまま活用できる。

直面した課題と解決策

課題① ログアウト後の再認証ループ

現象

ログアウトしても、すぐに再度ログイン状態になってしまう。

原因

Laravel のセッションを破棄しても、Azure 側の認証状態は維持されている。次のリクエストで Easy Auth が再度ヘッダーを注入し、ミドルウェアが自動的にログインさせてしまう。

解決策

Easy Auth 環境でログアウトするには、/.auth/logout エンドポイントにアクセスする必要がある。このエンドポイントは Microsoft Entra ID のログアウト URL にリダイレクトする。
このエンドポイントにアクセスすると、Azure 側の認証 Cookie が削除され、ID プロバイダーからも適切にログアウトされる。

課題② ログアウト時の CORS エラー

現象

ログアウトボタンをクリックすると、ブラウザのコンソールに CORS エラーが表示される。

Access to XMLHttpRequest at 'https://login.windows.net/...'
from origin 'https://app.example.com' has been blocked by CORS policy

原因

問題は、Inertia.js がデフォルトで AJAX(XHR)リクエストを使用することにある。AJAX リクエストでリダイレクトをフォローすると、異なるドメインへのアクセスが CORS ポリシーに違反する。

POST /logout (AJAX)
    ↓
302 Redirect → /.auth/logout
    ↓
302 Redirect → login.windows.net/...
    ↓
CORS エラー

解決策

Inertia::location() を使用することで、ブラウザの通常のページナビゲーションとしてリダイレクトを実行できる。

public function destroy(Request $request): SymfonyResponse
{
    Auth::guard('web')->logout();
    $request->session()->invalidate();
    $request->session()->regenerateToken();

    if ($request->hasHeader('X-MS-CLIENT-PRINCIPAL-NAME')) {
        // 通常の redirect() ではなく Inertia::location() を使用
        return Inertia::location('/.auth/logout');
    }

    return redirect('/');
}

Inertia::location() は 409 Conflict レスポンスと X-Inertia-Location ヘッダーを返す。Inertia のクライアントサイドライブラリはこのレスポンスを検出し、window.location を使用してブラウザナビゲーションを実行する。これにより CORS の問題を回避できる。

課題③ axios を使用したログアウトが機能しない

現象

Inertia::location() を実装しても、依然として CORS エラーが発生する。

原因

フロントエンドのログアウト処理が axios を直接使用していた。

// 問題のあるコード
const logout = async () => {
  await axios.post(route('logout'), {...})
    .then(() => {
      router.get(route('login'));
    });
};

axios は Inertia のプロトコルを理解しない。409 レスポンスを受け取っても、X-Inertia-Location ヘッダーを解釈してブラウザナビゲーションに切り替えることができない。

解決策

Inertia の router を使用するように修正した。

const logout = () => {
  mutate(() => true, undefined, { revalidate: false });
  router.post(route('logout'));
};

この修正により、Inertia のクライアントサイドライブラリが 409 レスポンスを適切に処理し、ブラウザナビゲーションとして /.auth/logout にリダイレクトされるようになった。

テストの実装

Easy Auth のヘッダーはテスト環境でもシミュレートできる。

test('Easy Auth ヘッダーが存在する場合、該当ユーザーで自動認証される', function () {
    $request = Request::create('/');
    $request->headers->set('X-MS-CLIENT-PRINCIPAL-NAME', 'test@example.com');

    $middleware = new EasyAuthMiddleware();
    $middleware->handle($request, fn () => new Response());

    expect(Auth::check())->toBeTrue();
    expect(Auth::user()->email)->toBe('test@example.com');
});

Inertia のログアウトテストでは、X-Inertia ヘッダーを付与して Inertia リクエストをシミュレートする。

test('Easy Auth 環境でログアウトすると Azure のログアウトエンドポイントにリダイレクトされる', function () {
    $this->actingAs($user);

    $response = $this->withHeaders([
        'X-MS-CLIENT-PRINCIPAL-NAME' => 'test@example.com',
        'X-Inertia' => 'true',
    ])->post(route('logout'));

    // Inertia::location() は 409 を返す
    $response->assertStatus(409);
    $response->assertHeader('X-Inertia-Location', '/.auth/logout');
});

まとめ

Easy Auth と Laravel のセッション認証を併用するハイブリッド認証を次の方法で実装した。

  1. Easy Auth のヘッダーを検出し、Laravel のセッション認証に橋渡しする。
  2. 外部ドメインへのリダイレクトには Inertia::location() を使用し、CORS エラーを回避する。
  3. axios ではなく Inertia の router を使用することで、Inertia のプロトコルが正しく機能する。

個人的な所感として、Easy Auth は「認証を簡単にする」という触れ込みだが、既存のフレームワークと組み合わせる場合はそれなりの考慮が必要である。特に SPA と組み合わせる場合、AJAX リクエストと外部リダイレクトの相性問題に注意が必要である。

しかし、一度仕組みを理解してしまえば、非常に強力な認証基盤を少ないコードで実現できる。JWT の管理やトークンのリフレッシュといった煩雑な処理を Azure に任せられるのは大きなメリットである。

参考資料

GitHubで編集を提案

Discussion