OAuth 2.0 のJavaScriptクライアントを作ってみる

OAuth 2.0 とか OpenID Connect の全体のフローを作ってみたいと思ったので、とりあえずOAuth 2.0で動くJavaScriptクライアントをSingle Page Applicationとして作ってみたいと思います。

OAuth 2.0 を利用する上で推奨されるセキュリティ上の対策がいくつかあると思うんですが、まずは単純な認可コードによる付与方式のクライアントを作っていこうかなと。なので、プロダクションとして使えるレベルのものではないです。後日、今回作ったものをベースに少しずつ機能なりを足していければいいかなといった感じです。OAuth 2.0 クライアントもどきだと思ってください。

認可コードフロー

OAuth 2.0 の認可コードフローを図にまとめてみました。よく忘れがちなんですよね...。こうやって図として自分で書いてみると、ちょっと理解が進む気がします。

f:id:bau1537:20210223124014j:plain

使用させていただいたアイコン Laptop Icon by Bambang Dewanto on Iconscout / Server Icon by Bambang Dewanto / Badge Icon by Dalpat Prajapati on Iconscout

今回はこのフローの中でフロントチャネルコミュニケーションと、バックチャンネルコミュニケーションの部分を作ってみようかなと思います。最終的に認可サーバーからアクセストークンを取得できればOKということになるわけですね。

  • フロントチャネルコミュニケーション:リソースオーナーのブラウザを通して認可サーバーとやり取りすること
  • バックチャンネルコミュニケーション:認可サーバーと直接やり取りすること

今回使う技術要素はこちら。

  • クライアントアプリケーション:Vue.js (version 2.6.11、下のpackage.jsonで詳しく書いてます)
  • 認可サーバー:Keycloak (docker image version 12.0.3)

Vueアプリケーションの作成

JavaScriptフレームワーク、Vueを使って作っていきます。OAuth対応のライブラリもいくつかあるのですが、今回は学習が目的なので、使いません。 package.json はこんな感じ。axios はバックチャンネルコミュニケーショのために使います。

Vue CLIvue create コマンドで作成したアプリケーションなので特に変わった設定などはありません。

{
  "name": "vue-oauth-client",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "serve": "vue-cli-service serve",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint"
  },
  "dependencies": {
    "axios": "^0.21.1",
    "core-js": "^3.6.5",
    "vue": "^2.6.11",
    "vuex": "^3.4.0"
  },
  "devDependencies": {
    "@vue/cli-plugin-babel": "~4.5.0",
    "@vue/cli-plugin-eslint": "~4.5.0",
    "@vue/cli-plugin-vuex": "~4.5.0",
    "@vue/cli-service": "~4.5.0",
    "babel-eslint": "^10.1.0",
    "eslint": "^6.7.2",
    "eslint-plugin-vue": "^6.2.2",
    "vue-template-compiler": "^2.6.11"
  },
  "eslintConfig": {
    "root": true,
    "env": {
      "node": true
    },
    "extends": [
      "plugin:vue/essential",
      "eslint:recommended"
    ],
    "parserOptions": {
      "parser": "babel-eslint"
    },
    "rules": {}
  },
  "browserslist": [
    "> 1%",
    "last 2 versions",
    "not dead"
  ]
}

フロントチャネルコミュニケーションの実装

フロントチャネルコミュニケーションで認可サーバーから認可コードを受け取るまでを作っていきます。まずは認可サーバーの認可エンドポイントへリソースオーナーをリダイレクトさせます。その際にURLのリクエストパラメーターに必要な情報をセットしておきます。

OAuth の各アクターがコミュニケーションする際の内容はこちらの記事にわかりやすく載っていました。OAuth でわからなければこの記事の著者で検索すれば大抵のことはわかりやすく書いてくださっているので、とても助かります。

qiita.com

認可サーバーへ渡す情報はRFC6749のこちらのセクションにあります。

https://openid-foundation-japan.github.io/rfc6749.ja.html#code-authz-req

まとめるとこんな感じ。

  • response_type:レスポンスタイプ。認可コードによる付与方式の場合は code で固定。
  • scope:要求するアクセストークンが保持するスコープ。
  • clientId:クライアントID。
  • redirect_uri:リダイレクトURL。

リダイレクトURL

