aptpod Tech Blog

株式会社アプトポッドのテクノロジーブログです

SPA に OAuth 2.0 の認可フローを実装してみた

aptpod Advent Calendar 2022の20日目を担当します、intdash グループ フロントエンドエンジニアの佐藤です。

早速ですが、弊社では認可制御にOAuth 2.0 を採用しています。

tech.aptpod.co.jp

ブラウザのアプリケーションでこの認可制御をする際、Express 等を使ったバックエンドがある場合が多いかと思います。
弊社でもNext.js を用いて、認証を管理するバックエンドサービス (BFF) のある構成をとっています。
このバックエンドがある場合のパターンは、Node.js のクライアント・ライブラリやExmaple も多く、それに従えばおおよその実装はできるのではないでしょうか。(oauth2-proxy が有名でしょうか。)
バックエンドがある場合の方がセキュリティレベルは高く、一般的にこちらが採用されることが多いように思えます。

一方、バックエンドが無いアプリケーション、つまりブラウザ側のみで認証管理する方法は調べてもあまり有用な情報が出てこないことが多いです。
業務の中でブラウザ側のみで認証管理をする場合の実装が必要になり、実装方法について色々と調べましたので、その内容をご紹介します。

前提条件

実装方法

完成したコードの内容は以下のようになりました。

import {
    v4 as uuidv4
} from "uuid";
import pkceChallenge, {
    generateChallenge
} from "pkce-challenge";
import Cookies from "js-cookie";
import axios from "axios";
import {
    AuthMeApi,
    MeasDataPointsApi,
    AuthOAuth2Api
} from "../../intdash";


// クライアント ID
const CLIENT_ID = "XXXX-XXXX-XXXX-XXXX";
// 認可先のホスト
const AUTHORIZATION_HOST = "https://example.intdash.jp";
// リダイレクトURI
const REDIRECT_URI = "https://localhost:8443";
// PKCE の設定
const {
    code_verifier
} = pkceChallenge();
// state の作成
const state = uuidv4();

(async () => {
    // アクセス時のURL の検証
    const code = new URL(window.location.href).searchParams.get("code") ?? "";

    try {
        const authOAuth2Api = new AuthOAuth2Api({
            isJsonMime: () => true,
            basePath: `${AUTHORIZATION_HOST}/api`,
        });

        const {
            data: {
                access_token,
                expires_in
            },
        } = await authOAuth2Api.issueToken({
            grantType: "authorization_code",
            code,
            clientId: CLIENT_ID,
            redirectUri: REDIRECT_URI,
            codeVerifier: Cookies.get("code_verifier") ?? "",
        });

        // アクセストークンを取得できた場合、PKCE とstate の値を削除する
        Cookies.remove("code_verifier");
        Cookies.remove("state");

        if (typeof access_token !== "undefined") {
            Cookies.set("_bearer_token", access_token, {
                ...options,
                expires: expires_in,
            });
        }

        const authMeApi = new AuthMeApi({
            isJsonMime: () => true,
            basePath: `${AUTHORIZATION_HOST}/api`,
        });


        const {
            data: {
                name
            },
        } = await authMeApi.getMe({
            withCredentials: true,
        });

        document.querySelector < HTMLDivElement > (
            "#app"
        ) !.innerHTML = `<p>Hello ${name}</p>`;
    } catch (error) {

        Cookies.set("code_verifier", code_verifier, options);
        Cookies.set("state", state, options);

        const requestURL = new URL(
            `${AUTHORIZATION_HOST}/api/auth/oauth2/authorization`
        );
        requestURL.searchParams.set("client_id", CLIENT_ID);
        requestURL.searchParams.set("response_type", "code");
        requestURL.searchParams.set("redirect_uri", REDIRECT_URI);
        requestURL.searchParams.set("state", Cookies.get("state") ?? "");
        requestURL.searchParams.set(
            "code_challenge",
            generateChallenge(Cookies.get("code_verifier") ?? "")
        );
        requestURL.searchParams.set("code_challenge_method", "S256");

        document.querySelector < HTMLDivElement > (
            "#app"
        ) !.innerHTML = `<a href=${requestURL.href}>Connect Your Account</a>`;
    }
})();
  • 各エンドポイントはOpenAPI Generator でドキュメントから生成したクライアントを通して、リソースを取得しています

