Laravel PassportのPKCE OAuthをやる

Laravel Passportを使ったPKCEを使った認可コード引き換えをするOAuthをやってみよう！
ノリで先行してやってしまって、あとから備忘録的に書いているので間違っている箇所があるかもしれません。

# 使ったもの
Laravel 7.22.2
Docker Desktop for Windows (on WSL2)

またフロントエンドにはNuxtをつかいました。
ユースケースとしてはLaravelをWeb APIサーバに、NuxtをWebクライアントに、という形ですが応用は効くかと思います。

# Laravelの準備
インストールや環境構築は飛ばします。
認可・認証機能でLaravel Mixを使うのでnpmコマンドを使えるように仕込む必要があります。

## 認証機能を準備する
[認証 7.x Laravel](https://readouble.com/laravel/7.x/ja/authentication.html)に従ってコマンドをポチポチしたりします。

### ルート定義
```shell
composer require laravel\ui
php artisan ui vue --auth
```

2行目のコマンドをタイプすると「`npm install`と`npm run dev`よろしく～」みたいなメッセージがでるのでやっておきましょう。

```shell
npm install
npm run dev
```

## Passportの準備
Laravel PassportはLaravel公式パッケージのひとつでOAuth2.0を使った認可とAPIの認証がLaravelで手早く行えるようになるものです。

### インストール
```shell
composer require laravel/passport
```

インストールするとマイグレーションされるテーブルが増えるので`php artisan migrate`します。
その後、認可に使われたりする暗号キーをつくるために`php artisan passport:install`をします。

```shell
php artisan migrate
php artisan passport:install
```

<details>
    <summary>プロダクション環境にデプロイする時には...</summary><div>
    開発環境でPassportを使いつつ開発をして、本番環境でデプロイした際、暗号キーの生成だけ必要なことがあると思います。

暗号キーは`.gitignore`でバージョニングされないよう指定されているし、外部へ公開されるべきでなく、プロダクション環境ではプロダクション環境用の暗号キーが必要になるでしょう。

その場合は以下のコマンドをタイプすることで暗号キーの生成のみを行うことができます。

```shell
php artisan passport:keys
```

</div>
</details>

### `User`、`AuthServiceProvider`、`config/auth.php`の書き換え
Userモデルで`HasApiTokens`トレイトを使うように追記してやります。

```php
<?php

namespace App;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Passport\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens, Notifiable;
}
```

そして`AuthServiceProvider`の`boot`メソッドにPassportの機能がルーティングされるように処理を追加します。

```php
<?php

namespace App\Providers;

use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Gate;
use Laravel\Passport\Passport;

class AuthServiceProvider extends ServiceProvider
{
    /**
     * アプリケーションのポリシーのマップ
     *
     * @var array
     */
    protected $policies = [
        'App\Model' => 'App\Policies\ModelPolicy',
    ];

    /**
     * 全認証／認可サービスの登録
     *
     * @return void
     */
    public function boot()
    {
        $this->registerPolicies();

        Passport::routes();
    }
```

最後に`config/auth.php`を書き換えて、API認証にPassportが使われるように仕向けます。

```php
'guards' => [
    'web' => [
        'driver' => 'session',
        'provider' => 'users',
    ],

    'api' => [
        'driver' => 'passport', // <- これ
        'provider' => 'users',
    ],
],
```

## Passportクライアントをつくる
`artisan`でつくります。
この時`--public`オプションを設定します。
Laravelはそもそも普通のWebアプリケーションを構築するフレームワークであるため、Passport自体もWebアプリケーション間で認可と認証をし合うように設計されているものです。

認可のやりとりに`client_secret`を利用することになるのですが、Web APIを利用するクライアントに`client_secret`を配信してしまってはマズいため、`--public`オプションを付け、`client_secret`を発行しないようにします。
これにより、PKCE (Proof Key for Code Exchange by OAuth Public Clients) 従った方法でしかPassportによる認可を受けられなくしているわけです。

```shell
php artisan passport:client --public
```

このコマンドを実行すると対話形式でセットアップがすすみます。
**どのユーザに関連づけるか**、**クライアントの名前**は何か、**認証後のリダイレクト先**はどこかと聞かれるので、適切な値を入れましょう。
認証後のリダイレクト先は後程作ります。クライアントの然るべきページのURIを設定してください。

クライアントの名前は後で晒されますので、あんまり変な名前を付けるとサービス利用者に笑われちゃいます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/273517/965a4959-d843-4d9b-cd7b-b0f0b7658cf1.png)
※晒し例

クライアントが作成されると`Client ID`と`Client Secret`が出ますが、Publicクライアントの場合`Client Secret`が空欄になっています。
**`Client ID`は後々使うので控えておきましょう！**
忘れちゃった場合はもう一度Passportクライアントを作り直すか、phpmyadminなどのツールでデータベースに格納されている値を見ることで確認ができます。