この中で少し気になったのが redirect_uri です。今回作成しているのはブラウザベースで動くアプリケーションであり、この種類のアプリケーションはクライアントシークレットを持つことができません。このような種類のアプリケーションはOAuthでは公開クライアント(Publicクライアント)として定義されています。ブラウザベースで動くアプリケーションの他にもスマホで動くアプリケーションや、PCでネイティブ動作するアプリケーションも公開クライアントです。

f:id:bau1537:20210223162526j:plain

公開クライアントはRFC6749のOAuth 2.0仕様において、正しいリダイレクトURLを事前に設定しておくことが求められています。事前設定されたURLと異なるURLをリクエストパラメーターにセットしてリダイレクトするとエラーが返却され、認可コードを受け取ることができない仕様になっています。

https://openid-foundation-japan.github.io/rfc6749.ja.html#redirect-uri

認可サーバーは, 以下のようなクライアントに対してリダイレクトエンドポイントの事前登録を要求すること (MUST):

パブリッククライアント. インプリシットグラントタイプを利用するコンフィデンシャルクライアント.

ちなみに不正なリダイレクトURLを渡すと、Keycloakはこんなエラー画面を表示します。

f:id:bau1537:20210223170840p:plain

なんでこんな仕様になっているかというと redirect_uri を認可サーバーへ渡す時に自由に設定できてしまったらクライアントをなりすましてアクセストークンを取得できてしまうので、ここは制限をかける必要があるということですね。

Keycloakのドキュメントの public または Valid Redirect URIs のセクションにこれらの詳細が書いてあります。

https://keycloak-documentation.openstandia.jp/3.4/ja_JP/server_admin/index.html#oidc%E3%82%AF%E3%83%A9%E3%82%A4%E3%82%A2%E3%83%B3%E3%83%88

また、こちらにもその記載がるので一読しておくといいかと思います。

https://keycloak-documentation.openstandia.jp/3.4/ja_JP/server_admin/index.html#_oidc-auth-flows

このあたりの話はブラウザベースで動くアプリケーションがOAuthを利用する際の設定事項を制定しているRFCにもちらっと載っているので、後でこのあたりも読んでおきたいですね。ちなみにこちらのRFCは現段階ではドラフト版なので内容が変わる可能性があることに注意です。

https://tools.ietf.org/html/draft-ietf-oauth-browser-based-apps-07

OAuth 2.0 authorization servers:

o MUST Require exact matching of registered redirect URIs

その他、公開クライアントはクライアントシークレットを持つことができない点から、PKCEなど、安全にOAuthのフローを利用する方法などが提案されているようです。(今回はやりませんが)

Vueアプリケーションから認可サーバーへリダイレクトさせる

では、アプリケーションコードを書いていきます。やることを整理すると、リソースオーナーをリダイレクトさせ(1)、Keycloakで認証認可を終えて帰ってきた時に認可コードを受け取ります(2)。

f:id:bau1537:20210223164023j:plain

認可エンドポイントはロジックの中に埋め込んでしまうと保守上よろしくないので、configファイル的な役割のファイルに定義します。

// authorizationServer.js
export default {
    authorizationEndpoint: 'http://localhost:8081/auth/realms/vue-oauth-client-realm/protocol/openid-connect/auth',
}

ボタンを持つVueコンポーネントを定義し、ボタンがクリックされると認可サーバーへリダイレクトするようにします。後で設定しますが、Vuexを使用してアクセストークンを保持しているか、いないかを $store.state.isLogin で管理しています。(ちょっと名前が変かもです。OIDCにつられてログインと書いてます)

<template>
  <div>
    <button @click="redirectToKeycloak" :disabled="$store.state.isLogin">Keycloakによるアクセストークンの取得</button>
  </div>
</template>

<script>
import authorizationServer from "@/config/authorizationServer";

const response_type = 'code';
const scope = 'vue-oauth-client';
const clientId = 'vue-oauth-client';
const redirect_uri = 'http://localhost:8080';

export default {
  name: "Login",
  methods: {
    redirectToKeycloak() {
      console.log("redirect to keycloak");
      location.href = `${authorizationServer.authorizationEndpoint}`
          + `?response_type=${response_type}`
          + `&scope=${scope}`
          + `&client_id=${clientId}`
          + `&redirect_uri=${redirect_uri}`;
    }
  }
}
</script>

