OAuth
openid_connect
Authlete

OAuth 2.0 / OIDC 実装の新アーキテクチャー

はじめに

OAuth 2.0、OpenID Connect、UMA、など、数多くの標準仕様の作成に携わり、また『OAuth 2 in Action』の著者としても知られる Justin Richer 氏が『Deployment and Hosting Patterns in OAuth』という記事を 2017 年 7 月 27 日に公開しました。

deployment-and-hosting-patterns-in-oauth.png

Richer 氏が当該記事中で『Semi-Hosted Service』パターンと分類した、認可サーバー兼 OpenID プロバイダー実装の新しいアーキテクチャーを本記事で解説します。

注:余談も多いので一つの読み物としてお楽しみください。

1. 4 つの OAuth 配備パターン

Deployment and Hosting Patterns in OAuth』の中で Richer 氏は、OAuth の配備パターンとして次の 4 つを示し、それぞれの良い点・悪い点を挙げています。

パターン
1 On-Premises
2 Hosted Server
3 Hosted Service
4 Semi-Hosted Service

1.『On-Premises』パターンは、自分でマシンを用意し、そこに認可サーバーのソフトウェアをインストールして運用する方法です。

2.『Hosted Server』パターンは、マシンの物理的な運用はクラウドサービスにまかせ、そのマシン上に認可サーバーのソフトウェアをインストールして運用する方法です。 例えば AWS の EC2 インスタンスに OpenAM をインストールして運用する場合、このパターンに該当します。

3.『Hosted Service』パターンは、認可サーバー自体がクラウドサービスとして提供されるパターンです。 OktaAuth0 がこのパターンに該当します。

4.『Semi-Hosted Service』パターンは、認可サーバーの主要機能を提供するサーバーが存在するものの、一部をローカルで実装するというパターンです。 Richer 氏が明示的に言及しているように、Authlete(オースリート)がこのパターンに該当します。

2. Semi-Hosted Service パターン

Semi-Hosted Service パターンでは、認可サーバーの主要機能を提供するサーバーがバックエンドに存在するものの、それ自体は認可サーバーではないため、別途認可サーバーを立てることになります。 そして、そのフロントの認可サーバーの実装が、バックエンドのサーバーと通信をおこないます。 下図において、Authlete となっている部分が Semi-Hosted Service パターンのバックエンドサーバーです。

semi-hosted-service-pattern.png

この構成の何が良いかというと、バックエンドのサーバーが認可機能に特化できる点です。 アイデンティティー管理、ユーザー認証、ログイン状態管理、API 管理、不正検出機構などから独立し、認可機能に専門特化することができます。 逆に言うと、認可機能以外の技術要素の選択において自由度が増すので、柔軟なシステム設計が可能になります。

3. ユーザー認証と認可の分離

特に Authlete が Semi-Hosted Service パターンという複雑な構成を採用した理由は※1、ユーザー認証と認可を分離するためでした。

※1:私が Authlete の主要部分を実装した当時、既に Semi-Hosted Service パターンという考え方が世の中に存在していたかどうかは知りません。 少なくとも私は一人で悶々と考えてこの設計に辿りつきました。

認可フロー内の一つの処理としてユーザー認証が含まれるため(参考:「認証と認可」)、認可サーバーを実装する際、たいていは両方まとめて実装してしまいます。

認可サーバーの認可エンドポイントが返す認可画面の典型的な構成では、同一画面にユーザー認証と認可が混ざっていますし、

authorization-page.png

アクセストークンも、「どのユーザーが、どのクライアントアプリケーションに、どんな権限を与えたか」という情報を表す物で、認証されたユーザーと切っても切れない関係にあるので、逆に、ユーザー認証と認可を分けて作るのは難しいのです※2

※2:アクセストークンに関しては、厳密に言うと、ユーザー認証と分離するのが厄介ということではなく、ユーザー管理データーベースとアクセストークン管理データベースを分離するのが厄介、という話です。

ほとんどの認可サーバーの実装は、認可画面の実装を用意するなどして、ユーザー認証の枠組みを提供しており、それをある程度カスタマイズできるようにしています。 しかし、この方法ですと、当該実装の想定から大きく外れるユーザー認証方法の採用は難しくなります。 より高いセキュリティーを求めてユーザー認証の方法は年々高度化しているため、想定から外れるのはそれほど珍しいことではなく、特に FIDO を考慮しはじめると、「ID とパスワードを入力してサーバーにログイン」というフローを大前提としている認可サーバーの実装は遅かれ早かれ行き詰ってしまいます。

ユーザー認証方法の多様化への対処方法として、「もっと柔軟にカスタマイズできるようにしよう」という機能拡張方向の努力が当然考えられるわけですが、その一方で「認可機能に特化してユーザー認証に一切関わらないようにしよう」という機能部品化の発想もありえて、Semi-Hosted Service パターンは後者になります。

3.1. ユーザー認証のタイミング

下図は『OAuth 2.0 全フローの図解と動画』から転載した認可コードフローの図です。

RFC6749-4_1-authorization_code_flow-Japanese.png

認可サーバーがクライアントアプリケーションから認可リクエストを受け(図中の②)、認可コードを発行する(図中の⑥)までの間に、ユーザーとのインタラクションがあります。 このインタラクションによりユーザー認証が行われます。 ただし、認可リクエストを受け付ける前に既にユーザー認証が済んでいる場合、このインタラクションでのユーザー認証は省略されることもよくあります。

いずれにしても、ポイントは、どのタイミングでもいいので認可コードを発行する前までにユーザー認証を済ませておく、ということです。

では、そもそもなぜ認可コード発行の前にユーザー認証を済ませておく必要があるのでしょうか。 それは、認可コードが「どのユーザーが、どのクライアントアプリケーションに、どんな権限を与えたか」という情報を表すものだからです。 どうしても、認可コードを生成する際、ユーザーを特定しておかなければなりません。

3.2. ユーザー特定

突き詰めると、認可コードを生成する際、ユーザーを特定できている必要はあるものの、どのように特定したかは気にする必要はありません。

突っ込まれそうなので早々に言及しますが、もちろん、どのような方法でどれだけセキュアにユーザー認証を行なったか、ということを気にする文脈もありまして、OpenID Connect Core 1.0 でも関連するリクエストパラメーターとして acr_values などが存在します(参考:「16. 認証コンテキストクラス」)。 また、Financial API のセキュリティー要件においても、X.1254(Entity authentication assurance framework)で定義されている LoA(Level of Assurance)に明示的に言及しています(参考:『Financial API 実装の技術課題』)。 しかしながら、認可コードを管理するデータベーステーブルにおいては、認証方法に関する情報を保持する必要はなく、結果的に、認可コードを生成するタイミングでは認証方法に関する情報は不要です※3

※3:ただし、アクセストークン発行時のユーザー認証方法を後から参照できるようにする、といったことを実現したい場合は、認可コード生成時にユーザー認証方法に関する情報も必要となります。

まとめますと、認可コード生成時には、ユーザー認証の結果として得られる、ユーザーを特定するための一意識別子が必要です。 一方、ユーザー認証方法に関する情報は必要ありません。

3.3. ユーザー認証と認可の分離方法

これまでの説明を踏まえ、認可機能に特化するバックエンドサーバーは、ユーザー認証と認可を分離するため、どういう設計であるべきでしょうか? Authete のアプローチは、ユーザー認証を挟んで認可処理を前半と後半に分け、その前半と後半のそれぞれに対して API を提供するというものです。