## CORSの設定
Laravelサーバとクライアント開発機内だったとしても、ポートが異なる場合はオリジンをまたぐことになるのでCORSを設定する必要があります。

LaravelのCORSについてはあまり深く理解できていないので、説明はこのリンク先にお任せします...
[CORS を許可する - Larapet](https://larapet.hinaloe.net/2020/03/05/laravel-cors/)

Laravelの`config/cors.php`の`paths`の値を変えます。
デフォルトでは`api/*`のみが設定されており、APIルート全体でオリジンをまたぐ通信を許すようになっています。
これに`oauth/token`を追加してください。

クライアントでは認可サーバでのユーザ認証後、クライアントにリダイレクトされ、トークンとの引き換え用コードが渡されます。
この引き換えコードとトークンを引き換えるときにオリジンをまたぐリクエストを送信することになるので、このレスポンスを読むためにこの設定が必要です。

---

ここまで来たらLaravelの設定は終わりです。

# クライアントの準備
Nuxtを使ったのでNuxtで説明していきます。

パスワード・メールアドレスの入力フォームはLaravel側のものを使います。
入力フォームのあるページへクライアントからリダイレクト、Laravelで認可を受けた後にリダイレクトでクライエントへ返される、という流れです。

## ページをつくる
認可・認証をするのに最低限2つのページが必要です。
1つ目はページを認可サーバのフォームへ置き換えるもの、2つ目は認可後に認可コードとトークンの引き換えを行うものです。
例として前者を`login.vue`、後者を`authenticated.vue`として`pages/`ディレクトリ配下に追加します。

### `login.vue`
```vue
<template>

</template>

<script lang="ts">
import {Component, Vue} from "nuxt-property-decorator";

@Component
export default class login extends Vue {
  async mounted() {
    const state = createRandomString(40);
    const verifier = createRandomString(128);
    const challenge = await AuthCode.makeCodeChallenge(verifier);

    sessionStorage.setItem('state', state);
    sessionStorage.setItem('verifier', verifier);

    location.replace(`http://localhost:8080/oauth/authorize?${this.transformQueryString({
      'client_id': '2',
      'redirect_uri': 'http://localhost:3000/authenticated',
      'response_type': 'code',
      'scope': '',
      'state': state,
      'code_challenge': challenge,
      'code_challenge_method': 'S256'
    })}`);
  }

  transformQueryString(obj: any) {
    let str = '';
    for (const it in obj) {
      str += `${encodeURIComponent(it)}=${encodeURIComponent(obj[it])}&`;
    }

    return str.slice(0, -1);
  }

  hash(message: string){
    const encoder = new TextEncoder();
    const data = encoder.encode(message);
    return crypto.subtle.digest('SHA-256', data);
  }

  async makeCodeChallenge(verifier: string) {
    const encoded = Buffer.from(new Uint8Array(verifier).buffer).toString("base64"));
    return encoded
        .replace(/\+/g, '-')
        .replace(/\//g, '_')
        .replace(/=/g, '')
  }

  createRandomString(num: number){
    return [...Array(num)].map(() => Math.random().toString(36)[2]).join('');
  }
}
</script>

<style scoped>

</style>
```

`state`と`code_verifier`を作り、後程ページ内で読める場所に保管します。
これを使う機会は認可後のリダイレクトを受けた時だけなのでsessionStorageが最適かと思います。