アプリケーションとしてはこれで動きますが、Keycloakの設定が事前にされていないとエラーが帰ってきます。Keycloakの設定は大まかに以下の通りに設定しました。

  • vue-oauth-client-realm realmを作成。
  • vue-oauth-client clientを作成。
  • vue-oauth-client clientのリダイレクトURLをVueアプリケーションが起動しているhttp://localhost:8080に設定。
  • vue-oauth-client scopeを作成し、上記のclientのclient scopeに関連付け。
  • bob userを作成し、パスワードを password に設定。

他の設定はデフォルトのままです。

では動かしてみましょう。

npm serve によりアプリを起動させるとlocalhost:8080で開くことができます。

f:id:bau1537:20210223171630p:plain

先ほど作成したボタンをクリックすると、javascriptによりリダイレクトされ、Keycloakの認可エンドポイントへ遷移します。

f:id:bau1537:20210223171731p:plain

bob で Sign In すると、元のアプリケーションにリダイレクトされ、その際にURLに認可コードがセットされています。

f:id:bau1537:20210223171854p:plain

認可コードを受け取ったら、バックチャンネルコミュニケーションでアクセストークンを要求します。

バックチャンネルコミュニケーション

アクセストークンを要求するにはバックチャンネルコミュニケーションでクライアントと認可サーバーが直接やり取りします。

f:id:bau1537:20210224180532j:plain

Vueのコードはこちらになります。

トークンエンドポイントを設定ファイルに追記します。

// authorizationServer.js
export default {
    authorizationEndpoint: 'http://localhost:8081/auth/realms/vue-oauth-client-realm/protocol/openid-connect/auth',
    tokenEndpoint: '/auth/realms/vue-oauth-client-realm/protocol/openid-connect/token',
}

ここで http://localhost:8080 がついていませんが、これはブラウザの同一オリジンポリシーによりVueのアプリから認可サーバーへリクエストが飛ばせないので、Vueの開発サーバーをプロキシする関係上このようにしています。Vueの開発サーバーはこのような設定にしています。

// vue.config.js
module.exports = {
    devServer: {
        proxy: 'http://localhost:8081'
    }
}

肝心のリクエストを行う処理はこんな感じ。認可サーバーからリダイレクトされ、Vueが初期化されるタイミングでURLを検査し、認可コードが有る場合は fetchAccessToken 関数でトークンエンドポイントに向けてaxiosを使ってリクエストを投げてます。

<template>
  <div id="app">
    <img alt="Vue logo" src="./assets/logo.png">
    <Login/>
  </div>
</template>

<script>
import Login from "@/components/Login";
import axios from 'axios';
import store from '@/store'
import authorizationServer from "@/config/authorizationServer";

function fetchAccessToken(searchUrl) {
  console.log('redirected from keycloak');
  searchUrl.split('&').forEach((e) => {
    const redirectedParameter = e.split('=');
    console.log(`key: ${redirectedParameter[0]}, value: ${redirectedParameter[1]}`);

    if (redirectedParameter[0] === 'code') {
      const code = redirectedParameter[1];

      console.log('get access token to keycloak');
      const params = new URLSearchParams();
      params.append('grant_type', 'authorization_code');
      params.append('code', code);
      params.append('redirect_uri', 'http://localhost:8080')
      params.append('client_id', 'vue-oauth-client');

      axios.post(authorizationServer.tokenEndpoint, params)
          .then(response => {
            console.log(response);

            const accessToken = response.data.access_token;
            console.log(`access token: ${accessToken}`);

            store.commit('loginSuccess', {accessToken});
          })
          .catch(err => {
            console.log(err);
          });
    }
  });
}

export default {
  name: 'App',
  components: {
    Login
  },
  beforeCreate() {
    const searchUrl = location.search.substring(1);

    if (searchUrl !== '') {
      fetchAccessToken(searchUrl);
    }
  }
}
</script>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>

ここで、アクセストークンをコンソールログに出力していますがこれはアクセストークンが外に漏れる可能性が高くなり、大変危険なのでプロダクションでは絶対にやってはだめです。今回は学習目的で出力しています。

実際にアプリを動かしてみるとこんな感じになります。アクセストークンが取得できているのがわかりますね。

f:id:bau1537:20210224182410p:plain

とりあえず、今回はここまで。