12
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

開発でチャレンジして、失敗・成功したことをシェアしよう by 転職ドラフトAdvent Calendar 2024

Day 2

OAuth/OIDCの実装をミスった!仕様見落とし・仕様変更をどう乗り切った?

Last updated at Posted at 2024-12-01

はじめに

Authlete (オースリート) という OAuth / OpenID Connect の商用実装を 2014 年から開発し続けています。本記事公開時点 (2024 年 12 月) で開発開始から 10 年以上が経過しています。

authlete_logo.png

OpenID Foundation という世界標準仕様策定団体が コンフォーマンススイート (互換性確認テスト群) の開発に Authlete を事実上のリファレンス実装として用いている事実が示すように、Authlete は数多くの標準仕様をサポートしており、実装品質はとても高いです。

特に、世界各国の API エコシステムで採用されている FAPI (ファピ) と呼ばれる高セキュリティプロファイルのサポートでは世界トップを走っており、高いセキュリティを必要とする国内外の金融機関で Authlete が採用されています。世界最大のデジタル銀行であるヌーバンク (導入事例) やラテンアメリカ最大の投資銀行であるビーティージー・パクチュアルによる Authlete 採用は特筆すべき事例です。

api_ecosystems.png

Authlete が業界内で重宝される理由は、ドラフト段階の最新仕様をいち早く実装し、仕様の問題点を仕様策定者にフィードバックするからです。例えば最近の事例ですと、OpenID Foundation の FAPI ワーキンググループRFC 9421: HTTP Message Signatures をベースに策定した『FAPI 2.0 HTTP Signing』という仕様に対し、実装を提供しました (参照: FAPI 2.0 HTTP Signing Demo)。また、この実装作業の副産物として、HTTP Message Signatures のための Java ライブラリをオープンソースで公開しました (authlete/http-message-signatures)。

FAPI 2.0 HTTP Signingに準拠したHTTP Message Signatureを含むHTTPレスポンス
HTTP/1.1 200 OK
Date: Tue, 22 Oct 2024 15:59:44 GMT
x-fapi-interaction-id: e5e7d28c-8d85-40fc-ab5b-3f0a32f4e99e
Content-Digest: sha-256=:RBNvo1WzZ4oRRq0W9+hknpT7T8If536DEMBg9hyq/4o=:
Signature-Input: sig=("@method";req "@target-uri";req "@status" "content-digest");created=1729612785;keyid="tsq5sQwuoADZ3iARLOreaYaIa9mG5TnV11zpRRjuA0k";tag="fapi-2-response"
Signature: sig=:2m81zeYd/jjtJ5wcViutKk1zHW0XU7yOjN7GBVEPbQ3VnL3dPcuRfqIKVBhrqkNIIdJVDuNwO0IKJxfCyaQYdg==:
Content-Type: text/plain
Content-Length: 2
Server: Jetty(9.3.7.v20160115)

{}

http-message-signatures ライブラリの NormalComponentValueProvider.java は、RFC 9421 Section 2.1 で定義されているパラメータ群 (bs, key, req sf, tr) の処理方法としてお手本のようなコードになっています。標準仕様の商用品質実装に興味のある方はソースコードを読んでみてください。

これまでに大量の標準仕様を実装してきました。その中で、仕様の見落としや解釈間違い、仕様自体の変更により、Authlete の実装を後から変更しなければならないことが何度かありました。この記事ではその経験を共有したいと思います。

アクセストークンには必ずスコープを紐付けなければならなかった

RFC 6749: The OAuth 2.0 Authorization Framework に登場する scope パラメータですが、次表に示すセクションで説明されています。

セクション 説明
4.1.1 認可コードフロー時の認可リクエスト
4.2.1 インプリシットフロー時の認可リクエスト
4.3.1 リソースオーナーパスワードクレデンシャルズ (以降 ROPC) フロー時のトークンリクエスト
4.4.1 クライアントクレデンシャルズフロー時のトークンリクエスト
6 リフレッシュトークンフロー時のトークンリクエスト

いずれのセクションでも scope は OPTIONAL と書いてあるので、「何のスコープも持たないアクセストークンもありうる」という前提で Authlete を実装していました。

しかしある日、「Section 3.3. Access Token Scope に次のように書かれているから、アクセストークンは必ず何らかのスコープを持たなければならない」と指摘されました。

If the client omits the scope parameter when requesting authorization, the authorization server MUST either process the request using a pre-defined default value or fail the request indicating an invalid scope.

つまり、認可サーバの実装は、認可リクエストの scope パラメータが省略された場合、アクセストークンに何らかのデフォルトスコープを設定するか、もしくは、scope パラメータが指定されていないという理由で認可リクエストを拒否しなければなりません。

それまで、「デフォルトスコープが設定されていない状態で scope パラメータ無しの認可リクエストが来たら、何のスコープも持たないアクセストークンを発行する」という動作をしていました。もしもこの動作を上記の要求事項に沿うように修正すると、影響が大き過ぎます。今まで受け入れられていた認可リクエストが拒否されるようになってしまうからです。とはいえ、この要求事項に厳密に従う方法を提供したいと思いました。

