📘

Laravel OpenID ConnectのPHP Unitのテストコードを書く

2023/05/04に公開

最初に

OpenID Connnectの認証のテストコードを書こうとすると、結構めんどくさいことが起きる

なぜかというと、認証のテストを書くには、認可サーバーのレスポンスをモックすることから始まり、トークンの署名の検証、クレームの検証(トークンに含まれている属性確認、認可サーバーが発行したものと同じかなどなど)の処理をテストする必要があり、ただ認証のテストといってもかなり多くなる。

トークンの検証については、Cognito、Firebaseを参照してみると良いかもしれない。

まず、外部サービスのテストコードを書いたことがない人は最初にどうやってレスポンスをモックするのか、といったところから始まり、その次にトークンをどうやって生成するのか、などなど迷うところがある。

つまずくポイントとしては下記がある。

  1. 外部サービスのテストをどうする?
  2. トークンをどうやって生成する?
  3. トークン生成で秘密鍵??公開鍵??
  4. トークン生成時の鍵はどうする??
  5. 実際のテストコード

実際どのように書いたか少し記載してみる

1. 外部サービスのテストをどうする?

結論から言うと外部サービスのテストは、システム内で、外部サービスと通信して結果が返ってくる状態を偽装してあげればOK

下記、外部サービスや、外部のAPIのテストを書いたことがある人は無視してください。

test.php
$response = Http::fake([
    // GitHubエンドポイントのJSONレスポンスをスタブ
    'github.com/*' => Http::response(['foo' => 'bar'], 200, $headers),
]);

$response->json()

こんな感じで外部のAPIを指定して、そのAPIで返ってくる値を、実際のAPIの返り値に合わせて作成すれば、これで外部サービスに依存せずに指定のレスポンスを取得することができる。

もしくは下記でMockHandlerを使って対応することもできると思う

test.php
$mock = new MockHandler([
            new \GuzzleHttp\Psr7\Response(status: 200, headers: [], body: json_encode([
		'foo' => 'bar'
            ])),
        ]);

$handlerStack = HandlerStack::create($mock);
$client = new Client(['handler' => $handlerStack]);

$response = $client->get('https://www.google.co.jp/');

$response->json();

どっちを使うのかは、自分がGuzzleを使って実装してるか、Httpクライアントを使ってるかで判断すると良いと思う。

2. トークンをどうやって生成する?

個人的にはこれが一番めんどくさいと思ったポイント。

トークンの生成について記載する前に自分がまず、どのトークンを生成したいのかみる必要がある、idトークンなのか、アクセストークンなのか、リフレッシュトークンなのか。

そして、そのトークンをデコードしたときに実際に何が含まれているのか。までみた上で作成する
実際にトークンの中を見たい時はこのツールを使えば良い

下記がCognitoのトークンをデコード時のサンプル値である

header.php
{
  "kid": "abcdefghijklmnopqrsexample=",
  "alg": "RS256"
}
payload.php
{
  "sub": "aaaaaaaa-bbbb-cccc-dddd-example",
  "aud": "xxxxxxxxxxxxexample",
  "email_verified": true,
  "token_use": "id",
  "auth_time": 1500009400,
  "iss": "https://cognito-idp.ap-southeast-2.amazonaws.com/ap-southeast-2_example",
  "cognito:username": "anaya",
  "exp": 1500013000,
  "given_name": "Anaya",
  "iat": 1500009400,
  "email": "anaya@example.com"
}

これに合わせてトークンを作るには、ヘッダー、ペイロード、署名の3つが必要になり、あと署名が必要になる。
その署名を作るには、ヘッダーとペイロードの2つがあれば作ることができる。

署名付きのトークンを生成するコードは下記

GenerateToken.php
$payload = [
    'sub' => 'aaaaaaaa-bbbb-cccc-dddd-example',
    'aud' => 'xxxxxxxxxxxxexample',
    'email_verified' => true,
    'token_use' => 'id',
    'auth_time' => 1500009400,
    'iss' => 'https://cognito-idp.ap-southeast-2.amazonaws.com/ap-southeast-2_example',
    'cognito:username' => 'anaya',
    'exp' => 1500013000,
    'given_name' => 'Anaya',
    'iat' => 1500009400,
    'email' => 'anaya@example.com',
];

