👌

Rails API と Okta で SCIM 機能を実装してみた(前半)

2023/04/10に公開

概要

Rails(API モード) と Okta を使用して、 User に関するSCIM 機能を実装してみました。
本記事ではこちらにあるチュートリアルにて紹介されている Runscope のテストが通るように実装しています。

今回作成したものは以下になります。
https://github.com/kazu-2020/okta-playground/tree/main/api

後半の記事は以下になります
https://zenn.dev/matazou/articles/653db8efd337b8

用意するもの

  1. Okta アカウント https://developer.okta.com/
  2. ngrok アカウント https://ngrok.com/
  3. Runscope アカウント https://www.runscope.com
  4. Rails アプリ(API モード)

前準備

1. API token を作成する

Rails と Okta 間で、認証を行う際に使用する API token を作成します。
Okta のダッシュボードにて、サイドメニューより Security > APIと選択し、Create tokenボタンから作成することができます。作成したトークンは手元に控えておいてください。

2. Rails の初期設定を行う

API エンドポイントは、こちらに記載されているようにhttps通信で行うように定められています。そのため、ngrok を使用して3000 port を外部公開します。

ngrok http 3000

そして、ngrok にて生成されたドメインをconfig/enviroments/development.rbに設定します。

# config/enviroments/development.rb

Rails.application.configure do
# ....

  config.hosts << '#{your_ngrok_domain}'
end

設定が済みましたら Rails server を立ち上げて、ngrok の url をブラウザで開くと Rails の初期画面が表示されると思います。

次に、先程 Okta にて作成した API token を credentials ファイルに登録し、保存します。

okta:
  scim: your_api_token

最後に Rails で SCIM 機能を実装するのに必要な Gem を Gemfile に設定し、bundle install して前準備は終わりになります。使用する Gem は scimitorを使用します。

Runscope の準備を行う

こちらを参考に前準備を行います。