そこで、Service.scopeRequired という真偽値フラグを導入し、このフラグが真の場合に上記の要求事項に厳密に従うようにしました。Authlete 3.0 の Web コンソール (https://console.authlete.com/) では、「スコープパラメーターなしのリクエストの処理」という項目がこの Service.scopeRequired フラグに対応します。

Service_scopeRequired.png

とはいえ、何のスコープに紐付かないアクセストークンを生成しても、大きな問題とはならないでしょう。

スコープで指定されたクレーム群をIDトークンに埋め込んでいいのはアクセストークンが発行されないときだけだった

OpenID Connect Core 1.0 (以降 OIDC Core) の Section 5.4. Requesting Claims using Scope Values では、特定のクレーム群を要求するための簡易的な方法として特別なスコープを定義しています。

例えば phone というスコープを要求すると、それは phone_numberphone_number_verified という二つのクレームを要求することと等しくなります。認可サーバの実装は、このようにして要求されたクレーム群を ID トークン (Section 2. ID Token) やユーザ情報エンドポイント (Section 5.3. UserInfo Endpoint) からのレスポンスに埋め込みます。

しかしある日、「Section 5.4. Requesting Claims using Scope Values に次のように書かれているから、スコープで要求されたクレーム群を ID トークンに埋め込んでいいのは、アクセストークンが発行されないときだけだ」という指摘を受けました。

The Claims requested by the profile, email, address, and phone scope values are returned from the UserInfo Endpoint, as described in Section 5.3.2, when a response_type value is used that results in an Access Token being issued. However, when no Access Token is issued (which is the case for the response_type value id_token), the resulting Claims are returned in the ID Token.

この要求事項の意図は、「アクセストークンが発行された場合、クライアントはそのアクセストークンを使ってユーザ情報エンドポイントからクレーム群の値を取得できるから、ID トークンにクレーム群を埋め込まなくていいよね」、というもののようです。

それまでの Authlete の実装は、スコープで指定されたクレーム群をアクセストークンに埋め込むとき、ID トークンと一緒にアクセストークンが発行されるかどうかは気にしていませんでした。つまり、スコープで指定されたクレーム群を無条件に ID トークンに埋め込んでいました。

この要求事項に厳密に従っている認可サーバの実装は世の中にはほとんど存在しないと思います。しかしながら、仕様に厳密に従う動作も実装しておきたいと思いました。

そこで、Service.claimShortcutRestrictive という真偽値フラグを導入し、このフラグが真の場合に上記の要求事項に厳密に従うようにしました。Authlete の Web コンソール上では、「ショートカットを規制する」という項目がこのフラグに対応します。

Service_claimShortcutRestrictive.png

Section 5.4 から引用した文は禁止のニュアンスが弱いので、「アクセストークンが発行されるときも ID トークンにクレームを含めても別にいいよね」と解釈することもできそうでした。もしもそういう解釈が許されるなら、Authlete の実装を変更する必要はありませんでした。そこで、この仕様を書いたであろう業界関係者の方々に仕様策定時の意図を尋ねて回りました。その結果、私の期待に反して仕様の意図は『禁止』でした。そのため Service.claimShortcutRestrictive フラグを導入することにしました。

リフレッシュトークンのローテーションはBad Practiceと認識されるようになった

RFC 6749 の Section 6. Refreshing an Access Token の最終段落に次のように書かれています。

The authorization server MAY issue a new refresh token, in which case the client MUST discard the old refresh token and replace it with the new refresh token. The authorization server MAY revoke the old refresh token after issuing a new refresh token to the client. If a new refresh token is issued, the refresh token scope MUST be identical to that of the refresh token included by the client in the request.

リフレッシュトークンフローの処理の結果として認可サーバは新しいリフレッシュトークンを発行してもよく、その場合、古いリフレッシュトークンは無効化しなければならない、という規定です。この動作はリフレッシュトークンのローテーションと呼ばれます。

しかし近年、リフレッシュトークンのローテーションは Bad Practice とみなされるようになってきました。トラブルが多いからです。

FAPI 2.0 Security ProfileSection 5.3.2.1. General requirements の 10 番目の項目には、"shall not use refresh token rotation except in extraordinary circumstances" (特別な状況を除きリフレッシュトークンのローテーションを使用してはならない) と書かれています。

リフレッシュトークンがローテーションされると、通信障害等の理由によりクライアントアプリケーションがリフレッシュトークンフローの結果を受けとれなかったときに、「新しいアクセストークンを受け取れなかったが使用したリフレッシュトークンは既に無効化されている」状態となり、詰んでしまいます。

また、クライアントアプリケーション側の何らかの理由 (せっかちなユーザのボタン連打など) で短時間にリフレッシュトークンフローのトークンリクエストが連投されてしまうことがあり、これが発生したときに、クライアントアプリケーションが最初に成功裡に受け取ったリフレッシュトークンで処理を進めていると、「新しいはずのアクセストークンとリフレッシュトークンの両方とも無効になっている」という状況に陥ってしまいます。

リフレッシュトークンローテーションの動作を制御するため、Authlete には Service.refreshTokenKept という真偽値フラグが用意されています。このフラグが真の場合、リフレッシュトークンローテーションは行われません。なお、フラグの名前が refreshTokenRotationEnabled という分かりやすいものではなく refreshTokenKept となっているのは、フラグが偽のときに以前の動作 (リフレッシュトークンローテーション有効) と同じ動作をするためです。

Authlete の Web コンソール上では「トークンローテーションを有効にする」という項目がこのフラグに対応します。

Service_refreshTokenKept.png

一方で、リフレッシュトークンローテーションの問題への対処方法には別のアプローチもあります。 短時間にリフレッシュトークンフローのトークンリクエストが複数届いた場合、トークンエンドポイントの実装は「新しいリフレッシュトークンを発行せず、直前に作成したばかりのリフレッシュトークンをそのまま返す」動作をする、というアプローチです。

一般的に、同じ変更リクエストを複数回投げても結果の整合性が保たれることを「冪等性 (べきとうせい)がある」と言います。英語で冪等性は idempotency、形容詞形は idempotent で、難しい単語ではありますが、IT 業界では時折目にします。

Authlete はリフレッシュトークンのローテーションにおいて冪等性をサポートしています。Service.refreshTokenIdempotent という真偽値フラグを真にすると、冪等性が有効になります。60 秒間の間に届いた同一リフレッシュトークンを用いたトークンリクエスト群は冪等処理の対象となります。Authlete の Web コンソールでは「冪等性を有効にする」という項目がこのフラグに対応します。

Service_refreshTokenIdempotent.png

Authlete 社内ではこの機能をベッキーと呼んでいます。

動的クライアント登録の scope クライアントメタデータを見落としていた

OpenID Connect Dynamic Client Registration 1.0 (以降 OIDC DynReg) の Section 2. Client Metadata では、クライアントを動的に登録する際に指定できるメタデータが列挙されています。

OIDC DynReg の OAuth 版の仕様として、RFC 7591: OAuth 2.0 Dynamic Client Registration Protocol が存在します。RFC 7591 の Section 2. Client Metadata にもクライアントメタデータが列挙されていますが、OpenID Connect にしか存在しないメタデータ、例えば id_token_signed_response_alg は、RFC 7591 には含まれていません。そういうわけで、基本的には OIDC DynReg は RFC 7591 の上位互換です。

そんなふうに考えていた時期が私にもありました。

しかしある日、「動的クライアント登録時に scope メタデータが認識されない」という指摘を受けました。「えっ?クライアントメタデータに scope というものは存在しないけど?」というのが私の最初の反応でしたが、よくよく調べてみると、OIDC DynReg では scope メタデータは定義されていないものの、RFC 7591 では scope メタデータが次のように定義されていました。

scope

String containing a space-separated list of scope values (as described in Section 3.3 of OAuth 2.0 [RFC6749]) that the client can use when requesting access tokens. The semantics of values in this list are service specific. If omitted, an authorization server MAY register a client with a default set of scopes.

この scope メタデータの説明文が期待している動作は、「クライアントが要求できるスコープを scope メタデータで指定されたものだけに制限する」というものです。例えば scope メタデータの値が diary:view photo:view だった場合、そのクライアントは diary:viewphoto:view 以外のスコープを要求できなくなります。たとえ当認可サーバが openid というスコープをサポートしていても、このクライアントは openid スコープを要求できなくなるので、結果として ID トークンの発行を受けることができません。

厄介なことに、RFC 7591 の説明文では、「scope メタデータが省略されたとき、認可サーバはデフォルトのスコープ群をクライアントに割り当ててもよい」とされています。もし認可サーバの実装がクライアント毎にデフォルトスコープ群を設定する機能を提供している場合、そして、デフォルトスコープ群のデフォルト値が空の場合、「scope メタデータを含まない動的クライアントリクエストにより作成されたクライアントは何のスコープも要求することはできない」という困った状況に陥ってしまいます。

Authlete は昔からクライアント毎に要求可能なスコープ群を制限する機能を提供しているので、動的クライアント登録リクエストに含まれる scope メタデータの値を、その機能にマッピングすること自体は可能でした。しかしながら、RFC 7591 の動作をそのまま実装すると、それまで「動的クライアント登録により作成されたクライアントは認可サーバがサポートするどのスコープも要求することができる」という動作から一転、「動的クライアント登録により作成されたクライアントは (動的クライアント登録に scope メタデータが含まれていなければ) 何のスコープも要求できない」という真逆の動作になってしまいます。

動的クライアント登録で scope メタデータを使わないお客様と、RFC 7591 で定義されている用途で scope メタデータを使いたいお客様、両方ともサポートするにはどうすればよいのか。

そこで、Service.dcrScopeUsedAsRequestable という真偽値フラグを導入し、このフラグが真の場合に RFC 7591 の説明文の動作に従うようにしました。Authlete のコンソールでは「DCRのスコープパラメータ」という項目がこのフラグに対応します。

Service_dcrScopeUsedAsRequestable.png

認可コードからクライアントIDを特定するべきではなかった

認可コードはクライアントに紐付けて発行するので、認可コードがあればクライアントを特定することができます。そのため、認可コードフロー時のトークンリクエストにクライアントを特定する情報が含まれていなかった場合、気を利かせて認可コードからクライアントを特定するという処理を実装していました。

しかしある日、「認可コードからクライアントを特定するのはセキュリティ上よくない」という指摘を受けました。また、RFC 6749 の Section 4.1.3. Access Token Request をよく読み返してみると、client_id リクエストパラメータは次のように説明されていました。つまり、クライアント認証が実施されない場合は client_id リクエストパラメータは必須でした。

client_id

REQUIRED, if the client is not authenticating with the authorization server as described in Section 3.2.1.

実装を修正する必要がでてきましたが、「パブリッククライアントだが client_id リクエストパラメータを含めずに認可コードフローのトークンリクエストを投げている」お客様がいる可能性があったため、後方互換性維持のため、Service.missingClientIdAllowed という真偽値フラグを導入することにしました。このフラグが明示的に真に設定された場合、仕様違反にはなるものの、後方互換性維持のため client_id リクエストパラメータが省略された場合でも認可コードからクライアントを特定してトークンリクエストを処理することとしました。Authlete の Web コンソール上では「クライアント ID の省略」という項目がこのフラグに対応します。

Service_missingClientIdAllowed.png

セキュリティのためにissレスポンスパラメータをデフォルトで含めるようにしたら怒られた

RFC 9207: OAuth 2.0 Authorization Server Issuer Identification という仕様があります。これは、mix-up attack という攻撃に対する対抗策として策定された仕様です。この仕様に従う認可サーバは、次の例が示すように、認可レスポンスに iss レスポンスパラメータを含めます。

HTTP/1.1 302 Found
Location: https://client.example/cb?
  code=x1848ZT64p4IirMPT0R-X3141MFPTuBX-VFL_cvaplMH58
  &state=ZWVlNDBlYzA1NjdkMDNhYjg3ZjUxZjAyNGQzMTM2NzI
  &iss=https%3A%2F%2Fhonest.as.example

Authlete が新しいフラグを追加する際は、基本的には既存動作を変更しないようにフラグを定義します。つまり、フラグの値が偽 (false) の場合に既存動作と同じ動作になるようにフラグを定義します。

しかしながら、セキュリティ上の理由により既存動作を変更したほうがよいと判断した場合、フラグの値が偽の場合に新しい動作になるようにフラグを定義します。以前の動作に戻したい場合はフラグの値を明示的に真 (true) に設定する必要があります。

iss レスポンスパラメータを認可レスポンスに含めるか否かを制御するフラグを定義する際、セキュリティ上の理由により、フラグが偽のときに新しい動作、すなわち iss レスポンスパラメータを含めるという動作をするようにしました。そのため、フラグを Service.issSuppressed と命名しました (「issが抑制されている」という意味)。iss レスポンスパラメータを含めたくない場合は、明示的に Service.issSuppressed=true と設定する必要があります。Authlete の Web コンソール上では「iss レスポンスパラメーターを抑制」という項目がこのフラグに対応します。

Service_issSuppressed.png

認可レスポンスに iss パラメータが追加されてもお客様が困ることはないだろう、と高を括っていました。しかし、あるお客様から怒られました。Authlete の定期更新時にしれっと iss レスポンスパラメータが追加された件について、問い詰められ、再発防止を求められました。

あまりに問い詰められたので、この件以降、「今後はたとえセキュリティのためであっても既存動作は変更しない」と心に決めました。

IDトークンのaudクレームの型に一貫性がなかった

ある時、「認可コードフローで発行される ID トークンと CIBA フローで発行される ID トークンの aud クレームの型が異なるが、何か理由がありますか?」と尋ねられました。

調べてみると、認可コードフローで発行する ID トークンの aud クレームの型は配列で、CIBA フローのそれは単一文字列となっていました。

記憶を辿ったところ、型が異なることに深い理由はありませんでした。認可コードフローで発行される ID トークンの aud の型を配列としたのは、OIDC Core 1.0 の "In the general case, the aud value is an array of case sensitive strings. In the common special case when there is one audience, the aud value MAY be a single case sensitive string." という記述から、「aud を単一文字列にするのは例外的なケース」と実装時に判断したためでした。一方 CIBA 実装時は、単純に仕様書内の例に倣って単一文字列にしました。

JWT の仕様上、aud クレームの型は配列と単一文字列、どちらでもよいので、ID トークンを受け取って処理するプログラムは、どちらのケースにも対応できるようになっていなければなりません。とはいえ、問い合わせをしてきたお客様は、「配列と単一文字列、どちらかに統一したい」とお考えのようでした。

そこで、ID トークンの aud クレームの型を指定する仕組みを二段構えで作り込むことにしました。

まず、Service.idTokenAudType というプロパティを作りました。このプロパティの値が arraystring、null の場合にそれぞれ、配列型、単一文字列、既存動作、となるようにしました。Authlete の Web コンソールでは「オーディエンスクレームの形式を選択」という設定項目がこのプロパティに対応します。

Service_idTokenAudType.png

次に、ID トークンを発行する可能性のある Authlete API (例: /auth/authorization/issue API) に idTokenAudType というリクエストパラメータを追加しました。このパラメータにより、Service.idTokenAudType の動作を上書きできるようにしました。

JWT の aud クレームは時折大きな議論を巻き起こします。2024 年 11 月下旬、FAPI WG は長い議論の末、クライアントアサーションの aud の型を文字列に限定する (配列を禁止する) 決定を下しました。

「オーディエンスを一つに絞りたいのであれば『文字列または要素が一つだけの配列』という縛りにすればよいのでは?」という議論はありましたが、その議論を踏まえた上での決定です。

参考: FAPI PR 522: Boom! AS to enforce aud as a single value issuer

RFC 8252のLoopback Interface Redirectionの規定が既存の仕様と衝突してしまう

RFC 8252: OAuth 2.0 for Native AppsSection 7.3. Loopback Interface Redirection は、OAuth 2.0 のリダイレクション URI のホスト部がループバック IP アドレスの場合、認可サーバはポート番号を可変として扱わなければならないと述べています。

この要求事項は、RFC 6749: The OAuth 2.0 Authorization Framework3.1.2.3. Dynamic Configuration および OpenID Connect Core 1.03.1.2.1. Authentication Request が求める単純文字列比較 (Simple String Comparison) 処理と衝突してしまいます (詳細は『ループバックインターフェースリダイレクション』を参照のこと)。そのため、RFC 8252 Section 7.3 の要求事項をそのまま実装してしまうと、RFC 8252 に追随しない環境では仕様違反となってしまいます。

そこで、Service.loopbackRedirectionUriVariable という真偽値フラグを導入し、RFC 8252 Section 7.3 に従うかどうかを切り替えられるようにしました。Authlete の Web コンソールでは「ループバックリダイレクトURI」という項目がこのフラグに対応します。

Service_loopbackRedirectionUriVariable.png

業界内で論争を巻き起こしたRFC 9101 (JAR)による破壊的仕様変更が凄まじかった

OIDC Core の Section 6. Passing Request Parameters as JWTs で定義されたリクエストオブジェクトは、後日、RFC 9101: The OAuth 2.0 Authorization Framework: JWT-Secured Authorization Request (JAR) (通称 JAR) という独立した仕様に切り出されました。

しかし、切り出す際に破壊的に (= 後方互換性を維持せずに) 仕様を変更することにしたため、業界内で大論争を巻き起こしました。議論開始から RFC 9101 が世に出るまで 11 年もの歳月を費やしたのはこれが理由です。

主な仕様変更に次のようなものがあります。(詳細は『JAR (JWT-Secured Authorization Request) に関する実装者の覚書』を参照)

リクエストパラメーター OAuth 2.0 OIDC JAR
リクエストオブジェクト外の
リクエストパラメーター群
マージされる マージされる 無視される
リクエストオブジェクト内の
リクエストパラメーター群
マージされる マージされる 参照される
response_type OAuth 2.0 OIDC JAR
リクエストオブジェクト外の response_type 必須 必須 無視
リクエストオブジェクト内の response_type 任意 任意。存在する場合は、リクエストオブジェクト外の値と一致しなければならない。 必須
scope OAuth 2.0 OIDC JAR
リクエストオブジェクト外の scope 任意 必須で、かつ openid を含まなければならない。省略された場合、または openid を含まない場合、そのリクエストは OIDC リクエストとはみなされない (ただしこれはエラーではない)。もしもリクエストオブジェクトが与えられ、それが scope を含み、その scopeopenid を含む場合、リクエストオブジェクト外にも scope が必須となり、その scopeopenid を含まなければならない。 無視
リクエストオブジェクト内の scope 任意 任意。存在し、かつ openid を含む場合、リクエストオブジェクト外にも openid を含む scope が必須となる。 任意
署名 OAuth 2.0 OIDC JAR
リクエストオブジェクトの署名 任意 任意 必須

RFC 9101 への移行が望ましいものの、無条件に仕様変更を実装してしまうと、動かなくなる既存システムも出てきてしまいます。そこで、Service.traditionalRequestObjectProcessingApplied という真偽値フラグを導入し、リクエストオブジェクトの処理を、OIDC Core の方法でおこなうか (後方互換) RFC 9101 の方法でおこなうかを切り替えられるようにしました。Authlete の Web コンソールでは「JAR 互換性を有効にする」という項目がこのフラグに対応します。

Service_traditionalRequestObjectProcessingApplied.png

RFC 9126: OAuth 2.0 Pushed Authorization Requests (通称 PAR) で定義されている PAR エンドポイントもリクエストオブジェクトを受け付けますが、PAR エンドポイントでは常に RFC 9101 の方法でリクエストオブジェクトを処理します。なぜなら、RFC 9126 が「リクエストオブジェクトの処理方法は RFC 9101 に従うこと」と規定しているからです。

FAPIを仕様通りに実装していたのがAuthleteだけだったので、Authleteの方が妥協せざるをえなくなった

FAPI 準拠を謳う英国オープンバンキングプロファイル (以降 OBP) の策定が活況を迎えていた頃、FAPI 仕様に次のように書かれていたので、

shall require user authentication at LoA 3 or greater by requesting the acr claim as an essential claim as defined in section 5.5.1.1. of OIDC;

Authlete は「claims リクエストパラメータの値 (JSON) の中に acr プロパティがあり、そのプロパティの値 (JSON) の中に essential プロパティがあり、そのプロパティの値が true である」ことを確認していました。この条件が満たされていない場合、Authlete はリクエストを不正とみなしてエラー応答を返していました。

当然 OBP 実装を対象としたコンフォーマンススイートも、「essential プロパティが true ではないリクエストを送った場合にサーバ実装がエラーを返すことを確認する」というテストケースを書くべきでした。

しかし、厳格な実装をしているのは Authlete だけだったので、そのようなテストケースを書いてしまうと、Authlete 以外の OBP 実装群がことごとくテストをパスできなくなってしまう事態に陥ることが予見されました。

コンフォーマンススイート開発チームはテストケースのテストに Authlete を使っているため、Authlete がテストをパスしないとテストケースをリリースできません。しかし、Authlete がテストをパスするように (FAPI 仕様に厳密に従う) テストケースを書いてしまうと、他の OBP 実装群がテストをパスできなくなってしまいます。技術的にはそれがあるべき姿でしたが、英国オープンバンキングのスケジュールの観点から、他の OBP 実装群に今から essential プロパティの処理の実装を求めることは現実的ではありませんでした。そこで、コンフォーマンススイート開発チームは「どうにかならないか」と Authlete 社に相談してきました。

open_banking_profile.png

その結果生まれたのが、Service.supportedServiceProfiles 配列とその配列に含める OPEN_BANKING という識別子です。当配列に OPEN_BANKING を含めておくと、FAPI Part 2 に準拠しなければならない場合でも、Authlete は essential プロパティのチェックをしないようになります。Authlete の Web コンソールでは、「サービスプロファイルを選択」に列挙されている項目が Service.supportedServiceProfiles 配列の要素群に対応します。

Service_supportedServiceProfiles.png

この経緯は過去に『OAuth & OIDC 勉強会 認可リクエスト編 by #authlete - 7. ユーザーの識別・認証』(15:23〜) という動画の中で話していますので、興味のある方はご覧ください。

しかしながら、数年後、FAPI の仕様自体から「acr クレームを必須クレーム (essential claim) として要求する」という項目自体がなくなったので、Authlete の実装からも acr クレームの essential プロパティの値をチェックするコードを取り除きました。そのため、OPEN_BANKING の有無による動作の違いはなくなりました。今となっては意味のない機能となってしまいました。

当時は英国オープンバンキングプロファイルがどの程度 FAPI 仕様から乖離することになるのか予想できなかったので、大掛かりな仕組み (Service.supportedServiceProfiles) を導入しましたが、「acr クレームの essential プロパティをチェックするか否かの差分しかない」とあらかじめ分かっていたなら、Service.acrEssentialUnchecked のようなシンプルな真偽値フラグの追加で済ませたことでしょう。

クライアント設定エンドポイントで401 Unauthorizedを返すべきケースがあることを見落としていた

RFC 7591: OAuth 2.0 Dynamic Client Registration Protocol で動的に登録したクライアントの設定を後から参照・更新・削除するためのエンドポイントがクライアント設定エンドポイント (Client Configuration Endpoint) です。このエンドポイントは RFC 7592: OAuth 2.0 Dynamic Client Registration Management Protocol で定義されています。

RFC 7592 では、クライアントが存在しないときに 401 Unauthorized エラーを返すようにと規定されています。しかし、これを見落としていました。

クライアント設定エンドポイントを実装するための API として、Authlete は /client/registration/get API、/client/registration/update API、/client/registration/delete) API を提供しています。これらの Authlete API 群が返す JSON レスポンスには、他の Authlete API と同様に action というプロパティが含まれています。この action プロパティの値に応じてクライアント設定エンドポイントの実装は動作を切り替えます。例えば actionOKUPDATED ならクライアント設定エンドポイントは 200 OK を返し、actionBAD_REQUEST なら 400 Bad Request を返す、といった具合です。