// algや、keyIdはheader.phpの情報
return JWT::encode($payload, $this->privateKey, alg:'RS256', keyId: 'abcdefghijklmnopqrsexample=');

php-jwtのライブラリを使うことで一瞬で署名済のトークンが生成される

あとは、$this->privateKeyとなっている秘密鍵が必要になる。ここの鍵の知識については下記記載

3.トークン生成で秘密鍵??公開鍵??

ここで急に鍵の話が出てくる、いきなりエンジニアになってしまった人は何回も調べている内容だと思う

トークンが実際にどのように生成されてるのかというとヘッダーと、ペイロード、に対して秘密鍵を使って署名を作成している。そのため下記のencodeメソッドの第二引数で、秘密鍵が必要になる。第三引数は署名に使うアルゴリズム、

GenerateToken.php

// algや、keyIdはheader.phpの情報
return JWT::encode($payload, $this->privateKey, alg:'RS256', keyId: 'abcdefghijklmnopqrsexample=');

逆を捉えると、秘密鍵をここで使ってるため、署名の検証をするときには必ず、公開鍵が必要になるとも捉えることができる。

ちなみにこれは公開鍵暗号方式と言われている

トークンの署名で必要な公開鍵の取得は、JWKSエンドポイントを叩くと取得できる

4. トークン生成時の鍵はどうする??

さっきのコードでトークンを生成するには秘密鍵が必要ということがわかった。

しかし秘密鍵をどうやって生成し、それをどのようにテストで扱うのか困る。。。

一般的に秘密鍵は誰かに見られてしまってはいけないためGithubにあげてしまうととんでもないことになる。。。

ここで困るのが、秘密鍵をどうするか問題。。。

実は何も難しくなく、テストコード上で秘密鍵を生成してあげればOK

public function setUp(): void
{
	parent::setUp();
	$keyPair = openssl_pkey_new([
	    'private_key_bits' => 2048,
	    'private_key_type' => OPENSSL_KEYTYPE_RSA, //ここで秘密鍵の種類を定義
	]);

	openssl_pkey_export($keyPair, $privateKey);

	$this->privateKey = $privateKey;
	$publicKey = openssl_pkey_get_details($keyPair)['key'];

	$this->publicKey = $publicKey;
}

これでついにトークンを生成することができ、テストコードでトークンの検証を行うことができる状態となる。

5. 実際のテストコード

今まで説明してきたことをまとめてると、外部の認可システムから、モックを使うことで、トークンを発行することができるようになった。

    public function setUp(): void
    {
        parent::setUp();

        $keyPair = openssl_pkey_new([
            'private_key_bits' => 2048,
            'private_key_type' => OPENSSL_KEYTYPE_RSA,
        ]);

        openssl_pkey_export($keyPair, $privateKey);

        $this->privateKey = $privateKey;
        $publicKey = openssl_pkey_get_details($keyPair)['key'];

        $this->publicKey = $publicKey;
    }
    
     /**
     * @return void
     * @throws \Exception
     */
    public function test_call_back_verify_id_token_expired(): void
    {
        $this->expectException(ExpiredException::class);
        $authResponse = $this->mockAuthRequest();
        $tokenResponse = $this->mockTokenRequest(isExpired: false);
        $idToken = $tokenResponse->json()['id_token'];

        JWT::decode($idToken, new Key($this->publicKey,'RS256'));

        $tokenResponse = $this->withSession([
            'state' => 'state',
            'code' => 'code',
        ])->get(route($this->endPoint, [
            'state' =>$authResponse['state'],
            'code' => $authResponse['code'],
        ]));

        $tokenResponse->assertBadRequest();
    }

個人的な感想だが、認証や、外部サービスを使うときほど、テストコードが詳細に書くべきと思う。。。

Discussion