実装する処理の流れ

以下のようなフローで認可を行い、エンドポイントから自分の名前を取得するSPA を実装していきます。

なお、薄灰色の箇所は認可サーバーの役割になるので本記事では触れません。

  1. https://localhost:8443 にブラウザからアクセス
  2. 「Connect Your Account」のリンクをクリック
  3. https://example.intdash.jp へ認可リクエストを行う
  4. https://example.intdash.jp 上でユーザー名とパスワードを入れて認証
  5. https://localhost:8443 へcode を発行 (URL のクエリパラメーターにセット) しリダイレクト
  6. https://localhost:8443 上でトークン交換をリクエストする
  7. 問題なくトークンが発行できたらCookie に_bearer_token として値をセット
  8. Axios で自分の名前を取得するエンドポイントからデータを取得

UI フロー図
UI フロー図

環境構築

さくっと作りたかったのでVite を使ってみました。
HTTPS でlocalhost を起動したいのと、API のエンドポイントのホスト (https://example.intdash.jp) に対してProxy をしたいのでvite.config.js を編集します。

import { defineConfig } from "vite";
import fs from "fs";

export default defineConfig({
  server: {
    port: 8443,
    https: {
      key: fs.readFileSync("./.key.pem"),
      cert: fs.readFileSync("./.cert.pem"),
    },
    proxy: {
      "/api": {
        target: "https://example.intdash.jp",
        changeOrigin: true,
        configure: (proxy, options) => {},
      },
    },
  },
});

以下のコマンドを入力すると、https://localhost:8443 でローカルサーバーが立ち上がります。

yarn dev

これで、環境準備が整いました。

code_verifier とstate を保存する

認可リクエストをする前にcode_verifierstate を適切なブラウザ API を使用して任意の場所に保存します。 以下の例ではjs-cookie を利用して Cookie に保存しています。

import {
    v4 as uuidv4
} from "uuid";
import pkceChallenge, {
    generateChallenge
} from "pkce-challenge";
import Cookies from "js-cookie";

// PKCE の設定
const {
    code_verifier
} = pkceChallenge();
// state の作成
const state = uuidv4();

Cookies.set("code_verifier", code_verifier, options);
Cookies.set("state", state, options);

認可リクエスト先のURL を作成する

次に、認可リクエスト先の URL に以下をクエリパラメータとして追加します。
生成したURL をhref 属性に追加したa タグ を実装します。
ユーザーはこのa タグ をクリックすることで認可フローを開始します。

const requestURL = new URL(
    `${AUTHORIZATION_HOST}/api/auth/oauth2/authorization`
);
requestURL.searchParams.set("client_id", CLIENT_ID);
requestURL.searchParams.set("response_type", "code");
requestURL.searchParams.set("redirect_uri", REDIRECT_URI);
requestURL.searchParams.set("state", Cookies.get("state") ?? "");
requestURL.searchParams.set(
    "code_challenge",
    generateChallenge(Cookies.get("code_verifier") ?? "")
);
requestURL.searchParams.set("code_challenge_method", "S256");

document.querySelector < HTMLDivElement > (
    "#app"
) !.innerHTML = `<a href=${requestURL.href}>Connect Your Account</a>`;

認証コードをアクセストークンへ交換する

認可先で認証が承認されたら認証コードとともに指定されたリダイレクト URL にリダイレクトされます。

https://localhost:8443/?code=UeE7Adf6QxGDiKuKOngG0Gc9Nee3XhnxMnKYsNTqQ60.OJouZMWIrYDm6EYIpt7xbBMSYC6u7UouTCo30Yk-Uyk&remember_me=true&scope=add_any_edge_to_project%20admin%20anonymous%20authz%20config%3Ar%20config%3Arw%20edge%3Ar%20edge%3Arw%20group%3Ac%20group%3Al%20group%3Arw%20me%3Ar%20me%3Arw%20meas%3Ar%20meas%3Arw%20media%3Ar%20media%3Arw%20offline%20passwordpolicy%3Arw%20project%3Al%20project%3Arw%20projectedge%3Arw%20role%3Ar%20role%3Arw%20scope%3Arw%20screen%3Ar%20screen%3Arw%20sig%3Ar%20sig%3Arw%20stats%3Ar%20stream%3Ar%20stream%3Arw%20system%3Ar%20temporary%20user%3Ar%20user%3Arw%20v1_admin&state=0ebdee45-7178-4d86-8978-560bf49fa6e4

アクセストークンの認証コードを交換するために、トークンエンドポイントに対して POST リクエストを行います。
リクエストには次のパラメータが含まれます。

  • grant_type
    • 値はauthorization_code で固定されています
  • code
    • URL のクエリパラメータに含まれているcode を使用します
  • client_id
  • redirect_uri
    • 認可フローを開始したときと同じ値を使用します
  • code_verifier
    • 任意の場所に保存したcode_verifier を使用します
import {
    AuthOAuth2Api
} from "./intdash";


const authOAuth2Api = new AuthOAuth2Api({
    basePath: `https://example.intdash.jp/api`,
});

const {
    data: {
        access_token,
        expires_in
    },
} = await authOAuth2Api.issueToken({
    grantType: "authorization_code",
    code,
    clientId: CLIENT_ID,
    redirectUri: "https://localhost:8443",
    codeVerifier: Cookies.get("code_verifier") ?? "",
});

アクセストークンを取得した場合のサンプルデータです。

{
  "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImIyYjNkNTIwNzY0MDQ3MTAwMmY0ZWY4MjhjZjJlN2YyZmYwNDhkMzc0YzUwNGYyNTJiYmQ0ZTgwZWE4YzJhZGUiLCJ0eXAiOiJKV1QifQ.eyJzY3AiOlsiYWRkX2FueV9lZGdlX3RvX3Byb2plY3QiLCJhZG1pbiIsImFub255bW91cyIsImF1dGh6IiwiY29uZmlnOnIiLCJjb25maWc6cnciLCJlZGdlOnIiLCJlZGdlOnJ3IiwiZ3JvdXA6YyIsImdyb3VwOmwiLCJncm91cDpydyIsIm1lOnIiLCJtZTpydyIsIm1lYXM6ciIsIm1lYXM6cnciLCJtZWRpYTpyIiwibWVkaWE6cnciLCJvZmZsaW5lIiwicGFzc3dvcmRwb2xpY3k6cnciLCJwcm9qZWN0OmwiLCJwcm9qZWN0OnJ3IiwicHJvamVjdGVkZ2U6cnciLCJyb2xlOnIiLCJyb2xlOnJ3Iiwic2NvcGU6cnciLCJzY3JlZW46ciIsInNjcmVlbjpydyIsInNpZzpyIiwic2lnOnJ3Iiwic3RhdHM6ciIsInN0cmVhbTpyIiwic3RyZWFtOnJ3Iiwic3lzdGVtOnIiLCJ0ZW1wb3JhcnkiLCJ1c2VyOnIiLCJ1c2VyOnJ3IiwidjFfYWRtaW4iXSwidGVuIjowLCJleHAiOjE2NjY1OTAwOTAsImp0aSI6ImE3MjQ4ZTBjLTZjYzUtNDkyNS1hZTEyLTU0Mjk3ZDYyNjNiYiIsImlhdCI6MTY2NjU4NjQ5MCwiaXNzIjoiaHR0cHM6Ly9kZXYuaW50ZGFzaC5qcCIsInN1YiI6IjIwMGU0NTBlLTE4YzMtNGI1MS1hMzk2LWM4NmM5NjYxNjYxMiJ9.F6Obt776U0EupdVqKnCSN0fVX1xKzDXZNTOVVpTinWvwF60r4rooKA_X60N7Bu4y3p1V6g95DJFE25BFAsOZBNM0Pjv8yEFEevLP34kgCoydT80l5ODdgYK0M0k5O03-Cdnjd4_yKk2stoSmF2TMNEjWm47ho6XDVDapi41RddfYu7YUn5_NwQVKBUDHL1i5U1a5-wvcOD_42A9rw2_kxoExuYq3UWOg692ZcfaDdaTDNU_OxagjWSZ8_Lx44kQKdNLZu9tLJZI0DUtYSD43eTy1GusVnxVmKASNigYByPuYaXZU6rkEZV9NlHeTjchh73lvpUhOozjpTXfaaoLpcQ",
  "expires_in": 3599,
  "refresh_token": "EIe7ZiGH_USAAs-21En0NzbnxN3WTFPgmMX9UMDSCtE.jTW1O7yEypG_qkmPY1OT3l1mcsSntdZ9c5YwmFZd9RY",
  "refresh_token_expires_in": 2591999,
  "scope": "add_any_edge_to_project admin anonymous authz config:r config:rw edge:r edge:rw group:c group:l group:rw me:r me:rw meas:r meas:rw media:r media:rw offline passwordpolicy:rw project:l project:rw projectedge:rw role:r role:rw scope:rw screen:r screen:rw sig:r sig:rw stats:r stream:r stream:rw system:r temporary user:r user:rw v1_admin",
  "token_type": "bearer"
}

アクセストークンを保存する

適切なブラウザ API を使用して、アクセストークン(と、ある場合はリフレッシュトークン)を可能な限り安全に保存します。
以下の例では Cookie に保存しています。

Cookies.set("_bearer_token", access_token, {
  ...options,
  expires: expires_in,
});

アクセストークンを利用してリソースを取得する

import {
    AuthMeApi
} from "./intdash";

const authMeApi = new AuthMeApi({
    basePath: `https://example.intdash.jp/api`,
});


const {
    data: {
        name
    },
} = await authMeApi.getMe({
    withCredentials: true,
});

document.querySelector < HTMLDivElement > (
    "#app"
) !.innerHTML = `<p>Hello ${name}</p>`;

OAuth 2.0 で認証したのち、発行されたアクセストークンを使用してリソースが取得できました。

実装してみて

アクセス時のURL の検証や、Cookie に格納した値の適切なタイミングでの削除などバックエンドがあるときとは異なる点が多くありました。
リクエストURL の生成やトークン交換はNode.js 上で動くクライアントライブラリ (PKCE に対応しているclient-oauth2)があるので、そちらを使ったほうがコードがスッキリします。
例えば初回アクセス時やアクセストークンが交換できなかった際、認証先にリダイレクトをしたい場合はバックエンドだと以下のようなコードになります。

// 認証のためのインスタンスの作成
import ClientOAuth2 from "client-oauth2";

const intdashAuth = new ClientOAuth2({
    clientId: CLIENT_ID,
    accessTokenUri: `${AUTHORIZATION_HOST}/api/auth/oauth2/token`,
    authorizationUri: `${AUTHORIZATION_HOST}/api/auth/oauth2/authorization`,
    redirectUri: REDIRECT_URI,
    state,
});

const uri = intdashAuth.code.getUri({
    state,
    query: {
        code_challenge,
        code_challenge_method: "S256",
    },
});

res.redirect(uri);

結局、メリットはとくになかったですね。。。
バックエンドがどうしても用意できないときに、最終手段として使うくらいに考えたほうが良さそうです。