Ruby on Rails × Shopify Multipass APIでSSO（シングル・サイン・オン）を試してみる

## 概要

業務の一環でShopifyのMultipass APIを使いSSO（シングル・サイン・オン）を実装する機会があったので、手順などについてメモ書きしておきたいと思います。

<font color="Red">SSO: 単一の資格情報（IDやメールアドレス）で複数のWebサービスにログインできる仕組みの事。</font>

![Untitled Diagram(8).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/a9f66fb5-2e06-a6df-0779-cc018ffab3d5.png)

**全体的な流れ**

- Shopify側でMultipassを有効化しシークレットキーを発行。
- SSO基盤側でシークレットキーをもとに暗号化キーと署名キーを抽出。さらに顧客情報（メールアドレス、ユーザーID、IPアドレス、名前、住所など）を暗号化したトークンを生成し、それらを含めたGETリクエストをShopify側へ送信。（GET: https://ストアのドメイン/account/login/multipass/トークン）
- 認証に成功するとマイページに遷移。


## 環境

- Shopify Plus
- Docker
- Ruby 2.6
- Rails 6
- MySQL 8

まず大前提として、Shopify Plusプランに加入している必要があるのでご注意ください。（Multipass APIを利用できるのはShopify Plusのみなので）

> The Multipass login feature is available to Shopify Plus merchants only.
> https://shopify.dev/docs/admin-api/rest/reference/plus/multipass

アプリケーション側の実装については、Dockerで簡単なRailsアプリを準備します。

## 下準備

まず、Shopify側でMultipass APIを有効化しなければなりません。

![スクリーンショット 2021-04-10 16.24.40_censored.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/30d5e08b-fbb5-29b0-968f-036ecee16f2a.jpeg)

Shopify管理画面の左下から「設定」→「チェックアウト」と進み、「顧客アカウント」を任意もしくは必要とした上で「マルチパスを有効にする」をクリックします。

するとシークレットキーが表示されるはずなので、メモに控えておいてください。（後ほどアプリケーション側の実装を行う際に使用します。）

## 実装

次にアプリケーション側の実装に移ります。

### ディレクトリを作成

```
$ mkdir shopify-multipass-api-on-rails
$ cd shopify-multipass-api-on-rails
```

### 各種ファイルを作成

```
$ touch Dockerfile
$ touch docker-compose.yml
$ touch Gemfile
$ touch Gemfile.lock
```

```dockerfile:./Dockerfile
FROM ruby:2.6.6
ENV LANG C.UTF-8
RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs

RUN curl -sL https://deb.nodesource.com/setup_8.x | bash - && \
apt-get install nodejs

RUN apt-get update && apt-get install -y curl apt-transport-https wget && \
curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \
echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list && \
apt-get update && apt-get install -y yarn

ENV APP_PATH /myapp

RUN mkdir $APP_PATH
WORKDIR $APP_PATH

ADD Gemfile $APP_PATH/Gemfile
ADD Gemfile.lock $APP_PATH/Gemfile.lock
RUN bundle install

ADD . $APP_PATH
```

```yml:./docker-compose.yml
version: "3"
services:
  db:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: password
    command: --default-authentication-plugin=mysql_native_password
    volumes:
      - mysql-data:/var/lib/mysql
    ports:
      - 3306:3306
  web:
    build:
      context: .
    command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
    volumes:
      - .:/myapp
      - ./vendor/bundle:/myapp/vendor/bundle
    environment:
      TZ: Asia/Tokyo
      RAILS_ENV: development
    ports:
      - 3000:3000
    depends_on:
      - db

volumes:
  mysql-data:
```

```ruby:./Gemfile
source "https://rubygems.org"
gem "rails", "~>6"
```

```./Gemfile.lock
# 空欄でOK
```

### Railsプロジェクトを作成

```
$ docker-compose run web rails new . --force --no-deps --database=mysql --skip-test --webpacker
```

Gemfileが更新されたので再度ビルド。

```
$ docker-compose build
```

### データベースを作成