処理 処理内容 Authlete API
1 認可処理
前半
認可リクエストを解析する。 /api/auth/authorization
2 ユーザー
認証
ユーザー一意識別子を得る。 (お客様自身で実装してもらう) 
3 認可処理
後半
認可レスポンスを用意する。 /api/auth/authorization/issue
/api/auth/authorization/fail

この設計を文章だけで説明すると分かりにくいため、図と動画を用意しました。 認可コードフローを Authlete を使って実装すると、下図のような処理フローとなります(同図は『OAuth 2.0 全フローの図解と動画』の末尾にも掲載しています)。

認可コードフロー + Authlete
OAuth-Flows+Authlete-in-Japanese_2_Authorization-Code-Flow.png
動画:Authorization Code Flow + Authlete (in Japanese)

⑪ で実行される『ユーザー認証』処理に Authlete が全く絡んでおらず、そのユーザー認証処理の結果として得られる『ユーザー一意識別子』を ⑫ で /api/auth/authorization/issue API に渡すところがポイントです※4

※4:望むのであれば、ユーザー識別子を加工してから渡すことで、実際の値を Authlete に伝えることを避けることもできます。

Authlete API 群の仕様については Authlete 社ウェブサイト上の API リファレンス(更新遅れ気味)もしくは authlete-java-common ライブラリの JavaDoc(ほぼ最新)を参照していただくとして、このような複雑な設計で何が実現できるかというと、、、

Authlete のような Semi-Hosted Service パターンのバックエンドが登場する以前の認可サーバーでは、認可処理ロジックと関連データベース、および、ユーザー認証処理と関連データベースを、双方とも(直接か間接かはさておき)抱えていました。

Before-Authlete.png

一方、認可機能に特化した Semi-Hosted Service パターンのバックエンドを使用する認可サーバーの実装イメージは下図のようになります。 認可処理ロジックと関連データベースがバックエンド側に移動し、フロントの認可サーバー側にユーザー認証処理が残ることが分かります。

After-Authlete.png

Authlete では上記のようなアーキテクチャーにより、ユーザー認証の実装を認可処理の実装から切り離しています。

4. アイデンティティー管理と認可の分離

アイデンティティー管理は比較的昔からソリューションが存在する分野で、特にクラウドベースのものは IDaaS(IDentity as a Service)(アイダース)と呼ばれ、Azure Active DirectoryOneLogin などのソリューションが存在します。

アイデンティティー管理の文脈における認可と OAuth の文脈における認可は別物です。 前者は「誰が何の権限を持っているか」という概念を扱っており、後者は「誰が誰に何の権限を与えるか」という概念を扱っています。 詳細は『【第二弾】OAuth 2.0 + OpenID Connect のフルスクラッチ実装者が知見を語る』で説明しています。

しかしながら、アイデンティティー管理ソリューションが OAuth の文脈の認可もサポートしていることがあり、混乱することがあります。 ここでは、アイデンティティー管理ソリューションと OAuth の文脈の認可の実装の分離について考えてみます。

4.1. ユーザーデータベース共有

音楽関連の会員制サービスを提供するシステムを考えてみます。 ユーザー管理と認可が密結合している認可サーバーを用いてこのサービスの API を構築すると、システム構成の概略は下図のようになります。 認可サーバーがユーザーデータベースと認可データベースを抱える形となります。

One-API-Server_One-Authorization-Server.png
一つのサービスに一つの認可サーバー

このサービスを提供する企業が事業多角化により、ヘルスケアや旅行に関するサービスも始め、それぞれ API を提供することになったとします。 既存システムの延長線上に API を構築するのであれば、認可サーバーを共用し、ヘルスケアサービスの API サーバーと旅行サービスの API サーバーを追加することになります。

Multiple-API-Servers_One-Authorization-Server.png
複数のサービスで一つの認可サーバーを共用

認可サーバーを共用するということは、各サービスのスコープ群(権限群)やクライアントアプリケーション群を一箇所で管理するということです。 例えば、プレイリストを作る権限(音楽)、体重の記録を参照する権限(ヘルスケア)、宿を予約する権限(旅行)、などを全て一箇所で管理することになります。

各サービスの開発チーム、リリーススケジュール、API 公開範囲、クライアントアプリケーション等が異なるというのはよくあることなので、可能であれば認可サーバーはサービス毎に立てたいところです。 一方で、ユーザープールは共有したい(音楽サービスの登録ユーザーをそのまま新規サービスへと誘導したい)という要望は当然あります。 このとき、認可サーバーの実装とユーザー管理が密結合していると、サービス毎に認可サーバーを立てるという選択は難しくなってしまいます。

しかし、もしもユーザー管理と密結合しない認可サーバーの実装があったとしたらどうでしょうか? そういうものがあれば、ユーザープールを共有しつつ、サービス毎に認可サーバーを用意することができます。

One-Authorization-Server_per_One-API-Server.png
サービス毎に認可サーバーを用意しつつユーザープールを共有

ユーザー管理と認可を明確に分離する Semi-Hosted Service パターンの実装であれば、このようなシステム構成を実現することができます。

一つ心配なのは、認可サーバーを一つ開発するのに工数がかかり過ぎるようだと、このシステム構成を採用することが難しくなることです。 しかし、Authlete のように初めから複数の認可サーバーを立てることを想定して設計されている実装であれば、認可サーバーに対応するインスタンスの生成・削除が簡単にできるようになっているはずなので、複数認可サーバー構成のシステムの開発工数を抑えることができます。

下図は、Authlete の管理コンソールにログインしてから新しいインスタンスを立てるための手順を示しています。 数回ボタンをクリックすると新しいインスタンスが立ち上がります。

Multiple-Service-Instances.png

4.2. ID トークンに埋め込むユーザー属性情報

OpenID Connect』をサポートし、OpenID プロバイダーを兼務する認可サーバーは『ID トークン』を発行します。 この ID トークンには、ユーザー一意識別子(sub クレーム)に加え、任意ではありますが、氏名等のユーザー属性情報が含まれることがあります。

下記は『OpenID Connect Core 1.0』の「A.2. Example using response_type=id_token※5」に挙げられている ID トークンのペイロードの例で、namegiven_name などがユーザー属性情報にあたります。

{
 "iss": "http://server.example.com",
 "sub": "248289761001",
 "aud": "s6BhdRkqt3",
 "nonce": "n-0S6_WzA2Mj",
 "exp": 1311281970,
 "iat": 1311280970,
 "name": "Jane Doe",
 "given_name": "Jane",
 "family_name": "Doe",
 "gender": "female",
 "birthdate": "0000-10-31",
 "email": "janedoe@example.com",
 "picture": "http://example.com/janedoe/me.jpg"
}

※5:iss の値が https: で始まっていない件については「iss in examples should start with https」で報告済み。

ID トークンにユーザー属性情報を埋め込むということは、ID トークン生成時にユーザー属性情報を取得する必要があるということなので、常識的な考え方で実装すると、OpenID プロバイダーの実装がユーザーデータベースを抱え込むことになります。

しかしながら、Semi-Hosted Service パターンのアプローチを採用すると(及び慎重に設計・実装すると)、OpenID Connect をサポートして ID トークンを生成するにもかかわらず、バックエンドサーバーはユーザーデータベースを抱え込む必要がなくなります。

基本的な仕組みは、「ID トークン生成を伴うバックエンドサーバーの API を呼ぶ際、ID トークンに埋め込みたいユーザー属性情報をリクエストパラメーターとして API に渡す」というものです。 例えば Authlete であれば、/api/auth/authorization/issue API を呼ぶ際に claims リクエストパラメーターを使って情報を渡すことにより、ID トークンにユーザー属性情報を埋め込むことができます。

