OpenID Connectの実装について

## はじめに
この記事ではOpenID Connect認証の流れと、PHPを用いたアクセストークンおよびIDトークンの取得、公開鍵の検証方法について説明します。
OpenID Connectは、OAuth 2.0をベースにした認証プロトコルであり、
ユーザーが認証情報を直接提供することなく、第三者アプリケーションに安全にアクセス権を委任できる仕組みです。

---

## OpenID Connect基本的な処理の流れ
※Service Aは開発中のサイト
※Identity Providerは認証を行うサービス（例：Google、Facebookなど）

```mermaid
sequenceDiagram
    participant User
    participant Service A
    participant Identity Provider
    
    User->>Service A: ログインボタンをクリック
    Service A->>Identity Provider: 認証リクエストを送信
    Identity Provider->>User: ログインページを表示
    User->>Identity Provider: 認証情報を入力してログイン
    Identity Provider->>User: 認証成功レスポンス（認可コード）
    User->>Service A: 認可コードを送信
    Service A->>Identity Provider: 認可コードを使用してアクセス トークンをリクエスト
    Identity Provider->>Service A: アクセス トークンとID トークンを返す
    Service A->>User: ログイン成功、サービス利用開始
```

### 認証と認可の違いについて
認証とは、ユーザーが誰であるかを確認するプロセスです。
例えば、ユーザーがユーザー名とパスワードを入力して自分自身を証明する行為が認証に当たります。
認証が成功すると、ユーザーはシステムに対して「私はこの人です」と証明されたことになります。

一方、認可はそのユーザーがどのリソースにアクセスできるかを決定するプロセスです。
認証が完了した後に、ユーザーが何をできるか
（例えば、特定のデータを読み取る、書き込む、削除するなど）を管理するのが認可です。

例えば、OpenID Connectを使用すると、ユーザーは自分のGoogleアカウントを使って他のアプリケーションにログインできますが、そのアプリケーションにはユーザーのメールアドレスやプロフィール情報へのアクセス権限しか与えられない場合があります。これは、ユーザーがGoogleで認証され、その後アプリケーションが特定のリソースに対するアクセスを認可されている例です。

---

### ログインリクエスト
開発中のサイトでログインボタンをクリックします。
サイトはユーザーをIdentity Providerにリダイレクトします。
ユーザーは認証情報を入力してログインします（シーケンス図の「User->>Identity Provider: 認証情報を入力してログイン」の部分）。
認証が成功すると、Identity Providerは認可コードを返します。
```
    {
        "code": "認可コード",
        // その他
    }
```

---

### アクセストークンの取得
ユーザーが認可コードを取得した後、開発中のサイト（Service A）はこの認可コードを使ってトークンエンドポイントにリクエストを送ります。
このリクエストには、認可コードと共にクライアントIDやクライアントシークレットも含まれます。

#### リクエスト例
以下のような形でトークンエンドポイントにリクエストを送信します。
- トークンエンドポイントにリクエスト(codeを添える)
    - ```grant_type=authorization_code&code=認可コード&redirect_uri=https://client.example.com/cb&client_id=クライアントID&client_secret=クライアントシークレット```
    - レスポンスの解析

```
{
    {
    "access_token": "eyxxxxx.eyxxxxx.xxxxx",      // アクセストークン（JWT形式）
    "refresh_token": "リフレッシュトークン",      　　　　　　    // リフレッシュトークン
    "scope": "openid profile email",              // トークンのスコープ（OpenID Connectを含む）
    "id_token": "eyxxxxx.xxxxx.yyyyy",            // IDトークン（JWT形式）
    "token_type": "Bearer",                       // トークンタイプ（通常は "Bearer"）
    "expires_in": 3600                            // アクセストークンの有効期限（秒単位）
　　　　　　　　}
}
```

このレスポンスには、アクセス トークンをデコードするとユーザー情報が含まれていることが確認できます。例えば、以下のような情報が含まれています。

```
{
    "alg": "RS256",
    "typ": "JWT"
}
.
{
    "sub": "1234567890",
    "name": "John Doe",
    "email": "john.doe@example.com",
    "iat": 1516239022,
    "exp": 1516242622
}
.
<SIGNATURE>
```

