ShopifyアプリでOAuthを学ぶ

[OPNELOGI Advent Calendar](https://qiita.com/advent-calendar/2020/openlogi)の12日目です。
先日玄関のドアを開けたらドアが取れてしまった大島が担当します。

今回はOPENLOGIもパートナーとしてアプリを出させていただいているShopifyで
アプリ（OAuthクライアント）を実装してみて理解を深めたいと思います。

実装する言語、環境は下記を想定しています。

- PHP7.3
- Laravel 8.x
- ngrok 2.3

## 全体像の把握
はじめにShopifyにおけるOAuthの全体像を把握します。

OAuthとはサービス（Shopify）のリソースに外部アプリからアクセスしたいときに、
ユーザーがアプリに対して、どの権限を使っていいよ、というのを承認する仕組みです。

OAuth2.0自体の流れについては下記の記事が大変勉強になりました。
この記事で記載している各エンドポイントの名称もこちらを参考にさせていただいています。
https://qiita.com/TakahikoKawasaki/items/200951e5b5929f840a1f


Shopifyの実装は公式のチュートリアルで丁寧に説明されています。
https://shopify.dev/tutorials/authenticate-with-oauth
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180075/072608d7-07c7-3180-69a5-3b38c314cab2.png)

すごいざっくり意訳すると、登場人物としては下記の三者です

名前 | 役割
--- | ---
ストアオーナー | Shopifyで商品を販売するユーザー
アプリ | Shopifyのリソースを使いたい外部アプリ（OAuthクライアント）。ストアに対してインストールする形になります
SHOPIFY | 管理画面とユーザーの商品や注文（リソース）を管理します

流れは下記のようになると思います
![2020_12_07_wiki.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180075/d556c1f8-c6d6-5223-c5a6-a7ba4cab4f55.png)

## 実装
LaravelでOAuthクライアントを実装してみて流れを追っていきます

追記：実際にアプリを作成する場合、NodeかRailsであれば 
`Shopify App CLI` を利用すると↓の内容が一瞬でできてしまいます

### アプリの準備
まずはShopifyの開発用アカウントとテストアプリを用意します。

下記から開発者アカウントを作成し
https://www.shopify.jp/partners

`アプリ管理` にてアプリを作成します。作成する際にいくつかURLが要求されます。

![アプリ設定_-_sample_app_-_Shopify_パートナー.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180075/ef3b78f6-bba2-fece-b05f-4808aafe0677.png)

これから詳しく見ていきますが、下記のように使われます。

- `アプリURL` はインストールボタンを押したときにリクエストされ、**認可画面**へのリダイレクトをするエンドポイントです。
  - 認可画面とは「この権限でアクセストークン発行（アプリインストール）していい？」ってユーザーに承認を求める画面です。
- `リダイレクトURLの許可` には認可画面の承認後にリダイレクトされ、アクセストークンを要求する処理をするエンドポイントのホワイトリストです。
  - 実際のURL指定は`アプリURL` のレスポンスとして指定します。


### インストール操作
公開されているアプリであれば、Shopifyのアプリストアからインストールすることができます。
https://apps.shopify.com

今回は非公開のテストアプリなので、アプリの管理画面にて`開発ストアに対してテストする`という操作でインストールします。
![Shopify_パートナー.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180075/10e1ca95-582a-cf9b-16b3-86b1ec465032.png)

下記、それぞれのアクション毎に流れを追っていきます。

### ユーザーが`インストール`ボタンを押した〜認可画面の表示

- ユーザーがインストールボタンを押すと、アプリの `アプリURL` として設定したURLにリダイレクトされます。

![アプリ設定_-_sample_app_-_Shopify_パートナー.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180075/7af16a49-bc84-77b7-f441-0ebc159008a7.png)

このときアプリが受け取るリクエストパラメータ：

パラメータ名|意味
---|---
hmac|パラメータ改ざんの検証に利用
shop|ショップ名
timestamp|タイムスタンプ。hmacの検証に利用

アプリの実装としては、hmacをもとに渡されたパラメータが正しいことを検証し、（後述）
ユーザーに権限を承認してもらうための確認画面（**認可画面**）を出すようにリダイレクトします。

このリダイレクトのリクエストを**認可リクエスト**、エンドポイントを**認可エンドポイント**と呼びます

認可エンドポイント：
`GET: https://{shop}.myshopify.com/admin/oauth/authorize`

渡すパラメータ：

名前| 意味
---|---
client_id|アプリのID
scope | 認可するスコープ
redirect_uri | インストール後にリダイレクトするURI
state | リクエスト毎にユニークなランダムな文字列。

実装：

```php:route/api.php
Route::get('/shopify/auth', [ShopifyController::class, 'auth']);
```

```php:ShopifyController.php
    public function auth(Request $request)
    {
        if (!$request->has(['shop', 'timestamp', 'hmac'])) {
            return 'パラメータが不正です';
        }

        $shop = $request->get('shop');
        $scope = 'read_orders';

        // 認可後にリダイレクトされ、トークンを要求するエンドポイント
        // ngrokでローカルのエンドポイントを公開しています
        $redirectUri = 'https://ab8c0a13fc81.ngrok.io/api/shopify/token';

        $url = "https://{$shop}/admin/oauth/authorize?".http_build_query([
                'client_id' => env('SHOPIFY_API_KEY'),
                'scope' => $scope,
                'redirect_uri' => $redirectUri,
                'state' => bin2hex(random_bytes(16)),
            ]);

        if (!$this->verifyHmac($request->all())) {
            throw new InvalidArgumentException('パラメータが不正です');
        }


        return redirect($url);
    }
```

