OpenID Foundationのガイドラインに沿ったRailsでのOIDC Implicit Flow実装

## モチベーション

- IDaaSを社内サービスへの適用したい
- OpenID Foundation Japanのガイドラインが伝える OpenID Connect Implicit Flowの実装方法を試してみる
- あんまりRailsでの実装事例を見ないので、これを見てもっと良い実装方法があるよ〜とかあればご意見をいただければ..!! :pray:
- 例のごとく、自分のブログからの転載 
  - http://selmertsx.hatenablog.com/entry/2018/08/22/104510

## 前提

-  本ドキュメントではOpenID Connectの仕様については説明しない
-  [OpenID Foundation Japanが提供する実装ガイド](https://www.openid.or.jp/news/eiwg_implementation_guideline_1.0.pdf)に従って認証機能の実装を行う
- 認証のフローはImplicit Flowを採用している
  - Authorization Code Flowは、OPとRPが直接やりとりをする必要がある
      - RP側でOPからアクセスできる口を開けなければならない
  - 上記理由から、ガイドラインではImplicit Flowの実装方法のみ記載されている (p.40 3.4.2 認証フロー参照)
- 今回の実装では、強制ログアウト機能&SCIMは未実装

## 目次

- 用語の説明
- Implicit Flowの概要
- 実際の作業とRails実装
- 懸念点

## 用語の説明

- SCIM: System for Cross-domain Identity Management。ADとユーザー情報を同期するのに使ったりする
- 強制ログアウト: シングルサインオンの逆。シングルサインアウトを実現するためには必要
- OP: OpenID Provider。認証する側。今回はAzure AD。
- RP: Relying Party。認証してもらう側。SSOでログインするアプリ

## Implicit Flowの概要

<img width="663" alt="スクリーンショット 2018-08-20 14.15.15.png" src="https://qiita-image-store.s3.amazonaws.com/0/23606/66aaab91-84c4-0dca-8a61-45664997480d.png">

( ※ https://www.openid.or.jp/news/eiwg_implementation_guideline_1.0.pdf のp.13より引用 )

**Implicit Flow**ではOPとRPが直接やりとりをせずに、ブラウザ経由でIDトークンの受け渡しを行う。そのためRPで**IDトークン**が改ざんされていないことを検証する必要がある。IDトークンとはエンドユーザーのOPに対する認証結果であり、emailやprofileなどのユーザー情報、OpenID Providerの情報、リプレイ攻撃対策のための文字列などを含んでいる。

## Railsの実装とAzure AD側の設定
### 参考にした資料

- サンプル実装: https://github.com/selmertsx/pomodoro
  - [実際の実装部分](https://github.com/selmertsx/pomodoro/compare/37418f11877e8ddfc948ed427ead51552bf7550a...374462db0d393dcdcf58b979cb52ce275a5da18a)
- 利用するgem: https://github.com/nov/openid_connect
- 参考実装１: https://github.com/nov/openid_connect_sample_rp
- 参考実装２: https://github.com/openid-foundation-japan/eiwg-guideline-samples/tree/master/sample-ruby-rp

参考実装１は、Dynamic Client Registrationを用いた OPの設定をしているものの、gem作成者が下記のような意見をしている。

https://github.com/nov/openid_connect/wiki/Client-Discovery
> Some OPs are supporting Discovery without supporting Dynamic Registration, but I don't think RPs "need" to discover OP config for such OPs. Almost all RPs won't need this feature currently.

参考実装２は、シンプルな実装なので読みやすいものの、最新のopenid_connect gemでは動かない。この[コミット](https://github.com/nov/json-jwt/commit/63d9b1eb04f680893932196d5f89013f756c28ed#diff-e64230ecda134ae0f937388155746d86L138) で動作しなくなったものと思われる。

今回は参考実装2をベースにしつつ、動くように手を入れた。

### OPにRPの情報を登録する (AzureADの設定)


<img width="770" alt="スクリーンショット 2018-08-21 10.44.22.png" src="https://qiita-image-store.s3.amazonaws.com/0/23606/2e1a5684-c2ec-d7d7-c909-bc1181dd0369.png">

<img width="512" alt="20180813172651 (1).png" src="https://qiita-image-store.s3.amazonaws.com/0/23606/81214c80-204f-db7d-fac6-b5d0cb724c00.png">


Azure ADに対してRP側の情報を登録する([登録方法](https://docs.microsoft.com/ja-jp/azure/active-directory/develop/v1-protocols-oauth-code#register-your-application-with-your-ad-tenant))。登録するべき情報はリダイレクト先のURLと、ログアウト要求を受け付けるエンドポイントのURLの２点である( ガイドライン P.31 ) 。**リダイレクト先のURLは完全一致で指定しなければならない(p.30)**。ここで登録することによって、OPからRPにClientIDが割り振られる。

### RPにOPの情報を登録する

上記手順により得られたClientIDをRP側に設定する。ガイドラインから公開鍵をJWK Set形式で、外部からアクセスできるURIに配置した場合は、下記４点のパラメータをRPに登録することとなる。( P.32~33 )


- OPから割り当てられたClient ID
- Issuerの識別子
- 認証エンドポイントURI
- JWKs URI

Azure ADにてこれらのパラメータを取得する方法については、[このドキュメント](https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-protocols-oidc#fetch-the-openid-connect-metadata-document)を参照。コレをもとに実際に実装したRailsのコードは下記のようになる。


```ruby
# app/models/authorization.rb
class Authorization
  include ActiveModel::Model

  AUTHORIZATION_ENDPOINT = Rails.application.credentials.oidc[:authorization_endpoint].freeze
  ISSUER = Rails.application.credentials.oidc[:issuer].freeze
  IDENTIFIER = Rails.application.credentials.oidc[:identifier].freeze
  JWKS_URI = Rails.application.credentials.oidc[:jwks_uri].freeze

  attr_reader :client

  def initialize
    @client = OpenIDConnect::Client.new(
      issuer: ISSUER,
      identifier: IDENTIFIER,
      authorization_endpoint: AUTHORIZATION_ENDPOINT
    )
  end

  private

  # JWKは鍵を適宜DLして利用する
  def jwk_json
    @jwks ||= JSON.parse(
      OpenIDConnect.http_client.get_content(JWKS_URI)
    ).with_indifferent_access

    JSON::JWK::Set.new @jwks[:keys]
  end
end
```

### RPからブラウザリダイレクトをしてOPに認証要求をする

ガイドラインのP.49 **4.1.3 利用企業の認証サーバーへの認証要求** に、OPへの認証要求の方法が記載されている。そこではqueryパラメータに値を設定し、OPにアクセスをすることで認証の要求をすることになっている。

```
// OPに認証リクエストをする際のURL
https://login.microsoftonline.com/xxx/oauth2/v2.0/authorize?
  client_id=xxx
  &nonce=33cf756a5fc3c69c05f40ae6baa17dac
  &redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback
  &response_type=id_token
  &scope=openid+email+profile
  &state=8fd4930f285a4e9e
```
今回はOPとしてAzure ADを利用しているため、各種パラメータの値はAzureADの[公式ドキュメント](https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-protocols-oidc#send-the-sign-in-request)を参照する。このサンプルではユーザーの基本情報しか取得しないため、**response_typeはid_token**とした。

```ruby
# app/controllers/authorizations_controller.rb 一部抜粋
class AuthorizationsController < ApplicationController

  def new
    redirect_to authz.authorization_uri(new_state, new_nonce)
  end

  private

  def authz
    @authz ||= Authorization.new
  end

  def new_state
    session[:state] = SecureRandom.hex(8)
  end

  def new_nonce
    session[:nonce] = SecureRandom.hex(16)
  end
end
```

```ruby
# app/models/authorization.rb 一部抜粋
class Authorization
  include ActiveModel::Model
  ...
  def authorization_uri(state, nonce)
    client.authorization_uri(
      response_type: 'id_token',
      state: state,
      nonce: nonce,
      scope: %w[openid email profile]
    )
  end
end
```

### OPから認証結果を受け取る

OPに対して認証要求をした結果、redirect_uri で設定したURIに対して、認証結果がフラグメントとして付与される(ガイドライン p.50)。

```
// 受け取るレスポンス
http://localhost:3000/authorization
#token_type=Bearer
&expires_in=3599
&scope=profile+openid+email+xxxx%2fUser.Read
&id_token=eyJhbGciOiJSUzI1NiIsImtpZCI6ImlBdzUifQ.eyJpc3MiOiJodHRwczovL29wLmNvbS5
leGFtcGxlLmNvLmpwIiwic3ViIjoiZTEyMzQ1NjciLCJhdWQiOiJwV0JvUmFtOXNHIiwib
m9uY2UiOiJxOGstdXBCWDRaX0EiLCJleHAiOjE0Mfafsafadfdafavfabfdaa
xNDM1NzA5NzYyfQ.paxubCBAPwITlNgg-Mi_RkX5EfaRVU8YGT
Z2e9UwUX1EIwBDU3i_uCqUj-yUMY6Li1rjbpHurYEw7N3ZQPdBlVy6GiMQtUFg6Ju-0MY4
rw0uiZ7HFoGenAMzoR8fNd4iIuyM4oOEF6WuV5JLfZmJ5fvgjTbAD5H-2SGeM38P8UibRy
v2lB-YldI8a7nEUGXz5m5iw-WpBgk4SboMWXsyg-jr-_fbRMn9_RR76-691P45-1p8BU8w
BMEwgHVARTbRg53W6dlILAl_FDWWIBxUboI9_uTKu7HCYuZecU7Uub6MXG5EMWJKUPjVuu
HuvkzBsl7MdKCRZuwI1fEAU35lYQ
&state=d612642b27161676
&session_state=e9ed8a15-9337-4cb1-bb8b-823dfef7a15d
```

今回は `GET authorization/callback` をredirect先に指定したため、下記のような実装になる。なおJavaScriptのコードについては[OpenID Connect Core 15.5.3](http://openid.net/specs/openid-connect-core-1_0.html#FragmentNotes) のコードそのままとしている。

```javascript
<%# app/views/authorizations/callback.html.erb %>
<h1>CallBack!!</h1>
<%= javascript_pack_tag "openid_connect" %>
```

```javascript
// app/packs/openid_connect.js
// authorization/callbackで実行されるJavaScript
let params = {}
const postBody = location.hash.substring(1);
const regex = /([^&=]+)=([^&]*)/g;
let m;
while (m = regex.exec(postBody)) {
  params[decodeURIComponent(m[1])] = decodeURIComponent(m[2]);
}
var req = new XMLHttpRequest();
req.open('POST', '//' + window.location.host + '/authorization', true);
req.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
var token = document.querySelector("meta[name='csrf-token']").attributes['content'].value;
req.setRequestHeader('X-CSRF-Token', token);
req.onreadystatechange = function (e) {
  if (req.readyState == 4) {
    if (req.status == 200) {
      console.log("success!!");
    }
    else if (req.status == 400) {
      alert('There was an error processing the token')
    } else {
      alert('Something other than 200 was returned')
    }
  }
};
req.send(postBody);
```

### ID トークンの検証を行う

認証に成功した後に、IDトークンの検証を行う。IDトークンの検証においては、state値が認証要求時に指定した値と同一であるかチェックした後(p.52)、jwkを用いてIDトークンをdecodeして検証を行う。検証項目についてはガイドラインを参照とする(p.54)。

```ruby
# app/controllers/authorizations_controller.rb 一部抜粋
class AuthorizationsController < ApplicationController
  def create
    if stored_state != fragment_params[:state]
      return render status: :unauthorized
    end

    id_token = authz.verify!(fragment_params[:id_token], stored_nonce)
    session[:identifier] = id_token.subject
    render json: id_token.raw_attributes, status: :ok
  end
end
```

```ruby
# app/models/authorization.rb 一部抜粋
class Authorization
  include ActiveModel::Model

  def verify!(jwt_string, nonce)
    id_token = decode_jwt_string(jwt_string)
    id_token.verify!(
      issuer: ISSUER,
      client_id: IDENTIFIER,
      nonce: nonce
    )
    id_token
  end

  private

  def decode_jwt_string(jwt_string)
    OpenIDConnect::ResponseObject::IdToken.decode(jwt_string, jwk_json)
  end

  def jwk_json
    @jwks ||= JSON.parse(
      OpenIDConnect.http_client.get_content(JWKS_URI)
    ).with_indifferent_access

    JSON::JWK::Set.new @jwks[:keys]
  end
end
```

```json
// ID TokenをDecodeした結果
{
 "aud"=>"xxxx", // required: IDトークンを利用しようとするクライアント
 "iss"=>"https://login.microsoftonline.com/xxxx/v2.0", //required: ID Tokenの発行者(Issuer)を表す識別子
 "iat"=>1534834161,  // require: IDトークンが発行された時刻。UTC
 "nbf"=>1534834161,
 "exp"=>1534838061, // required: IDトークンの有効期限
 "aio"=> "xxx",
 "name"=>"selmertsx",
 "nonce"=>"fcecee85d465e7df8e6d225c8e00bf15", // option: リプレイ攻撃の対策に利用される文字列
 "oid"=>"xxx",
 "preferred_username"=>"shuhei.morioka@hogehoge.com",
 "sub"=>"xxx", //required: 認証されたユーザーを表す識別子. Issuer内で一意
 "tid"=>"xxx",
 "uti"=>"xxx",
 "ver"=>"2.0"
}
```

## 懸念点
### openid_connectのgemではlogout_onlyの検証をしていない

ガイドラインにおいて、IDトークンの検証として、最低限下記の項目について検証することとしている。(p.54)

1. iss クレームが、認証要求を送った利用企業の認証サーバーの URL と一致することを検証する。
2. aud クレームが、自クラウドサービスに割り当てられた client_id であることを検証する。
もし aud クレームが複数の値を持つ場合は、OpenID Connect Core 1.0 [OpenID.Core]
の Section 3.1.3.7 の定義に従った追加の検証が必要である。
3. ID トークンの署名を、RS256 アルゴリズムを用いて検証する。
  ID トークンの base64url エンコーディングされた 1 番目、2 番目のパートを '.' で連結し
た文字列を、ハッシュ関数に SHA-256 を使うように構成された RSASSA-PKCS1-v1_5
署名検証器にかけることで検証を行なう。
具体的な手順は JWS [RFC7515] の Section 5.2, Section A.2 などに示されている。署名
検証に用いる公開鍵の取得方法は [4.1.9 節] に示す。
もし、ID トークンに RS256 以外の署名アルゴリズムが指定されており、その署名をク
ラウドサービスが検証可能な場合は、その検証で代替しても良い。
4. 現在時刻が exp クレームで指定された時刻よりも前であることを検証する。
5. nonce クレームが、クライアント（ブラウザ）セッションと紐付いた nonce 値と一致す
ることを検証する。
6. **logout_only クレームが含まれていないことを検証する。**

ここで6の項目について、現在のopenid_connectのライブラリでは検証がされていないように見受けられた。現在、これが検証されていない場合、どのようなリスクがあるのか調査中。(強制ログアウトの検証ができないくらいの認識で良いのだろうか :thinking: )

```ruby
# openid_connect/lib/openid_connect/response_object/id_token.rb
# https://github.com/nov/openid_connect/blob/007d35f249b028a43391550153c22e9cf006e661/lib/openid_connect/response_object/id_token.rb#L24-L35

def verify!(expected = {})
  raise ExpiredToken.new('Invalid ID token: Expired token') unless exp.to_i > Time.now.to_i
  raise InvalidIssuer.new('Invalid ID token: Issuer does not match') unless iss == expected[:issuer]
  raise InvalidNonce.new('Invalid ID Token: Nonce does not match') unless nonce == expected[:nonce]

# aud(ience) can be a string or an array of strings
  unless Array(aud).include?(expected[:audience] || expected[:client_id])
    raise InvalidAudience.new('Invalid ID token: Audience does not match')
  end

  true
end
```

## 次の予定

- ALBレイヤーでOIDCができるのか確認してく
  - https://dev.classmethod.jp/cloud/add-google-authenticate-your-webapp-on-alb/