またOpenID Connectを使用している証拠としてid_tokenが含まれています。id_tokenは、ユーザーの認証情報を含むJWT形式のトークンであり、ユーザーのIDやプロフィール情報を確認するために使用されます。
OAuthだとid_tokenがレスポンスには含まれてないということです。



---

## アクセストークンの検証
1. **署名キーの取得**
    - jwks_uriから署名キーを取得
    - URL（例：マイクロソフトサンプル）
https://fabrikamb2c.b2clogin.com/fabrikamb2c.onmicrosoft.com/B2C_1_susi_reset_v2//v2.0/.well-known/openid-configuration

    ```json
    {
        "kid": "X5eXk4xyojNFum1kl2Ytv8dlNP4-c57dO6QGTVBwaNk",
        "nbf": 1493763266,
        "use": "sig",
        "e": "AQAB",
        "n": "tVKUtcx_n9rt5afY_2WFNvU6PlFMggCatsZ3l4RjKxH0jgdLq6CScb0P3ZGXYbPzXvmmLiWZizpb-h0qup5jznOvOr-Dhw9908584BSgC83YacjWNqEK3urxhyE2jWjwRm2N95WGgb5mzE5XmZIvkvyXnn7X8dvgFPF5QwIngGsDG8LyHuJWlaDhr_EPLMW4wHvH0zZCuRMARIJmmqiMy3VD4ftq4nS5s8vJL0pVSrkuNojtokp84AtkADCDU_BUhrc2sIgfnvZ03koCQRoZmWiHu86SuJZYkDFstVTVSR0hiXudFlfQ2rOhPlpObmku68lXw-7V-P7jwrQRFfQVXw"
    }
    ```

2. **公開鍵の生成**
    - JWKから公開鍵を生成する方法
    - 公開鍵の使用例と説明

    ```php
    /**
      * JWK (JSON Web Key) から RSA 公開鍵を生成するメソッド
      * 
      * このメソッドは、与えられたJWKから公開鍵を生成します。JWKにはモジュラス ('n') と公開指数 ('e') が含まれており、
      * それらを用いてRSA形式の公開鍵を生成します。
      * 
      * @param array $jwk_key JWK配列。公開鍵の 'n' と 'e' の値を含む。
      * @return \phpseclib3\Crypt\RSA\PublicKey 生成されたRSA公開鍵オブジェクト
      */
    public function createPublicKey($jwk_key)
    {
        $n = new BigInteger($this->urlSafeB64Decode($jwk_key['n']), 256);
        $e = new BigInteger($this->urlSafeB64Decode($jwk_key['e']), 256);
        
        $publicKey = PublicKeyLoader::load(['n' => $n, 'e' => $e]);
        return $publicKey;
    }


3. **署名の検証**
    - 署名の検証方法とサンプルコード
    - エラーハンドリングとデバッグ方法

    ```php

    /**
     * URLセーフなBase64デコードを行うメソッド
     * 
     * このメソッドは、URLセーフなBase64エンコード形式の文字列をデコードします。
     * Base64エンコード形式は、通常のBase64とは異なり、URLで使用できる形式に変換されています。
     * 
     * @param string $input URLセーフなBase64エンコード文字列
     * @return string デコードされたバイト文字列
     */
    public function urlSafeB64Decode($input)
    {
        $remainder = strlen($input) % 4;
        if ($remainder) {
            $padlen = 4 - $remainder;
            $input .= str_repeat('=', $padlen);
        }
        return base64_decode(strtr($input, '-_', '+/'));
    }
    
    /**
     * JWTの署名を検証するメソッド
     * 
     * このメソッドは、JWT (JSON Web Token) のヘッダーとペイロードを元に、与えられた署名が正当かどうかを検証します。
     * 署名の検証には、JWKから生成されたRSA公開鍵を使用します。正当な署名であればtrueを返し、無効またはエラーが発生した場合はfalseを返します。
     * 
     * @param string $header_encoded JWTのヘッダー部分（Base64URLエンコード済み）
     * @param string $payload_encoded JWTのペイロード部分（Base64URLエンコード済み）
     * @param string $signature_encoded JWTの署名部分（Base64URLエンコード済み）
     * @param array $jwk_key JWK配列。公開鍵の 'n' と 'e' の値を含む。
     * @return bool 署名が正当であればtrue、無効またはエラーが発生した場合はfalseを返却
     */
    public function verifySignature($header_encoded, $payload_encoded, $signature_encoded, $jwk_key)
    {
        $publicKey = $this->createPublicKey($jwk_key);
        $signature = $this->urlSafeB64Decode($signature_encoded);
        $data = $header_encoded . '.' . $payload_encoded;
        
        // JWTの署名を検証
        $result = openssl_verify($data, $signature, $publicKey, OPENSSL_ALGO_SHA256);
        
        if ($result === 1) {
            return true; // 署名が正当
        } elseif ($result === 0) {
            Log::error("Signature verification failed."); // 署名が無効
            return false;
        } else {
            while ($msg = openssl_error_string()) {
                Log::error("OpenSSL Error: " . $msg); // OpenSSLのエラー
            }
            return false; // エラーが発生
        }
    }