このような仕組みのため、クライアント設定エンドポイントの典型的な実装は、下記の例のように action の値に従って switch をおこなうコードとなっています。

private Response process(ClientRegistrationResponse response)
{
    // 'action' in the response denotes the next action which
    // this service implementation should take.
    Action action = response.getAction();

    // The content of the response to the client application.
    String content = response.getResponseContent();

    // Dispatch according to the action.
    switch (action)
    {
        case BAD_REQUEST:
            // 400 Bad Request
            return ResponseUtil.badRequest(content);

        case CREATED:
            // 201 Created
            return ResponseUtil.created(content);

        case INTERNAL_SERVER_ERROR:
            // 500 Internal Server Error
            return ResponseUtil.internalServerError(content);

        case OK:
            // 200 OK
            return ResponseUtil.ok(content);

        case DELETED:
            // 204 no content
            return ResponseUtil.noContent();

        case UPDATED:
            // 200 OK
            return ResponseUtil.ok(content);

        default:
            // This never happens.
            throw getApiCaller().unknownAction("/api/client/registration/*", action);
    }
}

しかし、401 Unauthorized を返すべきケースがあることを見落としていたので、action の値として UNAUTHORIZED を定義していませんでした。このため、既存のサンプルコードには次のような case UNAUTHORIZED が含まれていませんでした。

        case UNAUTHORIZED:
            // 401 Unauthorized
            return ResponseUtil.unauthorized(content, null);