この仕組みに懸念点があるとすれば、ID トークン生成時のみとはいえ、ユーザー属性情報をバックエンドサーバーに渡すことになるという点です。 もし、第三者により運用されているバックエンドサーバーに寸分たりともユーザー属性情報を渡したくない、ということであれば、バックエンドサーバー自体を自分の管理下に置く必要があるので、その際はバックエンドサーバー提供者と交渉することになります。 なお、Authlete の場合、共用クラウドサーバーと専用クラウドサーバーに加えて、オンプレミス版という提供形態もあるので、バックエンドサーバーを完全に管理下におくことが可能です(代わりにバックエンドサーバーの運用は自分でおこなう必要があります)。

4.2.1. Authlete 特化の細かい話

暗号化されて保存されるとはいえ、一時的にユーザー属性情報が Authlete データベース内に格納されるケースがあります。 それは、認可コードフローを用いた認可リクエストの scope リクエストパラメーターに openid が含まれており、それに対応する /api/auth/authorization/issue API を呼び出す際に claims リクエストパラメーターにユーザー属性情報を含めた場合です。 現在の Authlete は、後でトークンエンドポイントから発行する ID トークンを /api/auth/authorization/issue API 呼び出し時に生成しておくという実装になっているため、認可コード発行後にトークンエンドポイントから ID トークンを発行するまでの短い間、認可コードに紐づく形でユーザー属性情報が暗号化された上で Authlete のデータベース内に保存されます。

このユースケース(scopeopenid を含む認可コードフロー)では、認可エンドポイントから認可コードを受け取ったクライアントアプリケーションは、その認可コードを伴ってすぐさまトークンエンドポイントにトークンリクエストを投げ、トークンエンドポイントからの応答としてアクセストークンと ID トークンを受け取ります。 通常、この処理は非常に短い時間内に行われるので、結果として、暗号化されたユーザー属性情報が Authlete のデータベース内に保存されている時間も非常に短いです。

ただし、何らかの理由でクライアントアプリケーションが認可コード受領後にトークンリクエストをおこなわない場合、Authlete のデータベース内に暗号化されたユーザー属性情報が残ることになります。 仕様上、認可コードの最大有効期間の推奨値が 10 分とされており、10 分を過ぎると認可コード自体が無効になるので、Authlete サーバー内で実行されるクリーンアップ処理により有効期限切れの認可コードは削除され、それに伴い暗号化されたユーザー属性情報も削除されますが、それでもクリーンアップ処理が走るまでの間は、暗号化されたユーザー属性情報が Authlete データベース内に残ります。

一時的だとしても Authlete データベース内に暗号化されたユーザー属性情報を格納したくないけれども、それでも ID トークンを生成したい場合、やり方は二つあります。

一つは、認可エンドポイントから直接 ID トークンの発行を受ける方法です。 認可リクエストの response_typeid_token とし(もしくは token id_token とし)、scopeopenid を含めれば、認可エンドポイントから ID トークンが発行され、トークンエンドポイントからは発行されません。 そもそも認可リクエストの response_typecode が含まれない場合、トークンエンドポイントは利用されません。

もう一つの方法は、認可コードフロー時のトークンエンドポイントから返す ID トークンにはユーザー属性情報を含ませず、代わりに、ユーザー情報エンドポイントからの応答にユーザー属性情報を含ませる方法です。

ユーザー情報エンドポイントは、OpenID Connect Core 1.0 の「5.3. UserInfo Endpoint」で定義されています。 このエンドポイントに、少なくとも openid をスコープとして含むアクセストークンを提示することにより、ユーザー情報を受け取ることができます。 返されるデータの形式は、クライアントアプリケーションの設定によりますが、素の JSON もしくは JWT になります。 このユーザー情報エンドポイントを実装するために、Authlete は /api/auth/userinfo API と /api/auth/userinfo/issue API を提供しています。 /api/auth/userinfo/issue API には/api/auth/authorization/issue API と同様の claims リクエストパラメーターがあり、このパラメーター経由でユーザー属性情報を Authlete に渡すことになりますが、/api/auth/userinfo/issue API の実装ではユーザー属性情報を Authlete 内のデータベースに格納しません。

暗号化されるのだとしてもユーザー属性情報を一切 Authlete のデータベースに保存させたくない場合、上記の通り回避方法はあります。 ただ、/api/auth/userinfo/issue API にユーザー属性情報を渡すことすらためらわれる、という場合、オンプレミス版の Authlete をご検討ください。

5. API 管理と認可の分離

API エコノミーの拡大に伴い、API 管理分野に大手企業も多く参入しています。 例えば次のようなものがあります。

API 管理ソリューションが OAuth の機能を併せ持つことはよくある設計で、それで事足りることもあります。 一方で、API 管理ソリューションに OAuth 機能が結びついていると都合が悪いユースケースも存在します。 複数の API 管理ソリューションを並行で使用したり、単一 API 管理ソリューションだとしてもそれらを複数並べて使う場合などです。 このようなユースケースに対応するためには、API 管理ソリューションと OAuth の機能が分離しているほうが好ましいと言えます。

しかしながら、上記ユースケースの話よりも現場で課題となっているのは、API 管理ソリューションに付属する OAuth 機能が十分でなかったり、融通がきかなったりすることです。 次のようなことが課題となっています。

  1. RFC 6749RFC 6750 のみであればサポートするのは比較的容易だが、重要な関連仕様が追加され続けており、それらに追従できない。

  2. OAuth / OIDC をしっかりサポートしようとすると、認可サーバー / OpenID プロバイダーの設定やクライアントアプリケーションとその開発者を管理するためのしっかりした仕組みが必要だが、それらは API 管理の範疇を大きく超える。

  3. API 管理を中心に OAuth やアイデンティティー管理、ユーザー認証を統合すればするほど、逆にユースケースに融通が効かなくなってくる。

大手企業の API 管理ソリューションに付属する OAuth 機能の使用を検討したものの、ユースケースがカバーできないことが判明したため、当該 API 管理ソリューションを使いつつも OAuth 部分だけは Authlete を使う、という事例が実際に存在します。

5.1. アクセストークン検証の委譲

OAuth で保護された API にアクセスするとき、クライアントアプリケーションは API にアクセストークンを提示します。 API 側は、そのアクセストークンが有効かどうかをチェックします。 このチェックをどのような方法でおこなうかは、各 API 管理ソリューションによります。

Amazon API Gateway は、OAuth / OIDC の複雑な仕組みを自ら抱え込むことはせず、アクセストークンの検証を Custom Authorizer という仕組みを使って外部に委譲することにしています。 これは、API 管理と OAuth の認可の仕組みを分けている例となります。 詳細は『Amazon API Gateway の Custom Authorizer を使い、OAuth アクセストークンで API を保護する』を参照してください。

IBM API Connect は、自ら OAuth の仕組みを持ちつつも、アクセストークンの検証を委譲する仕組みを持っています。 外部の認可サーバーが RFC 7662(OAuth 2.0 Token Introspection)をサポートしていれば、そのイントロスペクションエンドポイントにアクセストークンの検証を委譲します。 これについては『IBM 製品ベースの銀行 API に関する考察』でも言及しています。

5.1.1. Authlete のイントロスペクションエンドポイント

余談ですが、Authlete にはイントロスペクション関連の API が三つあります。

パス 説明
1 /api/auth/introspection Authlete 独自のイントロスペクション API
2 /api/auth/introspection/standard RFC 7662 を実装するための API
3 /api/auth/introspection/standard/direct RFC 7662 そのもの

Authlete が独自のイントロスペクション API を提供している理由は、『【第二弾】OAuth 2.0 + OpenID Connect のフルスクラッチ実装者が知見を語る』の「2.6. 情報取得 + バリデーション」で述べているとおり、開発者にとっては独自 API の方が標準 API よりもはるかに便利だからです。