```yml:./config/database.yml
default: &default
  adapter: mysql2
  encoding: utf8mb4
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  username: root
  password: password # デフォルトだと空欄になっているはずなので変更
  host: db # デフォルトだとlocalhostになっているはずなので変更
```

「./config/database.yml」を変更し、データベースを作成。

```
$ docker-compose run web rails db:create
```

### 動作確認

コンテナを起動。

```
$ docker-compose up -d
```

![スクリーンショット 2021-04-10 18.19.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/cd5b3fe4-c751-93b2-aded-6f68cb92e5bf.png)

[http://localhost3000](http://localhost:3000) にアクセスしていつもの画面が表示されればOKです。

### 認証機能を作成

Deviseなどを使って新規登録/ログインを行うための認証機能を作成していきます。

```ruby:./Gemfile
gem 'devise'
```

Gemfileを更新したので再度ビルド。

```
$ docker-compose build
```

#### Deviseをインストール

```
$ docker-compose run web rails g devise:install

...

create  config/initializers/devise.rb
create  config/locales/devise.en.yml
```

config以下のファイルが追加されたので、コンテナを再起動します。

```
$ docker-compose down
$ docker-compose up -d
```

#### modelを作成

```
$ docker-compose run web rails g devise user

...

Running via Spring preloader in process 18
      invoke  active_record
      create  db/migrate/20210410093331_devise_create_users.rb
      create  app/models/user.rb
      insert  app/models/user.rb
      route   devise_for :users
```

```
$ docker-compose run web rails db:migrate
```

#### 各種controllerを作成

```
$ docker-compose run web rails g devise:controllers users

...

Running via Spring preloader in process 18
      create  app/controllers/users/confirmations_controller.rb
      create  app/controllers/users/passwords_controller.rb
      create  app/controllers/users/registrations_controller.rb
      create  app/controllers/users/sessions_controller.rb
      create  app/controllers/users/unlocks_controller.rb
      create  app/controllers/users/omniauth_callbacks_controller.rb
```

#### 動作確認

![スクリーンショット 2021-04-10 18.47.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/f6c77801-21ba-1c9d-3a71-2a3358fc32de.png)

[http://localhost:3000/users/sign_up](http://localhost:3000/users/sign_up) にアクセスしして「Sigin up」ページが表示されれば成功です。

#### home_controllerを作成

```
$ docker-compose run web rails g controller home index

...

Running via Spring preloader in process 18
      create  app/controllers/home_controller.rb
      route  get 'home/index'
      invoke  erb
      create  app/views/home
      create  app/views/home/index.html.erb
      invoke  helper
      create  app/helpers/home_helper.rb
      invoke  assets
      invoke  scss
      create  app/assets/stylesheets/home.scss
```

```ruby:./config/routes.rb
Rails.application.routes.draw do
  root "home#index"
  devise_for :users
end
```

```erb:./app/views/home/index.html.erb
<h1>Home</h1>

<% if user_signed_in? %>
  <p><%= link_to "Sign out", destroy_user_session_path, method: :delete %></p>
  <p><%=  current_user.email %></p>
<% else %>
  <p><%= link_to "Log in", new_user_session_path %> / <%= link_to "Sign up", new_user_registration_path %></p>
<% end %>
```

![スクリーンショット 2021-04-10 18.59.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/ec427cae-4b11-238c-a9e5-fe24118ae604.png)

[http://localhost:3000/](http://localhost:3000/) にアクセスして「Home」ページが表示されていればOKです。

![スクリーンショット 2021-04-10 19.00.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/4f6fe8b7-b7f7-2e6d-36db-b1cc76d9ac99.png)

試しに適当なメールアドレス・パスワードでユーザーを作成してみましょう。

![スクリーンショット 2021-04-10 19.01.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/9aa95a1e-fcfc-765a-3a9a-e5dcda3e1e41.png)

ユーザー作成に成功すると「Home」ページに遷移するはず。メールアドレスが表示されている事からしっかりログイン状態になっているのも確認できますね。

### Shopify Multipass APIを導入

最低限の認証機能が準備できたので、いよいよShopify Multipass APIを導入していきます。

#### ライブラリを作成

```
$ touch lib/shopify_multipass.rb
```

```ruby:./lib/shopify_multipass.rb
require "openssl"
require "time"
require "json"
require "base64"

class ShopifyMultipass
  attr_accessor :encryptionKey, :signingKey

  # 暗号化キーと署名キーを生成する
  def initialize(multipass_secret = nil)
    return if multipass_secret.blank?

    block_size = 16

    hash = OpenSSL::Digest.new("sha256").digest(multipass_secret)
    self.encryptionKey = hash[0, block_size]
    self.signingKey = hash[block_size, 32]
  end

  # 顧客情報を暗号化してトークンを生成する
  def generate_token(customer_data_hash)
    return if !customer_data_hash

    customer_data_hash["created_at"] = Time.now.iso8601
    cipherText = self.encrypt(customer_data_hash.to_json)

    Base64.urlsafe_encode64(cipherText + self.sign(cipherText))
  end

  def encrypt(plaintext)
    cipher = OpenSSL::Cipher.new("aes-128-cbc")
    cipher.encrypt
    cipher.key = self.encryptionKey

    cipher.iv = iv = cipher.random_iv

    iv + cipher.update(plaintext) + cipher.final
  end

  def sign(data)
    OpenSSL::HMAC.digest("sha256", self.signingKey, data)
  end

  # Shopify側で認証を行うための顧客情報とトークンが入ったURLを生成する
  def generate_url(customer_data_hash, domain)
    return if !domain
    return "https://" + domain + "/account/login/multipass/" + self.generate_token(customer_data_hash)
  end
end
```

lib/以下を読み込むために、「./config/application.rb」に次の1行を追加します。

```ruby:./config/application.rb
config.autoload_paths += %W(#{config.root}/lib)
```

config以下のファイルが追加されたので、コンテナを再起動します。

```
$ docker-compose down
$ docker-compose up -d
```

#### shopify_multipass_controllerを作成

```
$ docker-compose run web rails g controller shopify_multipass confirm login

....

Running via Spring preloader in process 18
      create  app/controllers/shopify_multipass_controller.rb
      route  get 'shopify_multipass/confirm'
get 'shopify_multipass/login'
      invoke  erb
      create  app/views/shopify_multipass
      create  app/views/shopify_multipass/confirm.html.erb
      create  app/views/shopify_multipass/login.html.erb
      invoke  helper
      create  app/helpers/shopify_multipass_helper.rb
      invoke  assets
      invoke  scss
      create  app/assets/stylesheets/shopify_multipass.scss
```

```ruby:./app/controllers/shopify_multipass_controller.rb
class ShopifyMultipassController < ApplicationController
  def confirm
  end

  def login
    # Shopifyへ渡す顧客情報（必須項目はメールアドレスでそれ以外は任意）
    customer_data = {
      email: current_user.email,
      identifier: current_user.id
    }

    shopify_multipass = ShopifyMultipass.new("マルチパスのシークレットキー")

    # 次のようなURLが作成される 「https://<ストアのドメイン>/account/login/multipass/<トークン>」
    url = shopify_multipass.generate_url(customer_data, "ストアのドメイン")
    redirect_to url
  end
end
```

**customer_dataに含める事ができる値**

- email: メールアドレス
- first_name: 名
- last_name: 姓
- tag_string: タグ
- identifer: UID
- return_to: 認証後の遷移先（何も指定しない場合は「ストアのドメイン/account」ページに飛ぶ）
- remort_ip: IPアドレス
- addresses: 届け先住所

※メールアドレスのみ必須項目でそれ以外は任意。（今回の例ではユーザーIDをidentiferとして渡しています。）

より詳細な情報は公式ドキュメントを参照。
https://shopify.dev/docs/admin-api/rest/reference/plus/multipass

```erb:./app/views/shopify_multipass/confirm.html.erb
<h1>ShopifyMultipass</h1>
<p><%= link_to "Multipass login", login_multipass_redirect_path %></p>
```

```ruby:./config/routes.rb
authenticate :user do
  get "login/multipass", to: "shopify_multipass#confirm"
  get "login/multipass/redirect", to: "shopify_multipass#login"
end
```

「authenticate :user do ~ end」で囲み、ログイン済みのユーザーだけがアクセスできるようにしておきます。


#### 動作確認

![スクリーンショット 2021-04-10 19.43.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/920cb4cc-134e-4177-a10d-bdafb0fb30a3.png)

[http://localhost:3000//login/multipass](http://localhost:3000//login/multipass) にアクセスし、「Multipass login」をクリックしてみましょう。

![スクリーンショット 2021-04-10 19.47.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/d97a57d3-aa04-6374-38a5-1ee9b722ab8b.png)

上手くいくとマイページ（ストアのドメイン/account）に遷移するはずです。

![スクリーンショット 2021-04-10 19.48.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/63d908ce-104d-2b2e-9d1b-f70027659bcb.png)

Shopify管理画面からもMultipass API経由でユーザーがログインされている事が確認できました。

## Tips

これで最小限の実装は完了しましたが、実運用を想定した場合、個人的に気をつけた方が良いと思う点がいくつかあるので記述しておきます。

### メールアドレスの整合性について

何よりもまず気になるのがShopify ⇄ SSO基盤間におけるメールアドレスの整合性です。

というのも、両者は別にデータベースを共有しているわけではないため、たとえばSSO基盤側のメールアドレスを変更した際、同時にShopify側のメールアドレスも変更される仕組みを作っておかないと整合性が取れなくなり、全く別のユーザーとしてログインする事になってしまいます。（Shopifyではメールアドレスをユニーク識別子としているため。）

主な解決方法としては、Shopifyが提供しているCustomer APIを使うのが良さげです。
https://shopify.dev/docs/admin-api/rest/reference/customers/customer

実際、自分が携わっているサービスでは、メールアドレス変更時に上記APIを叩いてShopify側が保持しているメールアドレスも変更する事で整合性を保つようにしています。

### ログイン後の遷移先について

Multipass APIを利用したログイン後の遷移先を「return_to」で指定できるというのは先述した通りです。

何も指定していないデフォルトの状態だとマイページに飛ぶようになっていますが、良くあるような「ショッピングカート」→「ログイン」→「ショッピングカート（に戻る）」といったフローを実現したい場合はクエリパラメータなどで遷移先のURLを上手く拾ってあげる必要があります。

たとえば、

```
http://localhost:3000/login/multipass?redirect_uri=ショッピングカートのURL
```

といった感じで、Shopify → SSO基盤への遷移時にクエリパラメータとしてショッピングカートのURLを渡しておけば、あとは

```ruby:./app/controllers/shopify_multipass_controller.rb
class ShopifyMultipassController < ApplicationController
  def confirm
  end

  def login
    # Shopifyへ渡す顧客情報（必須項目はメールアドレスでそれ以外は任意）
    customer_data = {
      email: user.email,
      identifier: user.id,
    }

    # クエリパラメータ「redirect_uri」が含まれていた場合は拾う。
    uri = URI(request.referer) if request.referer

    if uri && uri.query.present?
      q_array = URI::decode_www_form(uri.query)
      q_hash = Hash[q_array]

      customer_data["return_to"] = q_hash["redirect_uri"] if q_hash["redirect_uri"].present?
    end

    shopify_multipass = ShopifyMultipass.new("マルチパスのシークレットキー")

    # 次のようなURLが作成される 「https://<ストアのドメイン>/account/login/multipass/<トークン>」
    url = shopify_multipass.generate_url(customer_data, "ストアのドメイン")
    redirect_to url
  end
end
```

「request.referer」を使う事でその値を「return_to」内に含める事ができます。

## あとがき

以上、Shopify Multipass APIを使ったSSOを試してみました。今回紹介したコードはあくまでサンプルなので、実運用を想定した場合は何かと不備があるかもしれませんが、その辺はご了承ください。

Tipsでも取り上げているように、色々と工夫しなければならない点はあるので、ご自身のプロジェクトの仕様などを踏まえた上で試行錯誤していただければと思います。

今回作成したアプリのソースコード: https://github.com/kazama1209/shopify-multipass-api-on-rails