`state`はランダムな文字列を指定します。Passportの例が40文字だったのでそれに倣って40文字の適当な文字列を入れました。
`code_verifier`は[RFC 7636仕様](https://tools.ietf.org/html/rfc7636)で決められているのだそう。43文字から128文字の文字、数字、それと4種の記号を含んだランダムな文字列である必要があるそうです。
が、Passportの例では`Str::random(128)`を使っていたので、少々いい加減ですがjsで似たような感じにしました。

`code_challenge`は`code_verifier`のハッシュ値を作り、これをBase64エンコードしたものになります。

Passportの例はこんな感じです。

```php
$encoded = base64_encode(hash('sha256', $code_verifier, true));
$codeChallenge = strtr(rtrim($encoded, '='), '+/', '-_');
```

認可後のコード引き換え時に`code_verifier`をサーバ側でこの処理をしたものとクライアントが生成した`code_challenge`が比較されるのでこれと同じ動きをするものをクライアント側で書かなくてはなりません。

jsだとこんな感じです。
SHA256ハッシュ値生成を文字列にせず`ArrayBuffer`を保つのがポイントです。

```js
// ハッシュ値をつくる
const encoder = new TextEncoder();
const data = encoder.encode(message);
const hashed = await crypto.subtle.digest('SHA-256', data); // <- DOMが持つ関数ですが、Promiseが返ります

// base64エンコード
const encoded = btoa(String.fromCharCode(...new Uint8Array(await this.hash(verifier))));
const codeChallenge = encoded.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
```

これで下ごしらえは完了です。

`location.replace()`でページを置き換えます。
`location.replace()`を使うことで置き換え後のページはブラウザの履歴に残らず、このページで生成するいろいろな値が用意されない状態で認可サーバへ飛んでしまうことを防ぎます。

クエリパラメータとして以下のものが必要です。

```js
{
  'client_id': '1',  // <- Passportクライアントを作ったときに表示されたもの
  'redirect_uri': 'http://localhost:3000/authenticated', // <- Passportクライアントを作ったときに指定したもの
  'response_type': 'code',
  'scope': '', // <- 空だよ Laravelの設定で認証ユーザの権限が表現できるみたい
  'state': '...', // <- 先ほど作ったランダムな文字列
  'code_challenge': '...', // <- 先ほど作ったハッシュ化とかエンコードとかしたやつ
  'code_challenge_method': 'S256'
}
```

`client_id`はPassportクライアント作成後に表示された数字を指定してあげます。
`redirect_uri`はPassportクライアントをコマンドで作ったときに入力したURIを入力します。
これが一致しないと認可されません。

リダイレクトして処理を続けることで認可に必要だけど漏れてしまったらマズい情報を漏らさずに認可ができる、というわけです。

### `authenticated.vue`

```vue
<template>
  <div>
    引き換えコードをもらった
    <p v-if="!!token">
      {{ token }}
    </p>
  </div>
</template>

<script lang="ts">

import {Component, Vue} from "nuxt-property-decorator";
import axios from 'axios';

@Component
export default class authenticated extends Vue {
  token: string | null = null;

  mounted() {
    const state = sessionStorage.getItem('state');
    const verifier = sessionStorage.getItem('verifier');
    const code = this.$route.query.code;

    if (state != this.$route.query.state) {
      console.error('ステートが違う');
      return;
    }

    axios.post('http://localhost:8080/oauth/token', {
      grant_type: 'authorization_code',
      client_id: 2,
      redirect_uri: 'http://localhost:3000/authenticated',
      code_verifier: verifier,
      code: code
    }).then(({data}) => console.log(data))
  }
}
</script>

<style scoped>

</style>
```

`login.vue`でsessionStorageへ保存しておいた`state`と`code_verifier`を取り出します。
そして、リダイレクトURIのクエリパラメータに認可コードが含まれてますので`code`パラメータを参照し、取り出します。

次に、認可コード引き換えの規約としてクライアント側で`state`を検査する必要があるとのことなので、クエリパラメータの`state`とsessionStorageから取り出した`state`が一致するか検証します。

そして、認可処理の大詰め、認可コードとトークンの引き換えを行います。
Laravelサーバの`oauth/token`へPOSTリクエストを送信します。

必要なパラメータは以下の通り。

```js
{
  grant_type: 'authorization_code,
  client_id: 1, // <- Passportクライアントを作ったときに表示されたもの
  redirect_uri: 'http://localhost:3000/authenticated', // <- Passportクライアントを作ったときに指定したもの
  code_verifier: verifier, // <- sessionStorageから取り出したverifier
  code: code // <- クエリパラメータから取り出したcode
}
```

リクエストをサーバに送信するとCORSになるので、まずプリフライトがされ、航路が安全か確認されます。
そのあと、トークンのリクエスト本体が飛んで、トークンがレスポンスされます。

ここでCORSに引っかかったり、`code_verifier`がなんのといわれることがありますが、エラーレスポンスがしっかりしていて、原因の断定がしやすいかと思うので、頑張って解決しましょう！

# うごかす
クライアントの`login.vue`がマッピングされてるところにアクセスしてみます。
そしたらLaravelのログインページへリダイレクトされたような感じになって、ログインするとOAuthらしさのある「アクセスしちゃう？する？」みたいな画面になって、「Authorize」をクリックするとクライアントに戻ってきて、トークンが獲得できると思います。

---

以上**Laravel Passportを使ったPKCEを使った認可コード引き換えをするOAuth**でした！！
APIの認可はこうすべきだったのか...！

# 見たもの
[CORS を許可する - Larapet](https://larapet.hinaloe.net/2020/03/05/laravel-cors/)
[PHP: hash - Manual](https://www.php.net/manual/ja/function.hash.php)
[SubtleCrypto.digest() - Web API | MDN](https://developer.mozilla.org/ja/docs/Web/API/SubtleCrypto/digest)
[JavaScriptでバイナリを扱いたい - Qiita](https://qiita.com/kareharide/items/93f0257ee5c31e239e63)
[sessionStorageをつかってみる - Qiita](https://qiita.com/uralogical/items/ade858ccfa164d164a3b)