Authlete サーバ側で不具合を修正し、適宜 UNAUTHORIZED を返すことはできますが、それを無条件にやってしまうと、既存のコードが意図しないエラーを起こしてしまいます (= Authlete API から未知の action が返ってきたことを理由に 500 Internal Server Error を誘発してしまうかもしれません)。ですので、Authlete が UNAUTHORIZED を返すのは、クライアント設定エンドポイントの実装が UNUAUTHORIZED を処理する準備ができている場合に限るべきです。

そこで、Service.unauthorizedOnClientConfigSupported という真偽値フラグを用意し、この値が真の場合のみ、必要に応じて UNAUTHORIZED を返すという動作を実装することにしました。このフラグが偽のときは、後方互換性のため (RFC 7592 には厳密に従っていませんが) BAD_REQUEST を返します。Authlete の Web コンソールでは「UNAUTHORIZED を返す」という項目がこのフラグに対応します。

Service_unauthorizedOnClientConfigSupported.png

クライアント更新時のclient_secretパラメータの処理方法について英語ネイティブ専門家間でも仕様の解釈が異なった

RFC 7592 の Section 2.2. Client Update Request には次のように書かれています。

This request MUST include all client metadata fields as returned to the client from a previous registration, read, or update operation. The updated client metadata fields request MUST NOT include the "registration_access_token", "registration_client_uri",
"client_secret_expires_at", or "client_id_issued_at" fields described in Section 3.