詳細
  1. SCIM 2.0 用の JSON ファイルをダウンロードします(https://developer.okta.com/standards/SCIM/SCIMFiles/Okta-SCIM-20-SPEC-Test.json)

  2. Runscope(https://www.runscope.com/okta)にアクセスし、左下にあるImport Textをクリックします。

  3. Runscope API Testsを選択し、「ファイル選択」より先程ダウンロードした JSON ファイルをします。そして、Import API Testをクリックします。

  4. 画面右上のTestタブをクリックし、 Okta SCIM 2.0 SPEC Testが作成されていることを確認します。

  5. Okta SCIM 2.0 SPEC内に進んで頂き、サイドメニューのEditorを選択し、Initial Variablesを選択します。設定する値は以下になります。

Name Value
auth Bearer {your_access_token}
SCIMBaseURL {your_ngrok_url}/scim_v2

  1. Initiral Scriptタブを選択し、以下の内容をペーストした後、画面上のSaveボタンを押して設定を反映します。
function generate_lastname()
{
    var lastname = "";
    var lower_alphabets = "abcdefghijklmnopqrstuvwxyz";
    var upper_alphabets = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
    var digits = "0123456789";
    
    lastname += upper_alphabets.charAt(Math.floor(Math.random() * upper_alphabets.length));

    for( var i=0; i < 8; i++ )
        lastname += lower_alphabets.charAt(Math.floor(Math.random() * lower_alphabets.length));

    for( var j=0; j < 3; j++ )
        lastname += digits.charAt(Math.floor(Math.random() * digits.length));

    return lastname;
}

function generate_firstname()
{
    var firstname = "Runscope";
    var digits = "0123456789";
    for( var j=0; j < 3; j++ )
        firstname += digits.charAt(Math.floor(Math.random() * digits.length));
    return firstname;    
}

var firstname = generate_firstname();
var lastname = generate_lastname();
var email = firstname + lastname + "@atko.com";

variables.set("randomGivenName", firstname);
variables.set("randomFamilyName", lastname);
variables.set("randomEmail", email);
variables.set("randomUsername", email);
variables.set("InvalidUserEmail", "abcdefgh@atko.com");
variables.set("UserIdThatDoesNotExist", "010101001010101011001010101011");
variables.set("randomUsernameCaps",email.toUpperCase());
  1. 最後に今回は User に関する SCIM 機能のみを実装するので、グループに関するテストは Skip するようにします。

READ, CREATE 機能を実装する

scimitar の 初期設定ファイルを作成

config/initializers/scimitar.rbを作成し、こちらを参考に以下の内容を設定します。
1つは CSRT token の確認を省略する設定で、もう1つは Bearer 認証を行う設定です。

# config/initializers/scimitar.rb

Rails.application.config.to_prepare do # (required for >= Rails 7 / Zeitwerk)
  Scimitar.engine_configuration =
    Scimitar::EngineConfiguration.new(
      {
        application_controller_mixin:
          Module.new do
            def self.included(base)
              base.class_eval { skip_before_action :verify_authenticity_token }
            end
          end,
        token_authenticator:
          proc do |token, _options|
            Rack::Utils.secure_compare(
              token,
              Rails.application.credentials.dig(:okta, :scim)
            )
          end
      }
    )
end
メモ

User モデルの作成

users テーブル用の migration ファイルは以下のようになります。

ActiveRecord::Schema[7.0].define(version: 2023_04_03_141442) do
  enable_extension "plpgsql"

  create_table "users", force: :cascade do |t|
    t.string "first_name", null: false
    t.string "last_name", null: false
    t.string "email", null: false
    t.string "okta_user_name", null: false, comment: "Okta上で一意なユーザー名"
    t.boolean "active", default: false, null: false, comment: "Okta上で有効かどうか"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["okta_user_name"], name: "index_users_on_okta_user_name", unique: true
  end

end

User::OktaScim モジュールを作成

SCIM のリソースと User モデルをマッピングするためのモジュール(app/models/user/okta_scim.rb)を作成し、以下を参考に実装します。
https://github.com/RIPAGlobal/scimitar#data-models
https://github.com/RIPAGlobal/scimitar/blob/fe43f9f2cbf157d2b16732fc52cd8341fe1aefd2/app/models/scimitar/resources/mixin.rb

# app/models/user/okta_scim.rb
module User::OktaScim
  extend ActiveSupport::Concern
  
  included { include Scimitar::Resources::Mixin }

  module ClassMethods
    # SCIM と 紐づく ActiveRecord のクラスを指定
    def scim_resource_type
      Scimitar::Resources::User
    end
     
    # SCIM の属性と User クラスの属性をマッピングするためのメソッド
    # 返却すべき基本的な形式が下記のURLから確認できる
    # https://developer.okta.com/docs/reference/scim/scim-20/#retrieve-a-specific-user
    def scim_attributes_map
      {
        id: :id,
        userName: :okta_user_name,
        name: {
          givenName: :first_name,
          familyName: :last_name
        },
        emails: [
          {
            match: 'primary',
            with: true,
            using: {
              value: :email,
              primary: true
            }
          }
        ],
        active: :active
      }
    end
    
    # SCIM  オブジェクト(User インスタンス)を作成、更新する際にどの属性を変更可能かを決定するためのメソッド
    # 今回は全ての属性を変更可能としている
    def scim_mutable_attributes; end

        # SCIM フィルタークエリ と User モデルの属性をマッピングするためのメソッド 
        # Okta では userName 属性を一意な値として管理しており、この値を元に一意なユーザーを検索する。
    # https://developer.okta.com/docs/reference/scim/scim-20/#retrieve-users
    def scim_queryable_attributes
      { 'userName' => { column: :okta_user_name } }
    end

    # こちらはオプション
    def scim_timestamps_map
      { created: :created_at, lastModified: :updated_at }
    end
  end
end

このモジュールを User モデルにインクルードします。

# app/models/user.rb
class User < ApplicationRecord
  include User::OktaScim

  validates :first_name, presence: true
  validates :last_name, presence: true
  validates :email, presence: true
  validates :okta_user_name, presence: true, uniqueness: true
end

コントローラーの作成

コントローラーを作成します。

bin/rails g controller ScimV2::Users index show create

作成したコントローラーは Scimitar::ResourcesControllerを継承します。

module ScimV2
  class UsersController < Scimitar::ResourcesController
    def index; end
    def show; end
    def create; end
  end
end

routes.rb に以下の設定を行います

Rails.application.routes.draw do
  namespace :scim_v2 do
    get 'Users', to: 'users#index'
    get 'Users/:id', to: 'users#show'
    post 'Users', to: 'users#create'
  end
end

コントラーラーの実装は以下のファイルを参考に行います。
https://github.com/RIPAGlobal/scimitar/blob/fe26bad339863b3b1fbb64112baf39e9b214798c/app/controllers/scimitar/resources_controller.rb
https://github.com/RIPAGlobal/scimitar/blob/dc1d369a05df4fbe06562ce9f698279eeff5fb69/app/controllers/scimitar/active_record_backed_resources_controller.rb

Index アクションの実装

module ScimV2
  class UsersController < Scimitar::ResourcesController
    def index
      # SCIM では ページネーション機能を実装する必要があり、Response に startIndex, count, totalResult が必要である。
      # それらの値を scim_pagination_info メソッドで計算している  
      # https://github.com/RIPAGlobal/scimitar/blob/bb8fb75061c0e2cf1729dac28b2c90ddec6749b9/app/models/scimitar/engine_configuration.rb
      pagination_info = scim_pagination_info(query.count)
      page_of_results =
   query.offset(pagination_info.offset).limit(pagination_info.limit).to_a
      # 親クラスのメソッドでは、JSON 形式で Response を組み立ている
      super(pagination_info, page_of_results) do |record|
        record.to_scim(location: url_for(action: :show, id: record.id))
      end
    end
    
    private
    
    def query
      @query ||= if params[:filter].present?
                   # params[:filter] 例: "userName eq \"abcdefgh@atko.com\""
                   parser = ::Scimitar::Lists::QueryParser.new(storage_class.new.scim_queryable_attributes)
		   # RPN 記法に変換する(演算子が一番後ろにくるという浅い認識です)
		   # 例: ["userName", "\"abcdefgh@atko.com\"", "eq"]
                   parsed_query = parser.parse(params[:filter])
		   # Acitive Record のクエリが組み立てられる
		   # 引数には base scope を設定する。今回は全ユーザーを対象としている
                   parsed_query.to_activerecord_query(storage_class.all)
                 else
                   storage_class.all
                 end
    end

    # Scimitar::Resources::Mixin モジュールをインクルードしたモデルを指定
    def storage_class
      User
    end
  end
end

親クラスの定義は以下のようになっています。
https://github.com/RIPAGlobal/scimitar/blob/fe26bad339863b3b1fbb64112baf39e9b214798c/app/controllers/scimitar/resources_controller.rb#L46-L56
また、ブロックで呼び出しているto_scimメソッドは User モデルのインスタンスから SCIM 用のハッシュ形式への変換を行なっています。
https://github.com/RIPAGlobal/scimitar/blob/fe43f9f2cbf157d2b16732fc52cd8341fe1aefd2/app/models/scimitar/resources/mixin.rb#L333-L345
下記がレスポンスの例になります。

{
	"schemas": [
		"urn:ietf:params:scim:api:messages:2.0:ListResponse"
	],
	"totalResults": 7,
	"startIndex": 1,
	"itemsPerPage": 1,
	"Resources": [
		{
			"id": "6",
			"userName": "abmqtynjoj",
			"name": {
				"givenName": "Chase",
				"familyName": "Schuster"
			},
			"emails": [
				{
					"primary": true,
					"value": "jared.balistreri@rutherford-turcotte.net"
				}
			],
			"active": false,
			"meta": {
				"location": "https://93f5-240d-1e-458-7f00-f0de-20a6-f288-a94.ngrok-free.app/scim_v2/Users/6",
				"created": "2023-04-09T05:20:22Z",
				"lastModified": "2023-04-09T05:20:22Z",
				"resourceType": "User"
			},
			"schemas": [
				"urn:ietf:params:scim:schemas:core:2.0:User"
			]
		}
	]
}

show アクションの実装

親クラスの定義は以下のようにシンプルな実装になっています。
https://github.com/RIPAGlobal/scimitar/blob/fe26bad339863b3b1fbb64112baf39e9b214798c/app/controllers/scimitar/resources_controller.rb#L63-L66

実装は以下のようになります。

module ScimV2
  class UsersController < Scimitar::ResourcesController
    ...
    def show
      super do |user_id|
        user = storage_class.find(user_id)
        user.to_scim(location: url_for(action: :show, id: user_id))
      end
    end
  end
end

下記がレスポンスの例になります。

{
	"id": "6",
	"userName": "abmqtynjoj",
	"name": {
		"givenName": "Chase",
		"familyName": "Schuster"
	},
	"emails": [
		{
			"primary": true,
			"value": "jared.balistreri@rutherford-turcotte.net"
		}
	],
	"active": false,
	"meta": {
		"location": "https://93f5-240d-1e-458-7f00-f0de-20a6-f288-a94.ngrok-free.app/scim_v2/Users/6",
		"created": "2023-04-09T05:20:22Z",
		"lastModified": "2023-04-09T05:20:22Z",
		"resourceType": "User"
	},
	"schemas": [
		"urn:ietf:params:scim:schemas:core:2.0:User"
	]
}

次にユーザーが見つからない場合の処理を実装します。
Okta では以下のような形式でレスポンスが返ってくるのを期待しています。

{
    "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
    "detail": "User not found",
    "status": 404
}

scimitar には上記の形式でレスポンスを返すメソッドが用意されているので、ユーザーが見つからない場合にそちらのメソッドを呼び出します。
https://github.com/RIPAGlobal/scimitar/blob/9d183fd42bf3d07278054e3f670de1110a78dcab/app/controllers/scimitar/application_controller.rb#L31-L33

module ScimV2
  class UsersController < Scimitar::ResourcesController
    rescue_from ActiveRecord::RecordNotFound, with: :handle_resource_not_found
    ...
  end
end

Create 機能の実装

親クラスの実装は以下のようになっており、クエリパラメータの内容をScimitar::Schema::User を元にScimitar::Resources::Userのインスタンスを作成します。そして、そのリソースが有効であれば子クラスに渡されます。
https://github.com/RIPAGlobal/scimitar/blob/fe26bad339863b3b1fbb64112baf39e9b214798c/app/controllers/scimitar/resources_controller.rb#L79-L83
https://github.com/RIPAGlobal/scimitar/blob/fe26bad339863b3b1fbb64112baf39e9b214798c/app/controllers/scimitar/resources_controller.rb#L174-L188

子クラスの create アクションの実装は以下のようになります。

module ScimV2
  class UsersController < Scimitar::ResourcesController
    ... 
    def create
      super do |scim_resource|
        user = storage_class.new
        user.save_from_scim!(scim_resource)

        user.to_scim(location: url_for(action: :show, id: user.id))
      end
    end
  end
end

また、 user に対して呼び出している#save_from_scim!メソッドに関しては、User::OktaScimモジュールに実装します。この時、何らかの理由でデータの作成に失敗した場合(対象のユーザーが既に存在していたなど)は、409エラーを返すようにします。

module User::OktaScim
...

  def save_from_scim!(scim_resource)
    ActiveRecord::Base.transaction do
      from_scim!(scim_hash: scim_resource.as_json)
      save!
    end
  rescue ActiveRecord::RecordInvalid
    raise Scimitar::ErrorResponse.new(status: 409, detail: 'User already exists in the database.' )
  end
end

以上が全ての実装になります。Runscope を実行して、全てのテストが通れば完了です🥳

Okta 上にアプリケーションを作成し、動作確認を行う

アプリケーションの準備

  1. サイドメニューの Appications > Applicationsをクリックし、Create App Integrationを選択します。
  2. 認証はSAML 2.0を指定し、次へ進みます。
  3. 任意の名前を設定し、Addvisibilityのチェックを外して次へ進みます。
  4. 認証機能は使用しないので下図のように任意の値を設定し次へ進みます。
  5. 最後はI'm a software vendor. I'd like to integrate my app with Oktaの方を選択してFinishボタンを押して完了です。(作成したアプリケーションの詳細ページに遷移します)
  6. Generalタブをクリックし、 App Settings の項目にある Provisioning を有効にします。これで SCIM 機能が利用できるようになります。
  7. Provisioning タブをクリックし、SCIM Connection の設定を行います。設定を終えた後、保存を行います。
  8. Provisioningタブをクリックし、To Appを選択します。そして、設定内にあるCreate Usersを有効化して保存します。

動作確認

アプリへのユーザーの追加は、作成したアプリケーションのAssignmentsタブから行うことができます。

今回は予め用意していた 田中太郎というユーザーをアサインしてみたいと思います。

ログを確認すると以下のようになっており、users テーブルにデータが作成できたのが確認できます。また、 Okta の Create Users のフロー図と同じ処理が行われているのも確認できると思います。


https://developer.okta.com/docs/reference/scim/scim-20/

参考

https://github.com/RIPAGlobal/scimitar#data-models
https://developer.okta.com/docs/reference/scim/scim-20/

Discussion