455
450

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

OAuth 2.0 の勉強のために認可サーバーを自作する

Last updated at Posted at 2019-10-17

はじめに

OAuth 2.0 の仕様書である RFC 6749 は、認可サーバー(authorization server)の動作を定めています。この記事は、認可サーバーを簡易的に実装することで、OAuth 2.0 の理解を深めることを目的としています。

1. エンドポイント

認可サーバーは Web サーバーの一種で、認可エンドポイント(authorization endpoint)と**トークンエンドポイント**(token endpoint)という二つのエンドポイントを提供します。この二つのエンドポイントを実装すれば、最小構成の認可サーバーができます。

authz-ep_and_token-ep.png

実際は、上図が示すように、認可エンドポイントに送られてきた認可リクエスト(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_modeJARM、署名・暗号アルゴリズムのサポート方法については予め設計に組み込んでおかないと、後でかなり苦労することになります(最悪書き直しになります)。『クレーム』の処理も意外と厄介です。

商用レベルの高品質な認可サーバーが必要な場合は 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_challengecode_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 PracticesOAuth 2.0 Form Post Response ModeJWT 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_request400 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_type400 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_grant400 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_grant400 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_posttls_client_authself_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_tokentoken_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 ブラウザのアドレスバーに入力します(もしくは単にクリックします)。

http://localhost:4567/authorization?response_type=code&client_id=1&redirect_uri=http://example.com/&scope=read+write&state=mystate

すると、次のような認可ページが表示されます。

authorization_page.png

ログインフォームの Login ID と Password にそれぞれ johnjohn と入力し、Approve ボタンを押します。

その結果、リダイレクト先(http://example.com/)に遷移し、次のような画面が表示されます。

redirection_page.png

ここで重要なのは、アドレスバーに表示されている 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 では、次の仕様をサポートしています。

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 を取得している実装です。

FAPI-CIBA_OPs_20190920.png

商用の認可サーバーには、是非 Authlete をご利用ください!

・・・

なお、簡易実装と商用利用に耐えうる実装の間には天と地ほどの差があるので、簡易実装のソースコードを公開したところで、Authlete のビジネスには全く影響ありません。ーーーこう考えるんだ。この簡易実装と解説記事を 24 時間以内に仕上げてしまう人が、世界からエキスパートを集めてチームを作り、5 年以上かけて(もうすぐ 6 年)開発し続けているのが Authlete なんだ。だから、Authlete の機能が多く、品質が高いのは当然なんだ。

455
450
1

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
455
450

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?