The client MUST include its "client_id" field in the request, and it MUST be the same as its currently issued client identifier. If the client includes the "client_secret" field in the request, the value of this field MUST match the currently issued client secret for that client. The client MUST NOT be allowed to overwrite its existing client secret with its own chosen value.

この記述を読んだとき、私は、「動的クライアント登録エンドポイントからのレスポンスに含まれるクライアントメタデータは (client_secret も例外扱いせずに) 全てクライアント更新リクエストに含めなければならない」、と解釈しました。

Authlete の動的クライアント登録レスポンスには必ず client_secret が含まれるため、クライアント更新リクエストが client_secret 含むことを Authlete の実装ではチェックしていました。

しかしある日、あるお客さまから「クライアントは client_secret を使っていないので保存管理したくない。しかしクライアント更新時に client_secret の提示が求められてしまう。どうして提示が必要なのか理由を知りたい」という問い合わせを受けました。

そこで、RFC 7592 の仕様策定者本人に質問してみました。すると、「登録レスポンスに client_secret が含まれているからといって、それを更新リクエストの必須パラメータとする意図はなかった。ただし、更新リクエストに client_secret が含まれていた場合に限り、その値が現在のクライアントシークレットと一致することを確認してほしかった」、という回答が返ってきました。

この会話には他の専門家も参加していましたが、彼の解釈は私と同じでした。つまり、「動的クライアント登録のレスポンスに client_secret が含まれていたら、クライアント更新リクエストに client_secret を含めなければならない」というのが彼の仕様解釈でした。