tweet-about-authlete-introspection.png

イントロスペクションエンドポイントは、認可エンドポイントやトークンエンドポイントとは異なり、その利用がサーバー側システム内(認可サーバーとリソースサーバー間)で閉じているということもあり、比較的独自仕様を採用しやすい部分でもあります。

一方で、前述の IBM API Connect の事例のように、標準 API が要求されるケースもあるため、最近になって Authlete でも RFC 7662 をサポートすることにしました。

分かりにくいですが、/api/auth/introspection/standard API のほうは、RFC 7662 を実装するのを手助けするための API であり、RFC 7662 そのものではありません。 この API は、java-oauth-server/api/introspection の実装(IntrospectionEndpoint.java)で利用されています。

一方で、/api/auth/introspection/standard/direct API は、RFC 7662 そのものです。 この API は、サービスの API キーと API シークレットによる Basic 認証で保護されています。 このように、Authlete サーバーが直接仕様をサポートしているエンドポイントを、我々は「ダイレクトエンドポイント」と呼んでいます。 ダイレクトエンドポイントのアーキテクチャーは、Semi-Hosted Service パターンではなく、Hosted Service パターンです。

Hosted Service パターンは、エンドポイント実装の手間が省けるのでその点は楽になるのですが、自由度がかなり下がってしまうのが難点です。 イントロスペクションエンドポイントのダイレクト版で言えば、(1)「API の URL がサービス提供社のもの(この場合 Authlete 社の URL)になってしまう」という Hosted Service パターン共通の問題のほか、(2)エンドポイントの保護の方法が決まっている(この場合「Authlete がサービス毎に割り当てた API キーと API シークレットによる Basic 認証」という方法になる)、という問題があります。 また、別の例で言うと、認可エンドポイントのダイレクト版である /api/auth/authorization/direct/{サービスAPIキー} は、認可画面を提供することにより開発者の負担を減らすことができるものの、(1)認可画面をカスタマイズできない、(2)ユーザー認証の方法が「ID とパスワード」という方法に限られる(及び開発者は認証コールバックエンドポイントを実装する必要がある)、という問題があります。

なお、稀に「ダイレクト版認可エンドポイントが表示する認可画面をカスタマイズするにはどうすればよいか?」という質問を受けることがありますが、「その場合はダイレクト版エンドポイントを利用するのではなく、Semi-Hosted Service パターンに基づく /api/auth/authorization API を利用してください」というのが回答となります。 カスタマイズ機能強化という方向に進まざるをえない Hosted Service パターンとは異なり、制限無しにカスタマイズできるのが Semi-Hosted パターンの良い点であり、すなわちそれが Authlete の良い点なのです。

5.1.2. RFC 7662 をサポートしていなかった理由

ところで、Authlete が RFC 7662 をなかなかサポートしなかったのには理由があります。 仕様書で納得できない部分があったからです。 下記は、当該仕様書の作成者本人である Justin Richer 氏に対して直接 Slack で投げたメッセージです。 要約すると、「アクセストークンの情報を取得する API を叩くために、当該アクセストークンに紐づくクライアントアプリケーションのクライアント ID とクライアントシークレットを API コールに先立って特定する必要があるというのはおかしくないか?」という話です。

RFC 7662 (OAuth 2.0 Token Introspection) (written by @justin), 2.1. Introspection Request:
https://tools.ietf.org/html/rfc7662#section-2.1

To prevent token scanning attacks, the endpoint MUST also require some form of authorization to access this endpoint, such as client authentication as described in OAuth 2.0 [RFC6749] or a separate OAuth 2.0 access token such as the bearer token described in OAuth 2.0 Bearer Token Usage [RFC6750]. The methods of managing and validating these authentication credentials are out of scope of this specification.

The examples in the paragraph excerpted above give an impression that expected API callers are legitimate client applications (not resource servers).

And, the following example and explanation:

The following is a non-normative example request:

POST /introspect HTTP/1.1
Host: server.example.com
Accept: application/json
Content-Type: application/x-www-form-urlencoded
Authorization: Bearer 23410913-abewfq.123483

token=2YotnFZFEjr1zCsicMWpAA

In this example, the protected resource uses a client identifier and client secret to authenticate itself to the introspection endpoint. The protected resource also sends a token type hint indicating that it is inquiring about an access token.

explicitly says that a pair of client identifier and client secret is used to call the introspection API.

For a resource server to use a pair of client identifier and client secret to call the introspection API, the resource server must know the client identifier and client secret which are associated with the access token before calling the introspection API. How can the resource server know the client credentials?

Because the main user of the introspection API is resource servers, in my opinion, the introspection API should be protected by a pair of API key and API secret issued to each resource server by the authorization server.

Well, this is why I've hesitated to implement RFC 7662 in Authlete. However, now, I want to implement RFC 7662 at (以下省略)

彼と話して分かったことは、仕様書内の client というのは、client 的なもののことを指す抽象的な概念であって、認可リクエストを投げるクライアントそのものではない、ということでした。 ・・・いやぁ、それにしたって余りに誤解を招く表現だよね・・・こんなん仕様作成者本人に尋ねないと分からんよね・・・

いずれにしても、結論としては、「イントロスペクションエンドポイントの保護は仕様上 MUST としているが、その方法は自由」ということです。 そういうわけで、Authlete では、(1)/api/auth/introspection/standard API はサービスの API キーと API シークレットによる Basic 認証で保護するが、(2)RFC 7662 のイントロスペクションエンドポイントをどのように保護するかは実装者の自由であり、参考実装の java-oauth-server では「Basic 認証で保護しているが ID が nobody 以外は全部 OK」としています(IntrospectionEndpoint.java を参照のこと)。 Semi-Hosted Service パターンでなければこの自由度は得られません。

ちなみに、Authlete がクライアントを新規登録する API(/api/client/create API)を初期段階から持っているにもかかわらず、『OIDC Dynamic Client Registration 1.0』で定義されている標準 API をサポートしていないのも、当該 API の保護方法について方針が定まっていないというのが主な理由です。 この影響で OpenID Certification の Dynamic Profile を取得していません。

ついでにもう一つ関連情報を載せておきます。 RFC 7662 の実装にあたり、仕様書を読んでも明確には書かれていなかったので、「token リクエストパラメーターがなかった場合にどういう応答をすべきか?」という質問を Richer 氏に投げました。

@justin When the token request parameter is missing, what status code should be returned from the introspection endpoint? 400 Bad Request or 200 OK with {"active":false}?

彼の回答はこちらで、400 Bad Request を返すべき、ということでした。

bad request
active:false means the request was good but the answer is that the token isn’t there
If you’re missing the input parameter, then it’s a bad request

なお、このやりとりで、MITREid Connect の実装がそうなっていないことに彼は気が付いたようです :smiley: (Richer 氏は MITREid Connect の主要開発者でもあります)

Though it looks like I don’t follow my own advice in MITREid Connect and I need to fix that :smiley:

6. ログイン状態管理と認可の分離

ID トークンを発行する際、OpenID プロバイダーはユーザー認証をおこないますが、既にユーザーがログイン済みであれば、たいていの OpenID プロバイダーの実装はユーザー認証処理をスキップします※6。 ただし、ユーザーがログイン済みかどうかをどのように判定するかは、実装依存です。

※6:prompt リクエストパラメーターを使って再認証を要求することはできます。