## 公開鍵エラーの調査内容
実装中に発生したエラー内容が以下になります。

```
OpenSSL Error: error:0909006C:PEM routines:get_name:no start line  
OpenSSL Error: error:04091077:rsa routines:int_rsa_verify:wrong signature length
```

openssl-verify関数について
https://www.php.net/manual/ja/function.openssl-verify.php

## id_tokenの精査
https://jwt.io/


### 公開鍵の精査
改行追加
https://qiita.com/ryou6152/items/9639db3e6836fd96239a
文字列にする
https://stackoverflow.com/questions/74842756/php-laravel-openssl-error0909006cpem-routinesget-nameno-start-line

```
$publicKeyPem = "-----BEGIN PUBLIC KEY-----\n".
        "xxxxxxxxxxxxxxxxxxxxxxxxxxx\n".
        "xxxxxxxxxx\n".
        "xxxxxxx\n".
        "xxxxxxx\n".
        "xxxxxxx\n".
        "xxxxxxxx\n".
        "xxxxxxxx\n".
        "-----END PUBLIC KEY-----\n";
```

エラー内容が変わらず解決まで至らなかったので
結局以下のライブラリを使用し公開鍵の検証まで実装できました。
[spomky-labs](https://web-token.spomky-labs.com/the-components/key-jwk-and-key-set-jwkset/key-management)

        // JWKオブジェクトを作成（公開鍵を作成）
        $jwk = new JWK($jwk_key);

        // JWKSetオブジェクトを作成
        $jwkSet = new JWKSet([$jwk]);

        // アルゴリズムマネージャを作成（RS256を使う）
        $algorithmManager = new AlgorithmManager([
            new RS256(),
        ]);

        // JWSVerifierを作成（JWTの署名が有効かどうかを検証）
        $jwsVerifier = new JWSVerifier($algorithmManager);

        // シリアライザを作成（JWTを文字列からオブジェクトに変換）
        $serializer = new CompactSerializer();

        // トークンをロードして検証する（デシリアライズしてJWSオブジェクトに変換）
        $token = $oauth_tokens['id_token'];
        $jws = $serializer->unserialize($token);

        // 署名を検証無効の場合エラー
        if (!$jwsVerifier->verifyWithKeySet($jws, $jwkSet, 0)) {
            Log::error('公開鍵の検証に失敗しました。');
            return \App::abort(500);
        }

### まとめ
OpenID Connectを利用した認証プロセスについて学ぶことで、他のサービスを利用した認証の仕組み、
トークンにはユーザー情報が含まれており、その情報がどのように保護されているかを実際に確認することで、
理解を深めることができました。


## 参考資料
- [Qiita参考記事](https://qiita.com/bobunderson/items/d48f89e2b3e6ad9f9c4c)
- [PHPマニュアル：openssl_verify](https://www.php.net/manual/ja/function.openssl-verify.php)
- [spomky-labs](https://web-token.spomky-labs.com/the-components/key-jwk-and-key-set-jwkset/key-management)