## 認可画面の表示
Shopifyは認可エンドポイントへリクエストがあるとユーザーへ **認可画面** を表示します。

![macaron-apptest___sample_appを承認___Shopify.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180075/53bf4e59-85ba-f6c5-5dc6-1a1ce340a609.png)

### ユーザーがインストールボタンをクリックする〜トークン発行
ユーザーが承認ボタンを押すと、まず**認可決定エンドポイント**へリクエストが送信されます。

```
POST: https://{shop}.myshopify.com/admin/oauth/grant
```

このリクエストでは、先ほど認可リクエストでパラメータを渡した`redirect_uri`のURLに**認可コード**と一緒にリダイレクトされます。
（あらかじめアプリ設定でホワイトリストに登録しておく必要があります。）

redirect_uri に渡されるリクエストパラメータ：

パラメータ名|意味
---|---
code| ユーザーが認可したことを示す認可コード。一回しか使えない
hmac|パラメータ改ざん検知に利用
shop|ショップ名。
state|リクエスト毎にユニークなランダムな文字列
timestamp|タイムスタンプ。hmacの検証に利用

下記を検証する必要があります

- stateが先ほど送ったものと一致する。
- hmacが正しい
- shop名が正しい（`myshopify.com`で終わっている。）

OKならShopifyの **トークンエンドポイント** にアクセストークンを要求するリクエストを送ります

```
POST https://{shop}.myshopify.com/admin/oauth/access_token
```
パラメータは下記:

パラメータ名|意味
---|---
client_id | アプリのid
client_secret | アプリのsecret(秘密鍵)
code: | 認証コード

実装：

```php:route/api.php
Route::get('/shopify/token', [ShopifyController::class, 'token']);
```

```php:ShopifyController.php
    public function token(Request $request)
    {
        [
            'code' => $authCode,
            'shop' => $shop,
        ] = $request->all();

        if (!preg_match('/[a-zA-Z0-9][a-zA-Z0-9\-]*\.myshopify\.com[\/]?/', $shop)) {
            throw new InvalidArgumentException('hostnameが不正です');
        }
        if (!$this->verifyHmac($request->all())) {
            throw new InvalidArgumentException('パラメータが不正です');
        }

        $response = Http::post("https://{$shop}/admin/oauth/access_token", [
            'client_id' => env('SHOPIFY_API_KEY'),
            'client_secret' => env('SHOPIFY_API_SECRET'),
            'code' => $authCode,
        ]);

        // responseからaccess tokenを保存する
    }
```

このレスポンスとしてついにアクセストークンを取得することができました。

```json
{"access_token":"shpat_1b144b80b6***************","scope":"read_orders"}  
```

以降はアクセストークンを提示してAPIアクセスを行うことで必要なリソースを取得することができます。

## リクエストパラメータの検証について
https://shopify.dev/tutorials/authenticate-with-oauth?locale=ja#verification

Shopifyからのリクエストには常に`hmac`パラメータが付与されており、
受け取る側（アプリ）ではそれを利用してパラメータが改ざんされていないことを検証する必要があります。

パラメータからhmacを除いたクエリ文字列と事前に共有済みの秘密鍵（`APIシークレットキー
`）をsha256でハッシュ化した文字列が、`hmac` と一致することでパラメータが正当であるとします。


### 実装例

```php
    private function verifyHmac(array $params): bool
    {
        // キーの辞書順にソートしておきます
        $parameters = collect($params)->sortBy(function ($_, $key) {
            return $key;
        })->toArray();

        $hmac = $parameters->pull('hmac');
        if (!$hmac) {
            return false;
        }
        // hmacを除いたクエリ文字列。
        // 例： shop=macaron-apptest.myshopify.com&timestamp=1607315468
        $queryString = http_build_query($parameters);

        $hash = hash_hmac(
            'sha256',
            $queryString,
            env('SHOPIFY_API_SECRET')
        );

        return $hash === $hmac;
    }
```

## 全体のリクエストの流れ
リクエストベースで見るとこんな流れになりました。

![2020_12_07_-_OPENLOGI_wiki.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/180075/217462aa-9386-2e79-c8c5-a2d7d28a0625.png)

## まとめ
ShopifyアプリとしてOAuth2.0の実装を行うことで理解を深めることができました。
Refresh tokenなど触れていない概念もあるので今後見ていきたいと思います。

## 参考
- Authenticate with OAuth
  - https://shopify.dev/tutorials/authenticate-with-oauth
- OAuth 2.0 全フローの図解と動画
  - https://qiita.com/TakahikoKawasaki/items/200951e5b5929f840a1f
- OAuth 2.0 の勉強のために認可サーバーを自作する
  - https://qiita.com/TakahikoKawasaki/items/e508a14ed960347cff11
- この記事に貼った内容がほとんどですが、実装したコードです
  - https://github.com/oshima0226/shopify-oauth-laravel-example