Semi-Hosted Service パターンの場合、ログイン状態の管理はフロントの OpenID プロバイダーが担当し、バックエンドサーバーは直接関与はしません。 ログイン状態管理と OAuth / OIDC の実装が分離されているので、開発者はログイン状態を管理する方法を自由に選択することができます。 例えば Apache Shiro を使うことも可能です。

6.1. java-oauth-server

フロントサーバー側のみでログイン状態管理が可能なことを示す好例は java-oauth-server です。 これは、Java 言語で書かれた認可サーバー兼 OpenID プロバイダーの実装で、バックエンドサーバーとして Authlete を使っています。

この java-oauth-server に対して初めて OpenID Certification テストを実施した際、ログイン状態関連のテストでエラーが出ましたが、java-oauth-server にログイン状態管理の仕組みを実装することにより、当該エラーを解消することができました。 このとき、バックエンドサーバーである Authlete 本体に対する変更は不要でしたが、これは、フロントサーバー側のみでログイン状態管理が完結していることを示しています。

なお、java-oauth-server にログイン状態管理の実装を追加してくれたのは Richer 氏です。 おかげで Authlete は OpenID Certification を取得することができました。

authlete_openid_certification.png
http://www.oixnet.org/openid-certifications/authlete/

6.1.1. Request URI の事前登録

余談ではありますが、Authlete が OpenID Certification を取得するにあたり、一つ引っかかった問題がありました。 Authlete はセキュリティー上の理由で、事前に登録されていない URI を request_uri の値として使うことを拒否する実装になっています。 具体的には、下記のコードにより、request_uri に指定された URI が登録済みでなければ例外を投げるようにしています。

private String extractRequestUri(AuthorizationResponseBuilder builder)
{
    // The value of 'request_uri'.
    String requestUri = extractFromParameters("request_uri", invalid_request, A008302);

    if (requestUri == null)
    {
        return null;
    }

    // OpenID Connect Core 1.0, 6.2. Passing a Request Object by Reference,
    // the 7th paragraph:
    //
    //   Note that Clients MAY pre-register request_uri values using the
    //   request_uris parameter defined in Section 2.1. of the OpenID
    //   Connect Dynamic Client Registration 1.0 [OpenID.Registration]
    //   specification. OPs can require that request_uri values used
    //   be pre-registered with the require_request_uri_registration
    //   discovery parameter.
    //

    // Authlete requires pre-registration of request URIs.

    // Get the list of the registered request URIs.
    String[] registeredUris = getRegisteredRequestUris(builder);

    // Drop the fragment component before comparison.
    String requestUriWithoutFragment = Utils.dropFragment(requestUri);

    // For each registered URI.
    for (String registeredUri : registeredUris)
    {
        if (registeredUri.equals(requestUriWithoutFragment))
        {
            // The value of 'request_uri' matches the registered one.
            return requestUri;
        }
    }

    // The value of 'request_uri' is not registered.
    throw toException(invalid_request, A008303);
}

コメント中にも記述がありますが、OpenID Connect の実装は、Request URI の事前登録を要求してもよいことになっていまして、Authlete は事前登録を要求する実装となっています。 OpenID Connect Discovery 1.0 の実装である Authlete の /api/service/configuration API から返す JSON でも、require_request_uri_registration の値を true とし、「Request URI の事前登録を要求する」旨、明示的に宣言しています。

何が問題だったかというと、OpenID Certification のテストが、ランダムな URI を生成し、その値を事前登録せずに request_uri の値として使用していることでした。 Authlete は事前登録されていない値を明示的に拒否する実装になっているので、テストが失敗します。

この問題を受け、私は、事前登録を要求するかどうかを選択できるようにしようと考えました(Service Owner Console に新しい設定項目を追加しようと考えました)。 しかし、この案に反対したのが Richer 氏でした。 「セキュリティーホールになるからダメだ」と。 とは言ってもですよ、このままでは OpenID Certification テストをパスできないので、私は Richer 氏を説得しようとしばらく議論を続けました。 しかし彼は折れませんでした。

で、結局どうしたかというと、Richer 氏は、テストの方を変更するようにと OpenID Foundation に要求を出しました。

OpenID Certification テストがちょうど更新のタイミングだったこともあり、事前登録していない Request URI を用いるテストは削除されたようで、結果、めでたく Authlete は OpenID Certification テストをパスすることができました。

唖然としましたが、さすが Justin Richer だと思いました。 Authlete チームに加わってもらった後、最初に彼にお願いした仕事が OpenID Certification 取得でしたが、上記の OpenID Foundation とのやりとりも含めて非常に短い期間で OpenID Certification 取得を達成してくれました。

6.1.2. java-oauth-server 関連記事

次の Qiita 記事では java-oauth-server が取り上げられています。

  1. Authlete を使って超高速で OAuth 2.0 & Web API サーバーを立てる
  2. Authleteを使った認可サーバの構築と、OAuthクライアント(Webアプリケーション)からの疎通

7. 拡張性

7.1. 序文 〜B2D SaaS〜

認可サーバーや OpenID プロバイダーは、それに紐付くサービスに合わせてカスタマイズされることがほとんどです。 しかし、カスタマイズすればするほど、他で流用することは難しくなるので、別のサービスを立ち上げるときは、再び同じようなカスタマイズ作業を繰り返すことになります。

一般的に、似たような開発を繰り返すことを避けるために作られるのが『ライブラリ』です。 OAuth / OIDC のライブラリも存在し、Apache Oltu はその一例です(Apache Oltu 自体はお勧めしませんが)。

私も昔、ライブラリを用いて認可サーバーを作ろうとしました。 しかし、良いライブラリを見つけられなかったので、考えた結果、まず、オープンソースでライブラリを自作することにしました。 自作開始後ほどなくして、ライブラリを作ってもとても薄いレイヤーにしかならないだろうことに気が付きました。 仕様に基づいてリクエストパラメーターを解析したりレスポンスを用意したりするライブラリを作ったとしても、そのライブラリを適切に使うことのほうがむしろ大変だと思ったのです。 つまり、薄いライブラリが存在しても認可サーバー開発の労力はほとんど軽減されないと思ったのです。 また、ライブラリというものは永続記憶領域を抱え込まないように設計するのが基本のため、データ表現の抽象化が求められますが、OAuth / OIDC のサーバー側実装で一番頭を悩ますのは、データの型や構造を具体的にどうすべきなのか、という点なのです。

例えば、クライアント ID はどうあるべきか、といったことを具体的に決めることが大変なのです。 「クライアント ID なんて一意であればなんでもいい」程度しか考えていないライブラリもありますが、商用品質を考えるならば、データベースやメモリキャッシュにおける追加・検索・変更・削除・容量の効率※7、推測の難しさ、識別空間の大きさ、JSON 等の外部表現型式と関連する自動データ解析ライブラリとの親和性、等々を考慮にいれるべきなのです。

※7:例えば MySQL + InnoDB ストレージエンジンを永続記憶領域に使うなら、InnoDB のデータレイアウトの実装を考慮して、一番効率の良い ID の振り方を考える。

アクセストークンの設計についても、『OAuth 2.0 + OpenID Connect のフルスクラッチ実装者が知見を語る』の「アクセストークン」や『Full-Scratch Implementor of OAuth and OpenID Connect Talks About Findings』の「7. Access Token」で述べているように、考えるべきことはいろいろあります。 同様のことは、リフレッシュトークンや認可コードについても言えます。

アクセストークン表現方法の比較
access-token-representation.png
Full-Scratch Implementor of OAuth and OpenID Connect Talks About Findings』より転載

