はじめに
OAuth 2.0 の仕様書である RFC 6749 は、認可サーバー(authorization server)の動作を定めています。この記事は、認可サーバーを簡易的に実装することで、OAuth 2.0 の理解を深めることを目的としています。
1. エンドポイント
認可サーバーは Web サーバーの一種で、認可エンドポイント(authorization endpoint)と**トークンエンドポイント**(token endpoint)という二つのエンドポイントを提供します。この二つのエンドポイントを実装すれば、最小構成の認可サーバーができます。
実際は、上図が示すように、認可エンドポイントに送られてきた認可リクエスト(authorization request)を処理する過程で、Web ブラウザを介して(まだ済んでいなければ)ユーザー認証および同意取得をインタラクティブに行わなければならないため、認可エンドポイントと連携するその他のエンドポイント群も必要となります。詳細は『OAuth 2.0 の認可レスポンスとリダイレクトに関する説明』を参照してください。
なお、サポートする認可フローを限定すれば、どちらか一方のエンドポイントのみの実装で済む場合もあります。具体的には、インプリシット・フローのみであれば認可エンドポイントの実装だけで済みます。一方、リソースオーナー・パスワード・クレデンシャルズ・フローもしくはクライアント・クレデンシャルズ・フローだけであればトークンエンドポイントの実装だけで済みます。
フロー | 認可エンドポイント | トークンエンドポイント |
---|---|---|
認可コード | 使用する | 使用する |
インプリシット | 使用する | 使用しない |
リソースオーナー・パスワード・クレデンシャルズ | 使用しない | 使用する |
クライアント・クレデンシャルズ | 使用しない | 使用する |
リフレッシュ・トークン | 使用しない | 使用する |
逆に、RFC 6749 以外で定義されている認可フローをサポートする場合、新たに別のエンドポイントの実装が必要になることがあります。例えば CIBA(Client Initiated Backchannel Authentication)では**バックチャネル認証エンドポイント(backchannel authentication endpoint)、デバイスフロー(RFC 8628)ではデバイス認可エンドポイント**(device authorization endpoint)の実装が求められます。
この記事では、認可エンドポイントとトークンエンドポイントを実装します。サポートする認可フローは認可コードフローのみ、サポートするクライアント・タイプはパブリックのみとします。
2. 注意点
下記の理由、および書かれていないその他の理由により、本実装は商用利用には適していません。
- セキュリティー上必須の仕様である PKCE(RFC 7636)をサポートしていない。
- 認可コードフロー以外のフローをサポートしていない。
- コンフィデンシャル・クライアントをサポートしていない。つまり、クライアント認証に関するコードは一切含まれていない。
-
RFC 6749 では条件により
redirect_uri
リクエストパラメーターを省略できるが、本実装では省略できない。 - RFC 6749 はリダイレクト URI がクエリー部を含むことを許しているが、その場合、この実装は
Location
ヘッダー用の適切な値を生成できない。 - RFC 6749 で "Request and response parameters MUST NOT be included more than one." と書かれているが、本実装では同一リクエストパラメーターが複数回指定されているかどうかのチェックをしていない。
- RFC 6749 の "Parameters sent without a value MUST be treated as if they were omitted from the request." という規定に厳密には従っていない。
- 認可エンドポイントは、使うべきリダイレクト URI を特定できた後に発生したエラーでも
302 Found
を使わない。 - RFC 6749 は
state
リクエストパラメーターの値に使うことができる文字を %x20-7E の範囲に限定しているが、この実装ではstate
の値をチェックしていない。 - トークンエンドポイントは常に
client_id
リクエストパラメーターを要求する。(パブリック・クライアントしか存在しないと知っているため) - トークンエンドポイントは常に
redirect_uri
リクエストパラメーターを要求する。(認可コードフローしかサポートせず、かつ、認可エンドポイントがredirect_uri
を常に要求することを知っているため) - 有効期限切れの認可コードとアクセストークンを定期的に削除する仕組みを持たない。
- 認可コードとアクセストークンのエントロピーがとても低い。
- Sinatra のセッション機能をセキュリティー上の配慮なく使用している。
- CSRF に対する保護を実装していない。
- 次のものがハードコーディングされている:クライアント(一つ)、ユーザーアカウント(一つ)、アクセストークンの有効期限
その他、OpenID Connect 対応も考える場合、このブログで紹介するものとはかなり異なる設計・実装が必要となりますので注意してください。特に、response_type とリクエストオブジェクト、response_mode と JARM、署名・暗号アルゴリズムのサポート方法については予め設計に組み込んでおかないと、後でかなり苦労することになります(最悪書き直しになります)。『クレーム』の処理も意外と厄介です。
商用レベルの高品質な認可サーバーが必要な場合は Authlete(オースリート)をご検討ください。
3. ソースコード
実装には、Ruby の Web フレームワークの一つである Sinatra を用います。ソースコードの完成版は GitHub の authlete/useless-oauth-server で公開しています。誤って商用利用することがないよう、名称に useless という単語を含めています。
4. データ構造
4.1. クライアント
仕様上、クライアントアプリケーションを表現するデータ構造が持つべきデータはクライアント ID とリダイレクト URI 群の二つです。
コンフィデンシャル・クライアントであれば、クライアント・シークレットも必要ですが、本実装ではコンフィデンシャル・クライアントをサポートしないので、クライアント・シークレット用のデータフィールドを用意しないことにします。
クライアント名を表すデータフィールドは仕様上必須ではありませんが、認可ページでクライアント名を表示したいので、今回の実装では含めることにします。
上記をまとめると、クライアントを表現するデータ構造は次のようになります。
class Client
attr_accessor :client_id
attr_accessor :client_name
attr_accessor :redirect_uris
def initialize(client_id, client_name, redirect_uris)
@client_id = client_id
@client_name = client_name
@redirect_uris = redirect_uris
end
end
なお、OpenID Connect Dynamic Client Registration 1.0 をサポートしようとすると、クライアントのメタデータの数は爆発的に増えます。その他、クライアントのメタデータは、CIBA やデバイスフロー(RFC 8628)、MTLS(OAuth 2.0 Mutual TLS Client Authentication and Certificate Bound Access Tokens)や JARM(JWT Secured Authorization Response Mode for OAuth 2.0) など、様々な仕様で適宜追加されます。
4.2. ユーザー
ユーザー認証をどのようにおこなうかは、OAuth 2.0 の仕様の範囲外です。この実装では、ログイン ID とパスワードという古典的な方法でユーザー認証をおこなうことにします。最低限のデータ構造は次のようになります。
class User
attr_accessor :user_id
attr_accessor :login_id
attr_accessor :password
def initialize(user_id, login_id, password)
@user_id = user_id
@login_id = login_id
@password = password
end
end
OpenID Connect をサポートする場合、OpenID Connect Core 1.0 の「5.1. Standard Claims」に挙げられている項目を参考にデータフィールドを追加するのが望ましいです。
4.3. 認可コード
認可コード(authorization code)は、認可エンドポイントから発行される短命のトークンです。クライアントは、受け取った認可コードをすみやかにトークンエンドポイントに提示し、アクセストークンと交換します。
認可コードは、どのユーザーがどのクライアントに何の権限を与えたかを表現しているので、認可コードを表すデータ構造には、最低限、ユーザー、クライアント、権限(スコープ)を表すデータフィールドが必要になります。
認可リクエストに redirect_uri
リクエストパラメーターが含まれていた場合、同じ値がトークンリクエストにも含まれていなければならないという規定があります。そのため、認可コード発行の際に指定されたリダイレクト URI を、その認可コードに紐づけて覚えておく必要があります。のちほどトークンエンドポイントの実装でチェックするためです。このような理由により、認可コードを表すデータ構造には、リダイレクト URI を表現するデータフィールドが必要です。
認可コードは短命であり、RFC 6749 に "A maximum authorization code lifetime of 10 minutes is RECOMMENDED." と書かれています。必然的に有効期限を表すデータフィールドも必要です。
上記をまとめると、認可コードを表すデータ構造は次のようになります。
class AuthorizationCode
attr_accessor :value
attr_accessor :user_id
attr_accessor :client_id
attr_accessor :scopes
attr_accessor :redirect_uri
attr_accessor :expires_at
def initialize(user_id, client_id, scopes, redirect_uri, expires_at)
@value = SecureRandom.urlsafe_base64(6)
@user_id = user_id
@client_id = client_id
@scopes = scopes
@redirect_uri = redirect_uri
@expires_at = expires_at
end
end
インスタンス生成時にランダム文字列を生成して value
フィールドに設定しています。この値は、外部からみた認可コードの値として使用します。
本実装では PKCE(RFC 7636)をサポートしていませんが(そのため商用利用はできませんが)、PKCE をサポートする場合、code_challenge
と code_challenge_method
を表すデータフィールドも必要になります。詳細は『PKCE: 認可コード横取り攻撃対策のために OAuth サーバーとクライアントが実装すべきこと』を参照してください。
4.4. アクセストークン
アクセストークン(access token)は、どのユーザーがどのクライアントに何の権限を与えたかを表現しているので、アクセストークンを表すデータ構造には、最低限、ユーザー、クライアント、権限(スコープ)を表すデータフィールドが必要になります。また、通常アクセストークンには有効期限を設けます。
上記をまとめると、アクセストークンを表すデータ構造は次のようになります。
class AccessToken
attr_accessor :value
attr_accessor :user_id
attr_accessor :client_id
attr_accessor :scopes
attr_accessor :expires_at
def initialize(user_id, client_id, scopes, expires_at)
@value = SecureRandom.urlsafe_base64(6)
@user_id = user_id
@client_id = client_id
@scopes = scopes
@expires_at = expires_at
end
end
認可コードと同様、インスタンス生成時にランダム文字列を生成して value
フィールドに設定しています。この値は、外部からみたアクセストークンの値として使用します。
アクセストークンの実装を JWT とし、認可サーバー側にアクセストークンのデータを持たないようにすることもできます。世の中にそのような実装が多いことは知っていますが、あまりお勧めしません。詳細は『OAuth アクセストークンの実装に関する考察』を参照してください。
実装にもよりますが、アクセストークンのデータ構造にリフレッシュトークンに関する情報、もしくはリフレッシュトークンそのものが含まれる場合があります。リフレッシュトークン・フローを実装する場合はデータ構造の検討が必要となります。
Mutual-TLS Client Certificate-Bound Access Tokens(MTLS, Section 3)をサポートする場合は、アクセストークン発行時に用いられたクライアント証明書のフィンガープリントを覚えておくデータフィールドも必要となります。
5. データストア
この簡易実装では、全てのデータをメモリ上に保存します(永続記憶領域には書き込みません)。また、クライアントとユーザーをそれぞれ一つずつ、ハードコーディングしておきます。
結果として、データストアは次のような実装になります。
# 登録済みクライアントアプリケーション
$ClientStore = [
# クライアント ID, クライアント名, リダイレクト URI 群
Client.new('1', 'My Client', ['http://example.com/'])
]
# 登録済みユーザー
$UserStore = [
# ユーザー ID, ログイン ID, パスワード
User.new('1', 'john', 'john')
]
# 発行された認可コード
# キーは AuthorizationCode.value、値は AuthorizationCode インスタンス
$AuthorizationCodeStore = {}
# 発行されたアクセストークン
# キーは AccessToken.value、値は AccessToken インスタンス
$AccessTokenStore = {}
6. 定数
サポートするスコープの種類、認可コードの有効期限秒数、アクセストークンの有効期限秒数は、次のようにハードコーディングすることにします。
$SUPPORTED_SCOPES = %w(read write)
$AUTHORIZATION_CODE_DURATION = 600
$ACCESS_TOKEN_DURATION = 86400
7. 認可エンドポイント
認可エンドポイントの簡易実装は次のようになります。
# 認可エンドポイント
get '/authorization' do
# --- client_id ---
# client_id リクエストパラメーターからクライアントを検索する。
client = look_up_client(params['client_id'])
if client.nil?
halt 400, 'client_id is wrong.'
end
# --- redirect_uri ---
# この実装は redirect_uri リクエストパラメーターを必ず要求する。
redirect_uri = params['redirect_uri']
if redirect_uri.nil? || !client.redirect_uris.include?(redirect_uri)
halt 400, 'redirect_uri is wrong.'
end
# --- response_type ---
# サポートするのは認可コードフローのみ。
if params['response_type'] != 'code'
halt 400, 'response_type is wrong.'
end
# --- state ---
state = params['state'].nil? ? '' : params['state']
# --- scope ---
# scope リクエストパラメーターで指定されたスコープ群のうち、
# サポートされているものだけを取り出す。
scopes = filter_scopes(params['scope'])
# 後で使用するので、幾つかのパラメーターをセッションに保存する。
session[:client] = client
session[:state] = state
session[:scopes] = scopes
session[:redirect_uri] = redirect_uri
# 認可ページを描画する。
erb :authorization_page, :locals => {
'client_name' => client.client_name,
'scopes' => scopes
}
end
一つずつ見ていきましょう。
7.1. client_id
# --- client_id ---
# client_id リクエストパラメーターからクライアントを検索する。
client = look_up_client(params['client_id'])
if client.nil?
halt 400, 'client_id is wrong.'
end
このコードでは、認可リクエストに含まれる client_id
リクエストパラメーターの値を取り出し、その値をクライアント ID として持つ Client
インスタンスを検索しています。仕様上、client_id
リクエストパラメーターは必須です。
Client
インスタンスの検索に用いられている look_up_client()
関数の実装は次の通りです。
def look_up_client(value)
$ClientStore.find { |client| client.client_id == value }
end
Client
インスタンスが見つからない場合、400 Bad Request
というエラーレスポンスを返します。
7.2. redirect_uri
# --- redirect_uri ---
# この実装は redirect_uri リクエストパラメーターを必ず要求する。
redirect_uri = params['redirect_uri']
if redirect_uri.nil? || !client.redirect_uris.include?(redirect_uri)
halt 400, 'redirect_uri is wrong.'
end
仕様上、ある条件下では redirect_uri
リクエストパラメーターは省略可能です(一方 OpenID Connect では常に省略不可)。しかし、単純化のため、この実装では redirect_uri
を必ず要求するようにしています。
redirect_uri
が指定されている場合、その値が登録済みかどうかをチェックしなければなりません。指定された値と登録されている値との比較は、後者が full URI でない場合は RFC 3986 Section 6 で規定されている方法で比較すべきとされています。しかし、単純化のため、この実装では単純な文字列比較(Section 6.2.1)としています(OpenID Connect では常に単純文字列比較)。
本来、リダイレクト URI の検証はもっと複雑です。詳細は「リダイレクト URI」を参照してください。
7.3. response_type
# --- response_type ---
# サポートするのは認可コードフローのみ。
if params['response_type'] != 'code'
halt 400, 'response_type is wrong.'
end
RFC 6749 の場合、実質的に response_type
が取りうる値は code
もしくは token
のどちらかで、それを前提にコーディングしていれば大丈夫です。しかし、OpenID Connect は、ここに id_token
という値を加えた上で、これらを複数同時に指定できるようにしました。このため、OpenID Connect をサポートしようとすると、response_type
の処理はかなり複雑になります。詳細は「応答タイプ (response_type)」を参照してください。
response_type
が決まると、レスポンスパラメーター群をクエリー部に置くのか、それともフラグメント部に置くのかを決定できます。RFC 6749 だけの世界では、認可コードフローならクエリー部、インプリシット・フローならフラグメント部となります。一方、OAuth 2.0 Multiple Response Type Encoding Practices、OAuth 2.0 Form Post Response Mode、JWT Secured Authorization Response Mode for OAuth 2.0 (JARM)、まで考慮に入れると、レスポンスパラメーター群の置き場所とそのフォーマットの決定は、かなり複雑な処理になります。
しかしながら、本実装では認可コードフローしかサポートしないと決めており、その他の拡張仕様もサポートしないので、response_type
リクエストパラメーターの値が code
かどうかを決め打ちで調べています。
7.4. state
# --- state ---
state = params['state'].nil? ? '' : params['state']
認可リクエストに含まれる state
の値は、最終的なレスポンスの中に含める必要があるので、認可リクエストからその値を取り出しておきます。仕様上、state
の値を構成する文字は %x20-7E
の範囲でなければなりませんが、この実装ではそこまでチェックしていません。
7.5. scope
# --- scope ---
# scope リクエストパラメーターで指定されたスコープ群のうち、
# サポートされているものだけを取り出す。
scopes = filter_scopes(params['scope'])
scope
リクエストパラメーターには、スコープ群が空白文字区切りで列挙されています。カンマ区切りを要求する認可サーバーの実装もありますが、それは仕様違反です。
filter_scopes()
関数は次の通りで、指定されたスコープ群の中から、本実装がサポートするスコープのみを取り出し、配列として返します。
def filter_scopes(value)
return [] if value.nil?
scopes = value.split(/\s+/)
scopes.find_all { |scope| $SUPPORTED_SCOPES.include?(scope) }
end
7.6. 後で利用する情報
# 後で使用するので、幾つかのパラメーターをセッションに保存する。
session[:client] = client
session[:state] = state
session[:scopes] = scopes
session[:redirect_uri] = redirect_uri
state
の値は、後ほど認可レスポンスに含めなければならないので覚えておきます。それ以外の情報は、後ほど認可コードを生成する際に必要となるので覚えておきます。
気付きにくいかも知れませんが、「認可リクエストの際に redirect_uri
を指定した場合は、同じ値をトークンリクエストに含めなければならない」という要求事項があるので、認可コード発行時に用いられた redirect_uri
を覚えておく必要があることに注意してください。
7.7. 認可ページ
# 認可ページを描画する。
erb :authorization_page, :locals => {
'client_name' => client.client_name,
'scopes' => scopes
}
最後に HTML の認可ページを生成して返しています。クライアント名とスコープ群に関する情報は、認可ページ内に埋め込むので、:locals
を用いて認可ページのテンプレート(:authorization_page
)に渡しています。本実装では、テンプレートをソースコードの末尾に埋め込んでいます。テンプレート内の文法については、Sinatra のドキュメントを参照してください。
__END__
@@ authorization_page
<html>
<head>
<title>Authorization Page</title>
</head>
<body class="font">
<h2>Client Application</h2>
<%= client_name %>
<h2>Requested Permissions</h2>
<% if scopes != nil %>
<ol>
<% scopes.each do |scope| %>
<li><%= scope %></li>
<% end %>
</ol>
<% end %>
<h2>Approve?</h2>
<form method="post" action="/decision">
<input type="text" name="login_id" placeholder="Login ID"><br>
<input type="password" name="password" placeholder="Password"><br>
<button type="submit" name="approved" value="true">Approve</button>
<button type="submit" name="denied" value="true">Deny</button>
</form>
</body>
</html>
8. 認可決定エンドポイント
認可ページでは、認可リクエストの情報(クライアント名とスコープ群)、ログインフォーム、Approve ボタンと Deny ボタンを表示します。ユーザーは、認可リクエストを承認する場合、ログイン ID とパスワードを入力し、Approve ボタンを押します。認可リクエストを承認しない場合は Deny ボタンを押します。
いずれの場合でも、本実装では、submit された情報は /decision
エンドポイントに送信されます。当該エンドポイントの実装は次のようになります。
# Decision Endpoint
post '/decision' do
client = session[:client]
state = session[:state]
scopes = session[:scopes]
redirect_uri = session[:redirect_uri]
session.clear
# レスポンスは、リダイレクト URI がさす場所に、state 付きで返される。
location = "#{redirect_uri}?state=#{state}"
# Approve ボタンが押されなかった場合
if params['approved'] != 'true'
redirect location + '&error=access_denied' +
'&error_description=The+request+was+not+approved.', 302
end
# ログイン ID とパスワードを用いて、ユーザーを検索する。
user = find_user(params['login_id'], params['password'])
if user.nil?
redirect location + '&error=access_denied' +
'&error_description=End-user+authentication-failed.', 302
end
# 認可コードを生成して保存する。
expires_at = Time.now.to_i + $AUTHORIZATION_CODE_DURATION
code = AuthorizationCode.new(
user.user_id, client.client_id, scopes, redirect_uri, expires_at)
$AuthorizationCodeStore[code.value] = code
# 認可コードを含む成功応答
redirect location + '&code=' + code.value, 302
end
一つずつ見ていきましょう。
8.1. 認可リクエストの情報
client = session[:client]
state = session[:state]
scopes = session[:scopes]
redirect_uri = session[:redirect_uri]
session.clear
/authorization
エンドポイントで保存した情報を取り出します。
8.2. Location
# レスポンスは、リダイレクト URI がさす場所に、state 付きで返される。
location = "#{redirect_uri}?state=#{state}"
最終的な応答は次の形式になるので(『OAuth 2.0 の認可レスポンスとリダイレクトに関する説明』)、
HTTP/1.1 302 Found
Location: {リダイレクトURI}?{レスポンスパラメーター群}
ここでは、Location
ヘッダーの値として用いる URI のベース部分を location
という変数に設定しています。
認可コードフローしかサポートせず、response_mode=form_post
もサポートしないので、レスポンスパラメーター群を置く場所はクエリー部で固定です。そのため、レスポンスパラメーター群は ?
に続けて配置します。なお、フラグメント部に置く場合は ?
のかわりに #
を使うことになります。
成功応答の場合もエラー応答の場合も、認可リクエストに含まれていた state
の値をレスポンスに含める必要があるため、location
には state=#{state}
というレスポンスパラメーターを含めています。
RFC 6749 は、リダイレクト URI がクエリー部を含むことを許しています。しかし、その場合、この簡易実装は Location
ヘッダー用の適切な URI を構築することができないので注意してください。(リダイレクト URI がクエリー部を含むと、location
が ?
を二回含むことになってしまう)
8.3. 認可リクエストに対する認否
# Approve ボタンが押されなかった場合
if params['approved'] != 'true'
redirect location + '&error=access_denied' +
'&error_description=The+request+was+not+approved.', 302
end
ユーザーが認可リクエストを承認しなかった場合、error=access_denied
というエラーコードを location
の末尾に追加してエラーレスポンスを返します。エラーレスポンスの HTTP ステータスコードは 302 Found
になります。
任意ですが、仕様上 error_description
パラメーターでエラーに関する詳細情報を追加することができます。また、error_uri
というパラメーターも存在しますが、ここでは用いていません。
8.4. ユーザー認証
# ログイン ID とパスワードを用いて、ユーザーを検索する。
user = find_user(params['login_id'], params['password'])
if user.nil?
redirect location + '&error=access_denied' +
'&error_description=End-user+authentication+failed.', 302
end
ログインフォームに入力されたログイン ID とパスワードを用いて、ユーザーを検索します。ユーザー検索に使う find_user()
関数の実装は次の通りです。
def find_user(login_id, password)
$UserStore.find do |user|
user.login_id == login_id && user.password == password
end
end
ユーザーが見つからなければ error=access_denied
というエラーコードを返します。error
の値は先ほどと同じですが、エラーの理由が異なるので、error_description
に「ユーザー認証が失敗した」という情報を書いています。
この簡易実装は、ログイン ID とパスワードの組が誤っている場合に再入力を促す処理は実装していません。
8.5. 認可コード生成
# 認可コードを生成して保存する。
expires_at = Time.now.to_i + $AUTHORIZATION_CODE_DURATION
code = AuthorizationCode.new(
user.user_id, client.client_id, scopes, redirect_uri, expires_at)
$AuthorizationCodeStore[code.value] = code
メインの処理となる認可コード生成部分です。認可コードの有効秒数には $AUTHORIZATION_CODE_DURATION
という固定値を用いています。
8.6. 成功応答
# 認可コードを含む成功応答
redirect location + '&code=' + code.value, 302
生成したばかりの認可コードの値を code
パラメーターの値として用いて、成功応答を返します。
9. トークンエンドポイント
トークンエンドポイントの実装は次の通りです。
# Token Endpoint
post '/token' do
content_type :json
# --- grant_type ---
# サポートするのは認可コードフロー(grant_type=authorization_code)のみ
grant_type_value = extract_mandatory_parameter(params, 'grant_type')
if grant_type_value != 'authorization_code'
halt 400, {'error'=>'unsupported_grant_type'}.to_json
end
# --- code ---
# 認可コードフローでは code パラメーターは必須。
code_value = extract_mandatory_parameter(params, 'code')
code = $AuthorizationCodeStore[code_value]
if code.nil?
halt 400, {'error'=>'invalid_grant','error_description'=>
'The authorization code is not found.'}.to_json
end
if code.expires_at < Time.now.to_i
$AuthorizationCodeStore.delete(code_value)
halt 400, {'error'=>'invalid_grant','error_description'=>
'The authorization code has expired.'}.to_json
end
# --- redirect_uri --
# 認可リクエストに redirect_uri が含まれていれば、トークンリクエストでも
# redirect_uri が必要。この簡易実装は、redirect_uri を含まない認可
# リクエストを全て拒絶するように実装されている。
redirect_uri_value = extract_mandatory_parameter(params, 'redirect_uri')
if redirect_uri_value != code.redirect_uri
halt 400, {'error'=>'invalid_grant','error_description'=>
'redirect_uri is wrong.'}.to_json
end
# --- client_id ---
# コンフィデンシャルクライアントかつクライアント認証が client_id を必要としない、
# という場合を除き、client_id パラメーターは必須。
client_id_value = extract_mandatory_parameter(params, 'client_id')
if client_id_value != code.client_id
halt 400, {'error'=>'invalid_grant','error_description'=>
'client_id is wrong.'}.to_json
end
# アクセストークンを生成して保存する。
expires_at = Time.now.to_i + $ACCESS_TOKEN_DURATION
token = AccessToken.new(
code.user_id, code.client_id, code.scopes, expires_at)
$AccessTokenStore[token.value] = token
# 使用済みの認可コードを削除する。
$AuthorizationCodeStore.delete(code_value)
# アクセストークンを含む成功応答
{
'access_token' => token.value,
'token_type' => 'Bearer',
'expires_in' => $ACCESS_TOKEN_DURATION,
'scope' => token.scopes.join(' ')
}.to_json
end
一つずつ見ていきましょう。
9.1. application/json
content_type :json
トークンエンドポイントからの応答の Content-Type は常に application/json
になります。
9.2. grant_type
# --- grant_type ---
# サポートするのは認可コードフロー(grant_type=authorization_code)のみ
grant_type_value = extract_mandatory_parameter(params, 'grant_type')
if grant_type_value != 'authorization_code'
halt 400, {'error'=>'unsupported_grant_type'}.to_json
end
フローの種別に関わらず、grant_type
リクエストパラメーターは必須です。
params['grant_type']
と書けば grant_type
リクエストパラメーターの値を取り出すことができますが、ここではわざわざ extract_mandatory_parameter()
関数を用いて値を取り出しています。この関数の実装は次のようになっており、指定されたリクエストパラメーターが存在しないときに、error=invalid_request
で 400 Bad Request
を返すようになっています。
def extract_mandatory_parameter(params, key)
value = params[key]
if value.nil? || value == ''
halt 400, {'error'=>'invalid_request','error_description'=>
"#{key} is missing."}.to_json
end
return value
end
grant_type
の値を取り出したあとは、それが authorization_code
と等しいかどうかをチェックしています。この実装では認可コードフローしかサポートしないので、このような決め打ちのチェックになっています。もしも grant_type
の値が authorization_code
ではない場合、error=unsupported_grant_type
で 400 Bad Request
を返します。
参考までに、grant_type
が取りうる値としては、例えば次のようなものがあります。
フロー |
grant_type の値 |
---|---|
認可コード | authorization_code |
リソースオーナー・パスワード・クレデンシャルズ | password |
クライアント・クレデンシャルズ | client_credentials |
リフレッシュ・トークン | refresh_token |
CIBA | urn:openid:params:grant-type:ciba |
デバイス | urn:ietf:params:oauth:grant-type:device_code |
9.3. code
# --- code ---
# 認可コードフローでは code パラメーターは必須。
code_value = extract_mandatory_parameter(params, 'code')
code = $AuthorizationCodeStore[code_value]
認可コードフローの場合(grant_type=authorization_code
の場合)、code
リクエストパラメーターは必須です。code
パラメーターの値は認可コードを表します。
上記のコードでは、まず、extract_mandatory_parameter()
で code
の値を取り出しています。それから、その値に対応する AuthorizationCode
インスタンスを $AuthorizationCodeStore
から取り出しています。
if code.nil?
halt 400, {'error'=>'invalid_grant','error_description'=>
'The authorization code is not found.'}.to_json
end
もしも AuthorizationCode
インスタンスが存在しなければ、それは、code
パラメーターで指定された認可コードが存在しないことを意味しているので、error=invalid_grant
で 400 Bad Request
を返します。
if code.expires_at < Time.now.to_i
$AuthorizationCodeStore.delete(code_value)
halt 400, {'error'=>'invalid_grant','error_description'=>
'The authorization code has expired.'}.to_json
end
AuthorizationCode
インスタンスが存在していても、その有効期限が既に切れている場合は、error=invalid_grant
で 400 Bad Request
を返します。
9.4. redirect_uri
# --- redirect_uri --
# 認可リクエストに redirect_uri が含まれていれば、トークンリクエストでも
# redirect_uri が必要。この簡易実装は、redirect_uri を含まない認可
# リクエストを全て拒絶するように実装されている。
redirect_uri_value = extract_mandatory_parameter(params, 'redirect_uri')
if redirect_uri_value != code.redirect_uri
halt 400, {'error'=>'invalid_grant','error_description'=>
'redirect_uri is wrong.'}.to_json
end
認可コードフローにおいて、認可リクエストに redirect_uri
パラメーターが明示的に渡されていた場合、トークンリクエストにも同じ値を持つ redirect_uri
パラメーターを指定しなければなりません。
この簡易実装は、認可コードフローしかサポートせず、かつ、認可エンドポイントが redirect_uri
を必ず要求するように実装されているので、結果として、この簡易実装のトークンエンドポイントに対するトークンリクエストは常に redirect_uri
を含んでいなければなりません。
上記のような理由により、このコードでは、何の条件もチェックせずに、redirect_uri
を必須パラメーターとして扱っています。
redirect_uri
の値を取得した後、それが、認可コードに紐付いているリダイレクト URI の値(=認可リクエストの redirect_uri
の値)と等しいかどうかを調べています。
9.5. client_id
# --- client_id ---
# コンフィデンシャルクライアントかつクライアント認証が client_id を必要としない、
# という場合を除き、client_id パラメーターは必須。
client_id_value = extract_mandatory_parameter(params, 'client_id')
if client_id_value != code.client_id
halt 400, {'error'=>'invalid_grant','error_description'=>
'client_id is wrong.'}.to_json
end
トークンリクエストの client_id
が必須となるのは、次のケースです。
- クライアントタイプがパブリックである。
- クライアントタイプがコンフィデンシャルで、使用するクライアント認証方式が
client_id
パラメーターを必要とする。client_secret_post
、tls_client_auth
、self_signed_tls_client_auth
が該当します。
この簡易実装がサポートするのはパブリッククライアントのみなので、決め打ちで client_id
を必須パラメーターとして扱っています。
大抵の実装では、認可コードのみからクライアントを特定することは可能ですが、そうであったとしても、セキュリティー上の理由から、パブリッククライアントの場合は client_id
リクエストパラメーターを必ず渡さなければならないとされています。
9.6. アクセストークン生成
# アクセストークンを生成して保存する。
expires_at = Time.now.to_i + $ACCESS_TOKEN_DURATION
token = AccessToken.new(
code.user_id, code.client_id, code.scopes, expires_at)
$AccessTokenStore[token.value] = token
アクセストークンの生成です。アクセストークンの属性の多くは、認可コードの属性からのコピーです。アクセストークンの有効秒数には、$ACCESS_TOKEN_DURATION
という固定値を使用しています。
9.7. 認可コード削除
# 使用済みの認可コードを削除する。
$AuthorizationCodeStore.delete(code_value)
認可コードを複数回使用することは禁止されているので、ここで削除しておきます。
9.8. 成功応答
# アクセストークンを含む成功応答
{
'access_token' => token.value,
'token_type' => 'Bearer',
'expires_in' => $ACCESS_TOKEN_DURATION,
'scope' => token.scopes.join(' ')
}.to_json
生成したばかりのアクセストークンを含む JSON を返します。
応答のうち、access_token
と token_type
は必須のパラメーターです。
token_type
の値が Bearer
となっていますが、これは RFC 6750 由来のものです。通常、リソースサーバーの API 群は RFC 6750 で定義されている方法でアクセストークンを受け取るように実装されると思うので、token_type
の値は Bearer
でよいでしょう。
expires_in
はアクセストークンの有効期限秒数を表すオプショナルパラメーターです。
認可リクエストで要求された scope
と、実際に許可された scope
が異なる場合、トークンレスポンスの scope
パラメーターは必須となります。
10. 実行
10.1. 認可サーバー起動
Sinatra をまだインストールしていなければインストールします。
$ gem install sinatra
ソースコードをダウンロードし、
$ git clone https://github.com/authlete/useless-oauth-server
$ cd useless-oauth-server
プログラムを実行します。
$ ruby server.rb
これにより、http://localhost:4567
で認可サーバーが動き始めます。
10.2. 認可リクエストと認可レスポンス
認可リクエストを表す次の URL を Web ブラウザのアドレスバーに入力します(もしくは単にクリックします)。
すると、次のような認可ページが表示されます。
ログインフォームの Login ID と Password にそれぞれ john
、john
と入力し、Approve ボタンを押します。
その結果、リダイレクト先(http://example.com/
)に遷移し、次のような画面が表示されます。
ここで重要なのは、アドレスバーに表示されている URL の code
パラメーターの値です。この値が、発行された認可コードです。この例では SBxFEHaX
が認可コードです。
学習用の認可サーバー実装なので、コピペしやすいように、認可コードの長さは短くしてあり、8 桁しかありません。認可コードのエントロピーが極端に低いので、セキュリティー上問題となります。
認可コードの有効期限は 10 分しかないので、すみやかにトークンリクエストを投げましょう。
10.3. トークンリクエストとトークンレスポンス
シェルターミナルを開いて、次のコマンドを実行します。
$ CODE={発行された認可コード}
$ curl http://localhost:4567/token \
-d grant_type=authorization_code \
-d code=$CODE \
-d client_id=1 \
-d redirect_uri=http://example.com/
次のような応答が返ってきたら成功です。access_token
の値が発行されたアクセストークンです。この例では mvL_sRy3
がアクセストークンです。
{
"access_token":"mvL_sRy3",
"token_type":"Bearer",
"expires_in":86400,
"scope":"read write"
}
なお、見やすいようにここでは出力を整形していますが、実際の出力は 1 行です。
おわりに
この記事で実装したのは RFC 6749 の一部のみであり、厳密に仕様に準拠していない箇所や、セキュリティー上問題になる箇所もあるので、とても商用利用できるものではありません。しかしながら、認可サーバーの実装のイメージを掴むことはできたと思います。
Authlete(オースリート)のバージョン 2.1 では、次の仕様をサポートしています。
- RFC 6749: The OAuth 2.0 Authorization Framework
- RFC 6750: The OAuth 2.0 Authorization Framework: Bearer Token Usage
- RFC 7009: OAuth 2.0 Token Revocation
- RFC 7523: JSON Web Token (JWT) Profile for OAuth 2.0 Client Authentication and Authorization Grants
- RFC 7591: OAuth 2.0 Dynamic Client Registration Protocol
- RFC 7592: OAuth 2.0 Dynamic Client Registration Management Protocol
- RFC 7636: Proof Key for Code Exchange by OAuth Public Clients
- RFC 7662: OAuth 2.0 Token Introspection
- RFC 8628: OAuth 2.0 Device Authorization Grant
- OAuth 2.0 Multiple Response Type Encoding Practices
- OAuth 2.0 Form Post Response Mode
- OAuth 2.0 Mutual TLS Client Authentication and Certificate Bound Access Tokens
- OpenID Connect Core 1.0
- OpenID Connect Discovery 1.0
- OpenID Connect Dynamic Client Registration 1.0
- OpenID Connect Client Initiated Backchannel Authentication Flow - Core 1.0
- Financial-grade API - Part 1: Read-Only API Security Profile
- Financial-grade API - Part 2: Read and Write API Security Profile
- Financial-grade API: JWT Secured Authorization Response Mode for OAuth 2.0 (JARM)
- Financial-grade API: Client Initiated Backchannel Authentication Profile
- UK Open Banking Security Profile
Authlete は次の項目で OpenID Certification を取得しており、
- Basic OP
- Implicit OP
- Hybrid OP
- Config OP
- Dynamic OP
- Form Post OP
- FAPI R/W OP w/ MTLS
- FAPI R/W OP w/ Private Key
- FAPI-CIBA OP poll w/ MTLS
- FAPI-CIBA OP poll w/ Private Key
- FAPI-CIBA OP Ping w/ MTLS
- FAPI-CIBA OP Ping w/ Private Key
FAPI-CIBA プロファイルに至っては、現時点(2019 年 10 月 17 日)で世界で唯一、OpenID Certification を取得している実装です。
商用の認可サーバーには、是非 Authlete をご利用ください!
・・・
なお、簡易実装と商用利用に耐えうる実装の間には天と地ほどの差があるので、簡易実装のソースコードを公開したところで、Authlete のビジネスには全く影響ありません。ーーーこう考えるんだ。この簡易実装と解説記事を 24 時間以内に仕上げてしまう人が、世界からエキスパートを集めてチームを作り、5 年以上かけて(もうすぐ 6 年)開発し続けているのが Authlete なんだ。だから、Authlete の機能が多く、品質が高いのは当然なんだ。