はじめに
この記事ではOpenID Connect認証の流れと、PHPを用いたアクセストークンおよびIDトークンの取得、公開鍵の検証方法について説明します。
OpenID Connectは、OAuth 2.0をベースにした認証プロトコルであり、
ユーザーが認証情報を直接提供することなく、第三者アプリケーションに安全にアクセス権を委任できる仕組みです。
OpenID Connect基本的な処理の流れ
※Service Aは開発中のサイト
※Identity Providerは認証を行うサービス(例:Google、Facebookなど)
認証と認可の違いについて
認証とは、ユーザーが誰であるかを確認するプロセスです。
例えば、ユーザーがユーザー名とパスワードを入力して自分自身を証明する行為が認証に当たります。
認証が成功すると、ユーザーはシステムに対して「私はこの人です」と証明されたことになります。
一方、認可はそのユーザーがどのリソースにアクセスできるかを決定するプロセスです。
認証が完了した後に、ユーザーが何をできるか
(例えば、特定のデータを読み取る、書き込む、削除するなど)を管理するのが認可です。
例えば、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がレスポンスには含まれてないということです。
アクセストークンの検証
-
署名キーの取得
- jwks_uriから署名キーを取得
- URL(例:マイクロソフトサンプル)
https://fabrikamb2c.b2clogin.com/fabrikamb2c.onmicrosoft.com/B2C_1_susi_reset_v2//v2.0/.well-known/openid-configuration
{ "kid": "X5eXk4xyojNFum1kl2Ytv8dlNP4-c57dO6QGTVBwaNk", "nbf": 1493763266, "use": "sig", "e": "AQAB", "n": "tVKUtcx_n9rt5afY_2WFNvU6PlFMggCatsZ3l4RjKxH0jgdLq6CScb0P3ZGXYbPzXvmmLiWZizpb-h0qup5jznOvOr-Dhw9908584BSgC83YacjWNqEK3urxhyE2jWjwRm2N95WGgb5mzE5XmZIvkvyXnn7X8dvgFPF5QwIngGsDG8LyHuJWlaDhr_EPLMW4wHvH0zZCuRMARIJmmqiMy3VD4ftq4nS5s8vJL0pVSrkuNojtokp84AtkADCDU_BUhrc2sIgfnvZ03koCQRoZmWiHu86SuJZYkDFstVTVSR0hiXudFlfQ2rOhPlpObmku68lXw-7V-P7jwrQRFfQVXw" }
-
公開鍵の生成
- JWKから公開鍵を生成する方法
- 公開鍵の使用例と説明
/** * 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; }
-
署名の検証
- 署名の検証方法とサンプルコード
- エラーハンドリングとデバッグ方法
/** * 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://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
// 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を利用した認証プロセスについて学ぶことで、他のサービスを利用した認証の仕組み、
トークンにはユーザー情報が含まれており、その情報がどのように保護されているかを実際に確認することで、
理解を深めることができました。