このように設計・実装に熟慮を要する部分を抽象化し、その実装をライブラリ利用者に丸投げすることは、設計的には美しいかもしれませんが、ライブラリ利用者からすると苦痛でしかありません。 それではと言って、設計・実装を丸抱えした認可サーバーそのものを提供すると、それはもはやライブラリではなくてアプリケーションであり、再利用性が下がってしまいます。

何とかならないものかと突き詰めて考えていくと、「主要ロジックの実装とデータ永続記憶領域を含んだライブラリ的何か」が存在すべきだという考えに至ります。 そして、それこそが Semi-Hosted Service パターンにおけるバックエンドサーバーなのです。

しかしながら問題は、データ永続記憶領域を持つバックエンドサーバーは運用が必要だということです。 スケーラビリティー、冗長構成、バックアップ、運用管理体制等々について考えなければなりません。 このままでは認可サーバー実装者の負担を大きく削減することはできません。 どうすべきでしょうか? 結論は「バックエンドサーバーを運用する会社が存在すれば良い」となります。 私が Authlete 社を創業した理由はそこにあります。

上記のような思考過程と会社設立を経て、認可サーバーや OpenID プロバイダーそのものではなく、それらを実装するための機能部品をライブラリよりも高度な方法で提供することが可能になりました。 認可サーバーや OpenID プロバイダーそのものを提供する場合、機能追加をすると、『独自仕様』という謗り(そしり)を受けたり、再利用可能性の低下という問題が起こります。 しかし、機能部品であれば、関連仕様に縛られることなく、再利用可能な形で機能追加をしていくことができます。

さて、前置きが大変長くなりましたが、以降、機能部品としての自由度を活かし、バックエンドサーバーが提供する拡張機能について考えてみます。

7.2. アクセストークン生成

ユースケースによっては、標準フロー以外の方法でアクセストークンを生成したい場合があります。 バックエンドサーバーはアクセストークン生成用の API を用意しているかもしれません。

7.2.1. /api/auth/token/create API

Authlete の場合、/api/auth/token/create API がそれにあたります。 この API は、HTTP メソッド は POST、Content-Type は application/x-www-form-urlencoded もしくは application/json で、下記のリクエストパラメーターを受け付けます。 詳細は TokenCreateRequest の JavaDoc に書かれています。

パラメーター名 要否 説明
grantType 必須 アクセストークン生成に際しエミュレートするフロー。 AUTHORIZATION_CODE(認可コードフロー)、IMPLICIT(インプリシットフロー)、PASSWORD(リソースオーナーパスワードクレデンシャルズフロー)、CLIENT_CREDENTIALS(クライアントクレデンシャルズフロー)のいずれか。 IMPLICIT もしくは CLIENT_CREDENTIALS が指定されたときはリフレッシュトークンは発行されない。 また、設定でリフレッシュトークンフローを非サポートとしている場合もリフレッシュトークンは発行されない。
clientId 必須 生成するアクセストークンに関連付けるクライアントアプリケーションの ID。
subject 条件 生成するアクセストークンに関連付けるサブジェクト(ユーザーの一意識別子)。 grantTypeCLIENT_CREDENTIALS 以外の場合は必須。
scopes 任意 生成するアクセストークンに関連付けるスコープ群。 リクエストの Content-Type が JSON のときは文字列の配列として、FORM のときはスコープ群をスペースで連結した一つの文字列として指定する。
accessTokenDuration 任意 生成するアクセストークンの有効期間を秒単位で指定する。 何も指定しないか、もしくは 0 が指定された場合、認可サーバーに設定されている有効期間が使用される。
refreshTokenDuration 任意 生成するリフレッシュトークンの有効期間を秒単位で指定する。 何も指定しないか、もしくは 0 が指定された場合、認可サーバーに設定されている有効期間が使用される。
accessToken 任意 通常、アクセストークンの値は Authlete が自動生成するが(256 ビットのランダム値を base64url で表現したもので 43 文字)、他のシステムで過去に発行されたアクセストークンをそのまま利用したい場合等、アクセストークンの値を指定したい場合にこのリクエストパラメーターを用いる。 Authlete のデータベースはアクセストークンの値そのものは保存せず、ハッシュ値を保存しているので、アクセストークンの値がなんであれ、データベース上は固定長となる。 そのため、このリクエストパラメーターに指定するアクセストークンの値の自由度は高いが、そのかわりに、アクセストークンの値が重複しないよう API 呼び出し側で配慮する必要がある(同じアクセストークンの値が来た場合は Authlete 側で拒否はする)。
refreshToken 任意 accessToken リクエストパラメーターのリフレッシュトークン版。
clientIdAliasUsed 任意 アクセストークン生成時にクライアント ID 別名を使用したかどうかをエミュレートする。 現状、このフラグが影響するのは、ユーザー情報エンドポイントからの応答に含まれる aud クレームの値のみ。 このフラグが true の場合、aud の値は、Authlete が発行した数値のクライアント ID ではなく、クライアントアプリケーション開発者が設定したクライアント ID 別名になる。 API コール時にクライアント ID 別名が設定されていない場合、このフラグは false 扱いされる。
properties 任意 アクセストークンに紐付ける任意のデータ。 詳細は次節で後述。 Content-Type が FORM の場合、このリクエストパラメーターは使用できない。

下記は API コールの例です。

$ curl ¥
  --user ${サービスAPIキー}:${サービスAPIシークレット} ¥
  https://api.authlete.com/api/auth/token/create ¥
  -d grantType=AUTHORIZATION_CODE ¥
  -d clientId=98282920604 ¥
  -d subject=user123 ¥
  -d scopes=photo

7.3. アクセストークンと任意データ

RFC 6749 の「5.1. Successful Response」で挙げられている例は、アクセストークン発行時に example_parameter のような非標準パラメーターが返される可能性があることを示しています。

{
  "access_token":"2YotnFZFEjr1zCsicMWpAA",
  "token_type":"example",
  "expires_in":3600,
  "refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA",
  "example_parameter":"example_value"
}

サーバー側の事情で、このような非標準パラメーターをアクセストークンと紐付けて記憶し、内部利用するユースケースは少なからず存在します。 場合よっては、上記例のように、内部利用のみにとどまらず、当該パラメーターをクライアントアプリケーションに開示することもあるでしょう。 ただ、残念ながら、任意データをアクセストークンに紐付ける仕組みは標準化されていません。 しかし、認可サーバーの裏で動くバックエンドサーバーは、アクセストークンに任意のデータを紐付けるための汎用的な仕組みを提供しているかもしれません。

7.3.1. properties リクエストパラメーター

Authlete の場合、アクセストークンの生成を伴う API のオプショナルリクエストパラメーター properties がその役目を担います。 properties は、key, value, hidden というプロパティーを持つ JSON オブジェクトの配列です。

"properties": [
  {
    "key":    "example_parameter",
    "value":  "example_value",
    "hidden": false
  },
  {
    "key":    "hidden_parameter",
    "value":  "hidden_value",
    "hidden": true
  }
]

hiddenfalse のエントリーは、アクセストークン発行時にクライアントアプリケーションに対してその情報が伝えられます。

■ トークンエンドポイントからの応答例

{
  "access_token":"pNj1h24a4geA_YHilxrshkRkxJDsyXBZWKp3hZ5ND7A",
  "token_type":"Bearer",
  "expires_in":86400,
  "scope":null,
  "example_parameter":"example_value"
}

■ 認可エンドポイントからの応答例(インプリシットフロー)

HTTP/1.1 302 Found
Location: https://api.authlete.com/api/mock/redirection/4593494640
  #access_token=pNj1h24a4geA_YHilxrshkRkxJDsyXBZWKp3hZ5ND7A
  &token_type=Bearer
  &expires_in=86400
  &scope=
  &example_parameter=example_value
