ADFS認証（SAML認証）について※Laravel寄り

[AEON Advent Calendar 2023](https://qiita.com/advent-calendar/2023/aeon) 17日目の記事です。
社内でWebアプリを開発する際、共通のIDでシステムにログインできるように、ADFS認証（SAML認証）を実装するのですが、
社内で共有されている実装例の言語が限定されていたり、PHP/Laravelで実装しようとした時に、インターネット上に古い情報しかなかったりしたので、
私のこれまでの経験をベースに、ADFS認証（SAML認証）についてわかったことと、PHP/Laravelでの実装例をご紹介できればと思います。

# ADFSとは
Active Directory Federation Servicesの略で、Active Directoryのリソースを使って認証する際に、
SSO（Single Sign-On）、つまり様々なシステムに対して、共通のIDでシステムにログインできるようにするためのActive Directoryの機能です。
ADFSでは、様々な認証方法を利用することができ、WS-Federation、SAML、OpenID Connectなどありますが、本記事では、主にSAMLについて説明します。

## SAMLとは
Security Assertion Markup Languageの略称で、ユーザー認証をするためのXMLベースの認証方法の規格です。

SAMLでは、サービスプロバイダ（SP）とアイデンティティプロバイダ（IdP）間のSAML Request・SAML Responseのやりとりによって、認証を実現しています。
ここでいう、IdPとは認証情報を保管するActive Directory（ADFS）で、SPとはIdPから認証情報を受けてログインなどをしたいシステムになります。

```mermaid
sequenceDiagram
  actor U as ユーザー
  participant S as SP<br/>（被認証システム）
  participant I as IdP <br/>（ADFS）
  U->>S: 認証が必要な機能にアクセス
  S->>U: IdPへのSAML Requestを含むURL発行し、<br/>IdPへリダイレクト
  U->>+I: 
  Note over I: SAML Request内に含まれる情報を読み取り、<br/>SPが認証情報を渡すに値するか判断
  I->>-U: SPへのSAML Responseを含むURLを発行し、<br/>SPへリダイレクト
  U->>+S: 
  Note over S: SAML Responseに含まれる情報を読み取り、<br/>内容に応じてユーザーに機能を提供
  S->>-U: IdPから受信したSAML Requestの情報に基づいて、<br/>機能を提供
```

特徴としては、IdPとSP間は直接通信せず、全てのやり取りはユーザーのクライアント上で行われることが挙げられます。

### IdP（Identity Provider）とは
IdPは、ユーザの認証情報を管理し、SPからの要求に応じてユーザの認証情報を返すものですが、
ユーザーがログインを実行する前にSPのMetadataとIdPのMetadataを交換しておいてお互いに「信頼関係」を結んでおく必要があります。

IdPのMetadataには下記情報が必要になります。
+ Entity ID：IdPで信頼関係を結んでいるSPの中で一意のID
+ SSO URL：SPからIdPにリダイレクトする際に、SAMLRequestを載せてリダイレクトする先のURL

また、IdPのMetadataにはSAML Response内にユーザの認証情報が格納されている「Attribute」の構造が記載されており、
SPの機能によってはIdPのMetadataを読み込むことでAttributeを自動的にマッピングして、
プログラム内で扱いやすい配列の形に変換してくれる機能があるものもあります。

### SP（Service Provider）とは
SPは、IdPに認証情報のリクエストを送り、
IdPから受け取った認証情報を使ってユーザーに機能を提供するシステムになります。
（SAML認証を実装したいシステム・被認証システム）

SPには下記機能が必要となります。
+ Metadataの生成
+ SAML Responseの解析
+ （任意）IdPのMetadata解析機能

また、SPのMetadataには、下記情報が必要になります。
+ Entity ID：IdPで信頼関係を結んでいるSPの中で一意のID
+ AssertionConsumerServiceURL：IdPからSPにリダイレクトする際に、SAMLResponseを載せてリダイレクトする先のURL
+ （任意）証明書のX509文字列：後述

SPで作成するMetadataは、IdPで「リクエストしてきたSPが認証情報を渡すに値するか判断」するための情報です。
しかし、IdPでは最悪Entity IDとAssertionConsumerServiceURLさえ合っていれば認証情報を返せてしまいます。
そのことから、IdPによって「Metadataに証明書情報が必須」としている場合があります。
ここで設定している証明書はサーバ証明書などを利用することも可能ですが、サーバ証明書の期限ごとにIdPと信頼関係を結び直さなければならなかったり、「SPが正しいSPであること」を証明できれば良いので、
長めに期限を設定した自己証明書などを設定するのが良いと思われます。

# ADFS認証（SAML）の実装
さて、仕様についてお話ししたところで実装を、となるのですが、
IdPのSAML Responseの解析・SPのMetadataの生成・IdP、SP間のリクエストのやり取りなど、
１から実装するのは骨が折れますので、世の中にあるパッケージを利用しよう、となります。

私が対応したプロジェクトでは下記のようなパッケージを候補として選定し、
最終的にSocielite（SAML2 Service Provider）を採用いたしました。
選定理由は下記の２点です。
+ Laravel公式パッケージであるため、サポート切れの危険性が少ない
+ 「プロバイダ」という仕組みにより、大きな変更を加えずにSAML以外にもAzure AD認証などの実装もできることから<br/>将来ADFS以外の認証方法が必要となったときに変更しやすい

|パッケージ|Shibboleth SP|[Socielite<br/>（SAML2 Service Provider）](https://socialiteproviders.com/Saml2/)|[aacotroneo/laravel-saml2](https://github.com/aacotroneo/laravel-saml2)|[24Slides/laravel-saml2](https://github.com/24Slides/laravel-saml2)|
|:-|:--:|:-:|:-:|:-:|
|言語|Java|PHP/Laravel|PHP/Laravel|PHP/Laravel|
|サポート|[3.4.1.4](https://shibboleth.atlassian.net/wiki/spaces/SP3/pages/2065335693/ReleaseNotes#3.4.1.4-(October-11%2C-2023))<br/>最終更新：2023/10/11|最終更新：2023/11/8|最終更新：2019/10/2|最終更新：2023/9/28|
|特徴||Laravelの公式パッケージ<br/>（の拡張プロバイダ）||SPの情報などをDB上に持つ|

※Laravel開発での移設プロジェクトでの選択肢ですが、移設前のプロジェクトがJavaのプロジェクトであったため、Shibboleth SPが選択肢にあります。

以下に各パッケージを用いたSAML認証の実装方法を示します。
なお、下記前提条件のもと説明します。
+ PHP、Composerをインストール済み
+ Laravelプロジェクトを作成済み
+ IdPはSAMLTest.idを利用

<details>
<summary>Socialiteの実装方法</summary>

## Socialiteの実装方法
### 1.Socialiteをインストール
```sh:ターミナル
composer require laravel/socialite
```
### 2.Socialite SAML2 Service Providerをインストール
```sh:ターミナル
composer require socialiteproviders/saml2
```
### 3.app.phpの設定
Socialiteで追加プロバイダを使えるようにするために、
config/app.phpに設定を追加します。
```php:config/app.php
・・・
    'providers' => ServiceProvider::defaultProviders()->merge([
        // ・・・
        \SocialiteProviders\Manager\ServiceProvider::class,
    ])->toArray(),
・・・
```
### 4.EventServiceProviderの設定
[2.Socialite SAML2 Service Providerをインストール](2socialite-saml2-service-provider)でインストールしたパッケージをSocialiteから呼び出せるようにするために、
app/providers/EventServiceProvider.phpに設定を追加します。
```php:app/providers/EventServiceProvider.php
・・・
protected $listen = [
    ・・・
    \SocialiteProviders\Manager\SocialiteWasCalled::class => [
        \SocialiteProviders\Saml2\Saml2ExtendSocialite::class.'@handle',
    ],
];
・・・
```
### 5.web.phpの設定①
web.phpに、SAMLRequestをIdPに送るときのトリガーとなるURLを設定します。
※Route::getメソッドの第一引数の値は任意の値で構いません。今回は、`/auth/redirect`とします。
```php:routes/web.php
・・・
Route::get('/auth/redirect', function () {
    return Socialite::driver('saml2')->redirect();
});
・・・
```
web.phpに、SPのMetadataを出力するURLを設定します。
※Route::getメソッドの第一引数の値は任意の値で構いません。今回は、`/auth/saml2/metadata`とします。
```php:routes/web.php
・・・
Route::get('/auth/saml2/metadata', function () {
    return Socialite::driver('saml2')->getServiceProviderMetadata();
});
・・・
```
### 6.Socialite SAML2 Service Providerの設定
IdP Metadataの情報と、SPのAssertionConsumerServiceURLの情報をconfig/services.phpに記載します。
今回、`metadata`は[SAMLtest.idのDownload Metadataに記載の情報](https://samltest.id/download/#SAMLtests_IdP)を指定します。
```php:config/services.php
・・・
    'saml2' => [
        'metadata' => 'https://idp.co/metadata/xml',
    ],
・・・
```

なお、ここで様々な設定をすることで、SPのMetatdataに記載する内容を変更し、`IdPでログアウトした時のリダイレクト先`などを指定することもできます。
その他に設定可能な内容は下記ページをご確認ください。
[https://socialiteproviders.com/Saml2/](https://socialiteproviders.com/Saml2/)
```php:config/services.php
・・・
    'saml2' => [
        'metadata' => 'https://samltest.id/saml/idp', // IdPのMetadata
        'sp_acs' => 'auth/callback', // IdPでログイン後、SAMLResponseを受け取るリダイレクト先
        'sp_sls' => 'auth/saml2/logout', // IdPでログアウト後のリダイレクト先
        'sp_certificate' => file_get_contents('path/to/sp_saml.crt'), // SPのMetadataに反映する証明書の情報
        'sp_private_key' => file_get_contents('path/to/sp_saml.pem'), // 証明書の秘密鍵
        'sp_tech_contact_surname' => 'Doe', // SPのMetadataに反映する担当者の名前
        'sp_tech_contact_givenname' => 'John',// SPのMetadataに反映する担当者の姓
        'sp_tech_contact_email' => 'john.doe@example.com',// SPのMetadataに反映する連絡先メールアドレス
        'sp_org_lang' => 'en',// SPのMetadataに反映する会社の言語
        'sp_org_name' => 'Example Corporation Ltd.',// SPのMetadataに反映する会社名
        'sp_org_display_name' => 'Example Corporation',// SPのMetadataに反映する会社の表示名
        'sp_org_url' => 'https://corp.example',// SPのMetadataに反映する会社のURL
    ],
・・・
```
### 6.SPのMetadataを取得する
[5.web.phpの設定①](#5webphpの設定)で設定したSPのMetadataを出力するURLにアクセスし、Metadataを取得します。
Metadataの中身は下記のようになっており、[6.Socialite SAML2 Service Providerの設定](#6socialite-saml2-service-providerの設定)で設定した内容が反映されています。
```xml:metdata
<?xml version="1.0"?>
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata"
    entityID="http://127.0.0.1:8000/auth/saml2" ID="_a40cc3c3cbeee2e3a914694cff0dc0abe9ed95db5f">
    <SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"
        WantAssertionsSigned="true">
        <NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:persistent</NameIDFormat>
        <AssertionConsumerService index="0" isDefault="true"
            Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
            Location="http://127.0.0.1:8000/auth/callback" />
    </SPSSODescriptor>
</EntityDescriptor>
```
### 7.Metadataを交換してIdPと信頼関係を結ぶ
[7.SPのMetadataを取得する](#7spのmetadataを取得する)で取得したSPのMetadataをIdPに登録して、信頼関係を結びます。
SAMLTest.idの場合は、[こちら](https://samltest.id/upload.php)からXML形式のファイルをアップロードすることで信頼関係を結べます。

### 8.web.phpの設定②
web.phpに、IdPからSAMLResponseを受け取るためのリダイレクト先を設定します。
第２引数に処理を記載することで、受け取ったSAMLResponseをシステムの中で利用することができます。
今回は、SAMLResponseを正常に受信できたことを確認するため、SAMLResponseのデータをダンプする処理を追加しています。
※Route::getメソッドの第一引数の値は任意の値で構いません。今回は、`/auth/callback`とします。
 `/auth/callback`を変更する場合、ここと、`config/services.php`の`sp_acs`の値を併せて設定してください。
※web.phpではなく、Controllerや、Middlewareに記述しても構いません。
```php:routes/web.php
・・・
Route::get('/auth/callback', function () {
    $user = Socialite::driver('saml2')->user();
    dd($user); //SAMLResponseのデータをダンプする処理
});
・・・
```
IdP側の設定によりSAMLResponseを受け取るためのリダイレクト先がPOSTリクエストを受け取れる形でないとならない場合、
Routeファサードのメソッドをpostにした上で`config/services.php`に設定を追加する必要があります。
※これを実施した後はmetadataの内容が変わるため信頼関係を結び直してください。
```php:routes/web.php
・・・
Route::post('/auth/callback', function () {
    $user = Socialite::driver('saml2')->user();
});
・・・
```
```php:config/services.php
・・・
    'saml2' => [
        //・・・
        'sp_default_binding_method' => \LightSaml\SamlConstants::BINDING_SAML2_HTTP_POST,
    ],
・・・
```
### 9.IdPへSAMLRequestを送り、IdPでログインする。
[5.web.phpの設定①](#5webphpの設定)で設定した、「SAMLRequestをIdPに送るときのトリガーとなるURL」にアクセスします。
すると、IdPのMetadataの内容に基づいてリダイレクトし、IdPのログイン画面が表示されます。

> https://samltest.id/idp/profile/SAML2/Redirect/SSO?SAMLRequest=fZJfT8IwFMXf%2BRRN37f%2BYWzYjBGUGElQCUMffDGl66Rx62bbEf32duASHgzp2%2B255%2FzubdP5d12BozRWNXoGSYjhPBuli84d9FZ%2BddI64BXazmBnNGu4VZZpXkvLnGD54nHNaIhZaxrXiKaCYLWcwfc44VjgKBpHyXSSTERZFje4JDGZEkFJEZM9jYUkUhIIXods7%2BPbre3kSlvHtfMlTMcBoQGJd2TMJpRF5A2CpYdSmrtT18G51jKELK8r5y9CVSBVtMgTlaqSqEekaCsLZaRwKM%2BfIdj80d4qXSj9cX20%2FVlk2cNutwkGIwgW1krTM9w12na1NLk0RyXky3Z9pvJQhCYh9oewKcYYcb9VJHhV7bn4hFnaM7PTxOa8ZNZXruPwIRZmw%2Bj%2FpfRGNEUXCVn65M1Wy01TKfED7htTc3c9q6%2BoIihPUtb2D2Wd1A6iLEWXXyQb%2FQI%3D&RelayState=si4cOPKGyXmdk6BwAULerWowvKfNSoMmqp7utsPO

ここで、URLに付加されたGetパラメータを確認すると、
「SAMLRequest」と、「Relaystate」という二つのパラメータがあることがわかります。
ここに記載されてある「SAMLRequest」はエンコードすることができ、
エンコードすると下記のようなXMLが取り出せます。
```xml:SAMLRequest
<?xml version="1.0"?>
<AuthnRequest xmlns="urn:oasis:names:tc:SAML:2.0:protocol" ID="_67a0c0443478575cffd90f16181c21d61b26ce1ee1" Version="2.0" IssueInstant="2023-12-16T13:52:41Z" Destination="https://samltest.id/idp/profile/SAML2/Redirect/SSO" ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" AssertionConsumerServiceURL="http://127.0.0.1:8000/auth/callback"><saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">https://127.0.0.1:8000/auth/saml2</saml:Issuer><NameIDPolicy Format="urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"/></AuthnRequest>
```
### 9.IdPへSAMLRequestを送り、IdPでログインする。
IdPでログイン後[8.web.phpの設定②](#8webphpの設定)で設定したURLにリダイレクトし、
第２引数に指定した処理が実行されれば設定は完了です。

※`419 Page Expired`となる場合
IdPから受け取ったリクエスト（SAMLResponse）で、CSRFの検証ができないためにエラーとなっています。
`app/Http/Middleware/VerifyCsrfToken.php`に[8.web.phpの設定②](#8webphpの設定)で設定したURLを指定することで解消します。
```php:app/Http/Middleware/VerifyCsrfToken.php
・・・
    protected $except = [
        //
        // 'auth/callback',
    ];
・・・
```

※`Laravel\Socialite\Two\InvalidStateException`となる場合
Socialiteではsessionに格納されているstateを見て、stateがNullとなっていた場合にこのエラーを出す仕様になっています。
[8.web.phpの設定②](#8webphpの設定)で設定したルートの中の処理を変更することで対処できます。
```php:routes/web.php
・・・
Route::get('/auth/callback', function () {
    $user = Socialite::driver('saml2')->stateless()->user();
});
・・・
```
</details>

<details>
<summary>開発に役立つツール</summary>

## テストIdP
テストの際に有用なサービス・ツールになります。
ADFSを検証用に使えない場合に仮のIdPとして利用できます。
他に有用なサービスなどありましたら、ご教示ください。
+ [SAMLtest.id](https://samltest.id)：SPの動作確認や、IdPの動作確認もできるオンラインサービス
+ [SimpleSAMLphp](https://simplesamlphp.org)：ダウンロードして、ローカルで動かせるIdP

## SAMLRequest・SAMLResponseのエンコード
GETパラメータを解析し、XMLを取り出すためのツールです。
+ [Google Admin Toolbox | Encode/Decode](https://toolbox.googleapps.com/apps/encode_decode/?lang=ja)
</details>

# 最後に
SAML認証は、必ずユーザの操作を介して認証を行う「対話型認証」のための認証方法になります。
「コンテナ」「API」などの活用が必要な今、OAuthや、OpenID Connectなどの「非対話型認証」が必要になってくるものと思われます。
必要性がありSAML認証などの認証方法を選ばなければならない場合でも、先を見据えたパッケージ選定・実装方法ができると良いと思います。

閲覧いただきありがとうございました。