このとき私は、英語ネイティブスピーカーの専門家間でも英文仕様の解釈に違いが生じてしまうという状況を目の当たりにしたのでした。私は元から「実装する際は仕様書原文を参照すべき」という考えですが、改めて「(英語原文を直接参照しても解釈違いが起きうるのに) 日本語訳を見ながら実装するなどもってのほか」と思いました。

結局、仕様策定時の昔話を仕様策定者から聞きながら、彼の意図を尊重して Authlete の実装を変更することにしました。すなわち、「クライアント更新リクエストに client_secret は含まれていなくてもよいが、含まれている場合は現在のクライアントシークレットの値と一致するか確認する」という動作に変更しました。

自然言語で仕様を議論していても埒が明かないときがあります。某所で uniqueness、multiple use、one-time use の定義に関する議論が堂々巡りしていたので仮想コードを書いてみせたところ、その不毛な議論は止まりました。コードしか勝たん。

FAPI 1.0最終版によるリクエストオブジェクト生存期間制限追加でリクエストが全部失敗してしまう

FAPI 1.0 Part 2 が Implementer's Draft 2 (実装者向け草稿第二版) から Final (最終版) になる際、リクエストオブジェクトの生存期間に関する要求事項が追加されました。

その要求事項は、「リクエストオブジェクトに含まれる exp クレーム (RFC 7519, Section 4.1.4. "exp" (Expiration Time) Claim) の値から nbf クレーム (RFC 7519, Section 4.1.5. "nbf" (Not Before) Claim の値を引いた値が 60 秒を超えてはならない」というものでした。

この要求事項をサポートするため、認可サーバの実装ではリクエストオブジェクトから nbf クレームの値を取り出さなければなりません。しかしそれまで、リクエストオブジェクトにわざわざ nbf クレームを埋め込むクライアントアプリケーションはほぼありませんでした。コンフォーマンススイートのテスト群でさえ nbf クレームを埋め込んでいませんでした。

この状況でリクエストオブジェクトの生存期間をチェックするようにサーバ側の実装を変更してしまうと、その瞬間から世の中にデプロイされているほぼ全てのクライアントアプリケーションおよびコンフォーマンススイートのテスト群がエラーを起こしてします。そのため、リクエストオブジェクトの生存期間のチェックを開始するのは、クライアントアプリケーション側・コンフォーマンススイート側の準備が整った後にするべきでした。移行をスムーズに進めるための仕組みが必要でした。

そこで Service.nbfOptional という真偽値フラグを導入し、このフラグが真の場合は、「たとえ FAPI 1.0 Part 2 に従うべき条件下であったとしてもリクエストオブジェクトの生存期間をチェックしない (nbf クレームは必須ではなくオプショナルという扱いにする)」という動作をするようにしました。Authlete の Web コンソールでは「nbf クレーム」がこのフラグに対応します。

Service_nbfOptional.png

動的クライアント登録リクエストに登録済みのsoftware_idが含まれている場合はリクエストを拒否したい

OIDC DynReg にはありませんが、RFC 7591: OAuth 2.0 Dynamic Client Registration Protocol では software_id というクライアントメタデータが次のように定義されています。

software_id

A unique identifier string (e.g., a Universally Unique Identifier (UUID)) assigned by the client developer or software publisher used by registration endpoints to identify the client software to be dynamically registered. Unlike "client_id", which is issued by the authorization server and SHOULD vary between instances, the "software_id" SHOULD remain the same for all instances of the client software. The "software_id" SHOULD remain the same across multiple updates or versions of the same piece of software. The value of this field is not intended to be human readable and is usually opaque to the client and authorization server.

この定義からは、「サーバは、登録済みの software_id を含む動的クライアント登録リクエストを拒否すべき」とは読み取れません。むしろ重複することを想定している節さえあります。

しかし、ブラジルの API エコシステムのローカルルールでは「登録済みの software_id を含む動的クライアント登録リクエストを拒否すべき」と定められています。

この要件が追加される前からブラジルに複数の顧客がいたため、Authlete はこの要求事項に対応する必要がありました。しかしこれはローカルルールなので Authlete の基本動作とすべきではありませんでした。そこで Service.dcrDuplicateSoftwareIdBlocked という真偽値フラグを作成し、このフラグが真の場合のみ「登録済みの software_id を含む動的クライアント登録リクエストを拒否する」という動作をすることにしました。ブラジルの顧客にはこのフラグを真に設定するようにお願いしています。Authlete の Web コンソールでは「重複するソフトウェアIDを持つDCR」という項目がこのフラグに対応します。

Service_dcrDuplicateSoftwareIdBlocked.png

リクエストオブジェクト暗号化に関するローカルルールに汎用的に対応したい

ブラジルの API エコシステムにはリクエストオブジェクト暗号化に関する次のローカルルールが存在します。

  • フロントチャネルでリクエストオブジェクトを送信する場合は暗号化しなければならない
  • 暗号化されたリクエストオブジェクトの alg で指定されるアルゴリズムは特定の値でなければならない
  • 暗号化されたリクエストオブジェクトの enc で指定されるアルゴリズムは特定の値でなければならない

Authlete のソースコードをブラジル用に分岐させることなくこれらのローカルルール群をサポートするため、次の真偽値フラグ群を新たに作成しました。

  • Service.frontChannelRequestObjectEncryptionRequired
  • Service.requestObjectEncryptionAlgMatchRequired
  • Service.requestObjectEncryptionEncMatchRequired

Authlete の Web コンソールでは、「フロントチャネルにおける暗号化」、「暗号化アルゴリズムマッチ」、「暗号化エンコーディングアルゴリズムマッチ」、がそれぞれのフラグに対応します。

Service_requestObjectEncryption.png

OIDC DynReg は、request_object_encryption_algrequest_object_encryption_enc というクライアントメタデータを定義しています。これらは、クライアントがリクエストオブジェクトを暗号化する際に algenc に指定する予定のアルゴリズムを宣言するためのものです。しかしその定義によれば、クライアントは、これらのクライアントメタデータに指定されているアルゴリズム以外のアルゴリズムを使ってもよいとされています。正直、これらのクライアントメタデータの存在意義が分かりません。

このように本来の存在意義が不明ということもあり、暗号アルゴリズム固定化の実装ではこれらのメタデータを流用することにしました。

「暗号化アルゴリズムマッチ」と「暗号化エンコーディングアルゴリズムマッチ」が有効になっている場合、Authlete は、暗号化されたリクエストオブジェクトの algenc がこれらのクライアントメタデータで指定されている値と一致するかどうかを確認します。

リフレッシュトークンフローでIDトークンを再発行したいと言われる日がついに来た

OIDC Core の Section 12.2. Successful Refresh Response の記述を読む限り、リフレッシュトークンフローでの ID トークン再発行は任意機能です。処理が厄介なこともあり、Authlete は ID トークン再発行処理を実装していませんでした。

しかし、ついに「リフレッシュトークンフローで ID トークンを再発行したい」と言うお客様が現れました。そこで重い腰を上げて ID トークン再発行機能を実装することにしました。

Authlete 特有のアーキテクチャにより、Authlete はユーザ情報を自身のデータベース内に保持していません。そのため、ID トークン生成時には、ID トークンに埋め込むユーザ情報を外部から提供してもらう、すなわち Authlete API コールに含めてもらう必要があります。もしもリフレッシュトークンフローで ID トークンを再発行するとなると、それまで API コール一回で済んでいたところ (Authlete の /auth/token API を一回呼ぶだけでリフレッシュトークンフローの処理が終わっていたところ) を、ユーザ情報を Authlete に提供するため、トークンエンドポイントの実装はもう一度 Authlete API を呼ばなければなりません。

とはいえ、実はこのような二回に分けた処理は初めてではなく、既に ROPC フローのサポートでおこなっていました。ROPC フローの場合、Authlete の /auth/token API の JSON レスポンスは "action": "PASSWORD" を含みます。それにより、トークンエンドポイントの実装はトークンリクエストが ROPC フローであると認識し、手元でユーザ認証をおこない (= トークンリクエストに含まれる usernamepassword でユーザ認証をおこない)、その結果に応じて /auth/token/issue API または /auth/token/fail API を呼びます。これと同様のことを ID トークン再発行時におこなえばよいのです。

そこでまず、ID トークン再発行のための /idtoken/reissue API を実装しました。それから、/auth/token API が返す action の値として新たに ID_TOKEN_REISSUABLE を定義しました。

さて、ID トークン再発行機能の実装ができたからといって、ID トークン再発行が可能なときに /auth/token API が "action": "ID_TOKEN_REISSUABLE" を無条件で返してしまうと、『クライアント設定エンドポイントで401 Unauthorizedを返すべきケースがあることを見落としていた』で説明したのと同じ問題が発生してしまいます。

つまり、トークンエンドポイントの実装が次のようになっているところに、

// Dispatch according to the action.
switch (action)
{
    case INVALID_CLIENT:
        // 401 Unauthorized
        return ResponseUtil.unauthorized(content, CHALLENGE, headers);

    case INTERNAL_SERVER_ERROR:
        // 500 Internal Server Error
        return ResponseUtil.internalServerError(content, headers);

    case BAD_REQUEST:
        // 400 Bad Request
        return ResponseUtil.badRequest(content, headers);

    case PASSWORD:
        // Process the token request whose flow is "Resource Owner Password Credentials".
        return handlePassword(response, headers);

    case OK:
        // 200 OK
        return ResponseUtil.ok(content, headers);

    case TOKEN_EXCHANGE:
        // Process the token exchange request (RFC 8693)
        return handleTokenExchange(response, headers);

    case JWT_BEARER:
        // Process the token request which uses the grant type
        // urn:ietf:params:oauth:grant-type:jwt-bearer (RFC 7523).
        return handleJwtBearer(response, headers);

    default:
        // This never happens.
        throw getApiCaller().unknownAction("/api/auth/token", action);
}

action の値として ID_TOKEN_REISSUABLE を渡してしまうと、トークンエンドポイントの実装はエラーを起こしてしまいます。

Authlete が ID_TOKEN_REISSUABLE を返すのは、トークンエンドポイントの実装が ID_TOKEN_REISSUABLE を処理する準備ができている場合に限るべきです。具体的には、次のような caseswitch に追加する作業が終わった後にすべきです。

    case ID_TOKEN_REISSUABLE:
        // The flow of the token request is the refresh token flow
        // and an ID token can be reissued.
        return handleIdTokenReissuable(response, headers);

そこで、/auth/token API が ID_TOKEN_REISSUABLE を返すかどうかを制御するための真偽値フラグとして Service.idTokenReissuable を導入しました。このフラグが真の場合、次の条件が全て満たされたときに /auth/token API は "action": "ID_TOKEN_REISSUABLE" を返します。

  1. リフレッシュトークンフローである
  2. トークンリクエスト処理後のスコープに openid が含まれる (注: スコープがナローダウンされるケースがある)
  3. アクセストークンにユーザが紐付いている (注: クライアントクレデンシャルズフローだとアクセストークンにユーザは紐付かない)
  4. アクセストークンにクライアントアプリケーションが紐付いている (注: OID4VCI の事前認可コードフローの特殊ケースで、クライアントアプリケーションに紐付かないアクセストークンが発行されうる)

Authlete の Web コンソールでは「再発行機能を有効にする」という設定項目がこのフラグに対応します。

Service_idTokenReissuable.png

Authlete APIをAPIキー・APIシークレットではなくアクセストークンで保護したい

Authlete は OAuth 2.0 のサーバ側実装を提供しますが、バージョン 2.3 以前の Authlete では、Authlete API 自身はアクセストークンではなく API キー・API シークレットの組で保護されています。これは、Authlete の初期設計時 (2014 年) に参考にした他の Web API に倣ったためです。

しかし、API キー・API シークレットによる保護はアクセストークンに比べて柔軟性に欠けます。また、API 利用者毎に API キー・API シークレットを発行する仕組みになっていないので、複数人で同じ API キー・API シークレットを共有しなければならないという問題もあります。

加えて、バージョン 2.3 以前の Authlete には、Web コンソールが二つ存在しており、分かりにくいという問題があります。また、Web コンソールにログインするときに使うログイン ID とパスワードもまた、API キー・API シークレットの問題と同様、複数の管理者で共有しなければならないという問題もあります。

これらの問題を一気に解決するため、Authlete 社は Authlete 3.0 の開発に取り掛かりました。しかし、Authlete 3.0 の開発は予想以上に長い道のりとなりました。記録によると、Authlete 3.0 の開発ブランチで API コールの方法をアクセストークンベースに変更したのは、2021 年 10 月 25 日となっています。実に 3 年も前の話です。

しかしながら、ついに、2024 年 11 月に Authlete 3.0 をリリースすることができました!(発表文書)

Authlete API および Web コンソールへのアクセスにはアクセストークンが使われます。アクセストークンの細かい権限制御は、スコープではなく RAR (RFC 9396: OAuth 2.0 Rich Authorization Requests) で記述されています。アクセストークンの権限によって Web コンソールは表示内容を切り替えるので、Web コンソールを一つに統一することもできました。また、外部 IdP を用いたログインもサポートしたので、アカウント管理も柔軟になりました。UI/UX の専門家もチームに迎え入れ、Web コンソールの見栄えも大変良くなりました。

おわりに

Authlete_History.png

2014 年に RFC 6749、OIDC Core という基本的な仕様から始め、次々と新しい仕様を実装していきました。

2018 年夏には FAPI 1.0 をサポートする Authlete 2.0 をリリースしました。とある米国大手ベンダーが FAPI サポートをアナウンスしたのは 2023 年なので、実に 5 年も先行していたことになります。

2018 年末には CIBA を実装し終えました。この頃、自分たちが「新しい仕様を世界で最も早くするベンダー」になっていることに気が付きました。

2024 年 11 月には Authlete 3.0 をリリースしました。Authlete 3.0 には、標準仕様の実装はもちろんのこと、この 10 年間の商用運用で得られたノウハウも蓄積しています。

ぜひ Authlete 3.0 をお試しください! → https://console.authlete.com/

お問い合わせはこちら! → https://www.authlete.com/ja/contact/

12
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?