Cache-Control: no-store
Pragma: no-cache

hiddentrue のエントリーは、内部的にはアクセストークンに紐付いているものの、クライアントアプリケーションには開示されません。

hidden の値がどちらだったとしても、Authlete の /api/auth/introspection API に問い合わせれば、

$ curl \
  --user 493494640:BBw0rner_-y1A6J9s20wjRCpkBvez3GxEBoL9jOJVR0 \
  https://api.authlete.com/api/auth/introspection \
  -d token=KQfuhsimWoOaTZVRYbzh166pqK49hQyNMGTbbN8UfUY

紐づけられたプロパティー群の情報を取得することができます。

{
  "type":"introspectionResponse",
  "resultCode":"A056001",
  "resultMessage":"[A056001] The access token is valid.",
  "action":"OK",
  "clientId":5008706718,
  "clientIdAlias":"MyClient",
  "clientIdAliasUsed":false,
  "existent":true,
  "expiresAt":1461519667000,
  "properties":[
    {
      "hidden":false,
      "key":"example_parameter",
      "value":"example_value"
    },
    {
      "hidden":true,
      "key":"hidden_parameter",
      "value":"hidden_value"
    }
  ],
  "refreshable":true,
  "responseContent":"Bearer error=\"invalid_request\"",
  "subject":"user123",
  "sufficient":true,
  "usable":true
}

7.3.2. 認可コードフローを curl で実行する

アクセストークンに任意のデータを紐付ける方法を、認可コードフローを curl で実行する例を用いて紹介します。 なお、curl コマンドの実行例や JSON 応答例は、読みやすいように適宜改行や空白を入れており、実際の値とは異なる場合があります。

手順 1

認可エンドポイントに対するクライアントからのリクエスト "client_id=4008706719&response_type=code"/api/auth/authorization API に渡します。

$ curl \
  --user 4593494640:BBw0rner_-y1A6J9s20wjRCpkBvez3GxEBoL9jOJVR0 \
  https://api.authlete.com/api/auth/authorization \
  -d "parameters=client_id%3D4008706719%26response_type%3Dcode"

API から返される JSON の中から ticket の値を取り出します。 このチケットは、/api/auth/authorization/issue API を呼び出すときに使用します。

手順 2

/api/auth/authorization/issue API を呼び出してアクセストークンを発行します。 ここで、認可コードに紐づけたいプロパティー群を properties というパラメーターで指定します。 ここで指定したプロパティー群は、最終的にアクセストークンに紐づけられます。 なお、後で実行することになる /api/auth/token API 呼び出しの際にも追加でプロパティー群を指定できますので、 認可コードフローの場合は、プロパティー群をアクセストークンに紐づけたいとしても、必ずしも /api/auth/authorization/issue API 呼び出し時にプロパティー群を指定する必要はありません。

$ curl \
  --user 4593494640:BBw0rner_-y1A6J9s20wjRCpkBvez3GxEBoL9jOJVR0 \
  https://api.authlete.com/api/auth/authorization/issue \
  -H 'Content-Type:application/json' \
  -d "{\"ticket\":\"xKdGvPyPkLJRkmP6MSAJ1wISBmdnSbPG8pFzgTdZh4U\",
       \"subject\":\"user123\",
       \"properties\":[{
         \"key\":\"example_parameter\",
         \"value\":\"example_value\"}]}"

次のような JSON 応答が返ってきます。

{
  "type":"authorizationIssueResponse",
  "resultCode":"A040001",
  "resultMessage":
    "[A040001] The authorization request was processed successfully.",
  "action":"LOCATION",
  "responseContent":
    "https://api.authlete.com/api/mock/redirection/4593494640
     ?code=n96DtM32eV8maSG5Z3_p3qhAT7zuvuqlAaizOmDInZ4"
}

手順 3

/api/auth/authorization/issue API の応答に含まれる action の値が LOCATION なので、クライアントアプリケーションには 302 Found という HTTP ステータスを返します。 /api/auth/authorization/issue API の応答内の actionLOCATION のときは、responseContent に含まれる文字列は Location ヘッダーに含むべき値です。 これらの情報を元に、認可サーバーの実装はクライアントアプリケーションに次のような応答を返します。

HTTP/1.1 302 Found
Location: https://api.authlete.com/api/mock/redirection/4593494640
  ?code=n96DtM32eV8maSG5Z3_p3qhAT7zuvuqlAaizOmDInZ4
Cache-Control: no-store
Pragma: no-cache

手順 4

トークンエンドポイントに対するクライアントからのリクエスト "code=n96DtM32eV8maSG5Z3_p3qhAT7zuvuqlAaizOmDInZ4&grant_type=authorization_code"/api/auth/token API に渡します。ここでは、アクセストークンに紐づける追加のプロパティーとして、additional_parameter=additional_value を指定しています。 なお、/api/auth/authorization/issue API に渡したプロパティー群と /api/auth/token API に渡したプロパティー群との間で、キー名に重複があった場合、/api/auth/token API に渡した値で上書きされます。

$ curl \
  --user 4593494640:BBw0rner_-y1A6J9s20wjRCpkBvez3GxEBoL9jOJVR0 \
  https://api.authlete.com/api/auth/token \
  -H 'Content-Type:application/json' \
  -d "{\"parameters\":
         \"code=n96DtM32eV8maSG5Z3_p3qhAT7zuvuqlAaizOmDInZ4&
           grant_type=authorization_code\",
       \"properties\":[{
         \"key\":\"additional_parameter\",
         \"value\":\"additional_value\"}]}"

次のような JSON 応答が返ってきます。

{
  "type":"tokenResponse",
  "resultCode":"A050001",
  "resultMessage":
    "[A050001] The token request (grant_type=authorization_code)
     was processed successfully.",
  "action":"OK",
  "responseContent":
    "{\"access_token\":
        \"xuc8cd1zf9TpdMwiB7sfLcPmY6DHpYIpz1jyo9a0YXs\",
      \"additional_parameter\":
        \"additional_value\",
      \"refresh_token\":
        \"66fIuQ33usvJ9ZSDrnUv2KQnC946Kr4Cj8n8bcjlpTI\",
      \"example_parameter\":\"example_value\",
      \"scope\":null,
      \"token_type\":\"Bearer\",
      \"expires_in\":86400}"
}

手順 5

/api/auth/token API の応答に含まれる action の値が OK であることから、クライアントアプリケーションには 200 OK という HTTP ステータスを返します。 /api/auth/token API の応答内の actionOK のときは、responseContent に含まれる文字列はレスポンスボディーとして使用する値です。 これらの情報を元に、認可サーバーの実装はクライアントアプリケーションに次のような応答を返します。

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
Pragma: no-cache

{
  "access_token":"xuc8cd1zf9TpdMwiB7sfLcPmY6DHpYIpz1jyo9a0YXs",
  "additional_parameter":"additional_value",
  "refresh_token":"66fIuQ33usvJ9ZSDrnUv2KQnC946Kr4Cj8n8bcjlpTI",
  "example_parameter":"example_value",
  "scope":null,
  "token_type":"Bearer",
  "expires_in":86400
}

この応答には、/api/auth/authorization/issue API 呼び出し時に指定した example_parameter=example_value というプロパティーと、/api/auth/token API 呼び出し時に指定した additional_parameter=additional_value が含まれています。

7.4. アプリに対する承認取り消し

クライアントアプリケーションに与えた許可をユーザーが取り消せるようにするためには、サービスは、承認済みアプリケーションのリストをユーザーに提示し、どのアプリケーションに対する承認を取り消すかを選択させ、その後、選択されたアプリケーションに対して発行されたアクセストークンを全て削除する、という作業をおこないます。

上記のような仕組みは、ある程度の規模のサービスであれば当然必要となってきますが、標準仕様範囲外の話なので※8、各サービスが独自実装することになります。

※8:個々のアクセストークン・リフレッシュトークンを削除するための API の標準仕様として RFC 7009(OAuth 2.0 Token Revocation)が存在するものの、これだけでは当該ユースケースをサポートできません。

認可サーバーや総合パッケージソリューションによっては、上記の仕組みを UI 込みで提供しているものもあるでしょうが、Semi-Hosted Service パターンのバックエンドサーバーであれば別のアプローチを取るでしょう。 すなわち、ユーザーが承認を与えたアプリのリストを取得する API や、それらのアプリに発行した全アクセストークンの一括更新・削除をおこなう API を提供するでしょう。

7.4.1. ユーザー・アプリ単位の操作をおこなう API

前述のユースケースをサポートするため、Authlete では、ユーザーとアプリの組みに対して操作をおこなうための API を幾つか提供しています。

■ 承認したクライアントのリストを取得する

メソッド パス
GET /api/client/authorization/get/list/{サブジェクト}
GET /api/client/authorization/get/list?subject={サブジェクト}
POST /api/client/authorization/get/list
Content-Type = application/x-www-form-urlencoded
POST /api/client/authorization/get/list
Content-Type = application/json
リクエストパラメーター 要否 説明
subject 必須 ユーザーの一意識別子
start 任意 検索結果の開始インデックス。 デフォルト 0。
end 任意 検索結果の終了インデックス。 デフォルト 5。
developer 任意 開発者識別子。 デフォルト null。
応答ステータス フォーマット
200 OK application/json
レスポンスパラメーター 説明
subject ユーザーの一意識別子
start 検索結果の開始インデックス
end 検索結果の終了インデックス
developer 開発者識別子
totalCount 該当するクライアントの総数
clients クライアント情報の配列

■ クライアントに与えた承認を全て取り消す

メソッド パス
DELETE /api/client/authorization/delete/{クライアントID}/{サブジェクト}
DELETE /api/client/authorization/delete/{クライアントID}?subject={サブジェクト}
POST /api/client/authorization/delete/{クライアントID}
Content-Type = application/x-www-form-urlencoded
POST /api/client/authorization/delete/{クライアントID}
Content-Type = application/json
リクエストパラメーター 要否 説明
subject 必須 ユーザーの一意識別子
応答ステータス フォーマット
200 OK application/json
レスポンスパラメーター 説明
resultCode 処理結果コード
resultMessage 処理結果に関する説明

■ クライアントに与えた承認を全て更新する

メソッド パス
POST /api/client/authorization/update/{クライアントID}
Content-Type = application/x-www-form-urlencoded
POST /api/client/authorization/update/{クライアントID}
Content-Type = application/json
リクエストパラメーター 要否 説明
subject 必須 ユーザーの一意識別子
scopes 任意 新しいスコープ群の配列。 null でない値が指定された場合、それが新しいスコープ群として既存のアクセストークン群にセットされる。 Content-Type = application/x-www-form-urlencoded でリクエストする場合、scopes の値はスコープ名をスペース区切りで列挙したもの(フォームエンコード後は + での区切りとなる)。
応答ステータス フォーマット
200 OK application/json
レスポンスパラメーター 説明
resultCode 処理結果コード
resultMessage 処理結果に関する説明

7.5. 承認済み権限の記録

過去に承認を受けたクライアントアプリケーションが追加で権限を取得したいとき、再度認可リクエストを認可サーバーに投げます。 それを受け、認可サーバーはユーザーに認可画面を表示します。 認可画面には、クライアントアプリケーションが要求した権限が列挙されます。

このとき、認可画面が単純な実装であれば、過去に一度承認した権限も含めて全ての権限を表示します。 一方、過去に一度承認されている権限は表示せず、新規で追加要求された権限のみを表示するという実装もありえます。 ユーザーにとっては後者のほうが好ましいと言えます。

追加要求された権限のみを表示するという仕組みを作るためには、ユーザーがクライアントアプリケーションに過去に与えた権限を全て覚えておく必要があります。 当該ユーザーの承認により当該クライアントアプリケーションに対して発行された全てのアクセストークンが期限切れになった後も覚えておかなければなりません。 つまり、アクセストークン群のライフタイムとは異なるライフタイムを持つデータとして管理することが求められます。

この仕組みは標準仕様外の話ですが、よくある仕組みでもあるので、Semi-Hosted Service パターンのバックエンドサーバーであれば、その仕組みを実装するための機能を提供しているかもしれません。

7.5.1. 承認済み権限の記録を操作する API

Authlete は、ユーザーがクライアントに対して過去に与えた権限のリストを取得する API と、その記録を削除する API を提供しています。 記録の新規作成・更新自体は Authlete 側が適宜自動でおこなうため、記録新規作成・更新のための API は用意されていません。

なお、記録削除 API により明示的に記録が削除されるか、もしくはクライアントやサービス自体が削除されない限り、承認済み権限の記録はデータベース内に残り続けるので、Authlete 利用者側が記録削除 API の呼び出しを怠ると、不要なデータが累積していってしまいます。 このため、この機能は Authlete 共用サーバー(api.authlete.com)では意図的に利用できないようにしてあり、Authlete 専用サーバー(クラウド版専用サーバーまたはオンプレミス版専用サーバー)でのみ利用可能です。

■ 承認済み権限のリストを取得する

メソッド パス
GET /api/client/granted_scopes/get/{クライアントID}/{サブジェクト}
GET /api/client/granted_scopes/get/{クライアントID}?subject={サブジェクト}
POST /api/client/granted_scopes/get/{クライアントID}
Content-Type = application/x-www-form-urlencoded
POST /api/client/granted_scopes/get/{クライアントID}
Content-Type = application/json
リクエストパラメーター 要否 説明
subject 必須 ユーザーの一意識別子
応答ステータス フォーマット
200 OK application/json
レスポンスパラメーター 説明
serviceApiKey サービスの API キー
clientId クライアント ID
subject ユーザー一意識別子
latestGrantedScopes 最後の認可処理で付与されたスコープ群
mergedGrantedScopes 過去に付与された全てのスコープ群(削除されたものは除く)

■ 承認済み権限の記録を削除する

メソッド パス
DELETE /api/client/granted_scopes/delete/{クライアントID}/{サブジェクト}
DELETE /api/client/granted_scopes/delete/{クライアントID}?subject={サブジェクト}
POST /api/client/granted_scopes/delete/{クライアントID}
Content-Type = application/x-www-form-urlencoded
POST /api/client/granted_scopes/delete/{クライアントID}
Content-Type = application/json
リクエストパラメーター 要否 説明
subject 必須 ユーザーの一意識別子
応答ステータス フォーマット
200 OK application/json
レスポンスパラメーター 説明
resultCode 処理結果コード
resultMessage 処理結果に関する説明

なお、この API で記録を削除しても、既存のアクセストークンは削除されず、また、それらに紐づく権限も変更されません。

おわりに

認可サーバー・OpenID プロバイダーそのものではなく、それらを実装するための部品を Web API として提供する」という新しいアーキテクチャーを紹介させていただきました。 Justin Richer 氏はこのアーキテクチャーに Semi-Hosted Service パターンという名前を付けました。

このアーキテクチャーにより、OAuth 2.0 と OpenID Connect の実装をユーザー認証処理などの他機能から綺麗に分離することが可能となります。 より良い設計を求める技術者の方々にこのアーキテクチャーを知っていただければと思います。

長文を読んでくださり、ありがとうございました。