フューチャー技術ブログ

MSAL.jsを使ってウェブフロントエンドだけでAzureAD認証する

11/30更新 スコープを["User.Read"]としていましたが、['{クライアントID}/.default']にしないと署名がvalidなトークンにならないという罠がありましたので修正しています。

AzureADを使って認証を行っている企業は多いと思います。このAzureADを使った場合にはMSAL.jsを使えば認証は楽だぞ、というのはAzureADのサイトには書かれているのですが、OpenID Connectのプロトコルの動きの理解と、ライブラリのAPIがどう対応づいているのかがわからずにちょっと試行錯誤したので、そのメモを残しておきます。

OAuth 2.1(現在策定中)ではImplicit Code Flowが非推奨になり、Authorization Code FlowにPKCEが追加されて、コード横取り攻撃への耐性が強化されて、モバイルアプリケーションや、ウェブフロントエンドなどのパブリッククライアント(ユーザー側で動作するため攻撃者が自由にいじれる)でも安全に認証できるようになります。OAuth 2.1自体はまだ作業中ではありますが、これはOAuth 2.0から少しずつ追加されたアップデートをまとめたバージョンであり、現在でもこれらの機能は使えます。

MSAL.js 2.0というMicrosoft製のライブラリはこのPKCE対応をうたっているライブラリなので、このライブラリを使えば、コールバックハンドラーをサーバー側で用意せずとも、ウェブフロントエンドだけで認証が可能となるはずですので、実験してみました。

実現したいことは

  • MSAL.js (npmのパッケージ名は@azure/msal-browser)を組み込む
  • フロントエンドだけで認証する

なお、このライブラリには、ReactとAngular向けのフレームワーク向けのラッパーライブラリが提供されていますが、動きを知るために直接このライブラリを使うものとします。

まずは実験用のサービスをAzureADに登録する

まずはAzureのActive Directoryのコンソールにアクセスしてアプリケーションを登録します。

  • 名前は適当に(azuread-testでもなんでも)
  • サポートされているアカウントの種類も任意
  • プラットフォームの種類は シングルページアプリケーション(SPA) 、コールバックURLはhttp://localhost:5173/callbackにする(ローカルで動かす開発サーバーで受けるため)

なお、すべての項目はあとで修正できますので(アカウント種類はマニフェストエディタでJSONいじる必要があって面倒ですが)、気軽な気持ちで作成できます。また、リダイレクト情報は複数登録できます。

スクリーンショット_2022-11-09_12.34.58.png

作成したあとにアプリケーションを選ぶと、アプリケーションの基本情報が表示されますが、次の2つのUUID型式のIDはあとで大事になります。

  • アプリケーション (クライアント) ID
  • ディレクトリ (テナント) ID
スクリーンショット_2022-11-09_12.37.10.png

ウェブフロントエンドの作成

今回はVue.jsで作ってみました。認証部分はプラグイン化して使えるようにします。ReactであればContext化すればよい気がします。Vueのアプリケーションを適当に作ります。僕はVite.jsで作りましたが、vue-cliでもNuxt.jsでもなんでもOKです。

まずはMSAL.jsをインストールします。

npm install @azure/msal-browser

次に認証情報を設定するファイルを作成します。先ほど作ったアプリケーションの情報を登録します。

authConfig.ts
import type { Configuration } from "@azure/msal-browser";

export const config: Configuration = {
auth: {
authority: "https://login.microsoftonline.com/{テナントID}",
clientId: "{クライアントID}",
redirectUri: "http://localhost:5173/callback"
},
cache: {
cacheLocation: "localStorage",
storeAuthStateInCookie: false,
}
};

次にプラグインを作ります。今回は基本的にログインしっぱなしの想定で、未ログインアクセスを許容しない前提でいます。もし、ページによっては未ログインを許可してVue Routerでアクセス制御するのであれば、ログインしているかどうかを確認するメソッド(auth.getAllAccounts()が1つもない)を追加しておけば組み込みがしやすいでしょう。

authPlugin.ts
import { App } from "vue";
import { Configuration, PublicClientApplication} from "@azure/msal-browser"

let auth: PublicClientApplication;
let accessToken = "";

export async function init(config: Configuration) {
auth = new PublicClientApplication(config);
await auth.handleRedirectPromise();
}

export type AuthPluginType = {
login(): Promise<string>;
logout(): Promise<void>;
accessToken(): string;
}

export const AuthPlugin = {
install(app: App, config: Configuration) {
app.config.globalProperties.$auth = {
async login() {
if (auth.getAllAccounts().length > 0) {
auth.setActiveAccount(auth.getAllAccounts()[0]);
const result = await auth.acquireTokenSilent({
scopes: ["{クライアントID}/.default"], // 11/30修正
redirectUri: config.auth.redirectUri
});
accessToken = result.accessToken;
return accessToken;
} else {
await auth.acquireTokenRedirect({
redirectStartPage: location.href,
scopes: ["{クライアントID}/.default"], // 11/30修正
redirectUri: config.auth.redirectUri
});
}
},
async logout() {
await auth.logoutRedirect();
},
accessToken() {
return accessToken;
},
};
}
};

このlogin()メソッドが肝です。

  • すでにログイン済みの場合はPublicClientApplication.getAllAccounts()がユーザー一覧を返すのでアクティブユーザーとして設定する。本当はidTokenClaims.audがクライアントIDと一致しているものを探すというのを丁寧にやった方がいいかもしれないけど、今回のケースではそもそも違うクライアントIDのユーザーアカウントがここに入ることは今のところないので雑に最初の要素をピック。
  • PublicClientApplication.acquireTokenSilent()はログイン済みであればアクセストークンをAzureADに問い合わせることなく取得してVue.js内で使える形で返すが、未ログインだと例外を投げる。
  • 未ログインだった場合にPublicClientApplication.acquireTokenRedirect()を使ってAzureADにリダイレクトしてログインを行う。

いろいろ試行錯誤しましたが、たぶんこれが最小ケースです。

なお、MSAL.jsにはリダイレクトモードだけでなく、SPA向けのポップアップモードがありますが、Chromeでは動かず、Edgeでしか動きませんでした。そもそも別ウィンドウでログイン画面が出るため、未ログイン時の画面のブロックとかを実装するのは手間なので、今回紹介したリダイレクトモードの方が手間が少なくて済むかと思います。あと、ChromeもEdgeも、デフォルトで別ウィンドウのポップアップはブロックされるという問題もあります。今のところ選ぶ理由が見当たらないです。

**(11/30追記)**なお、スコープは['{クライアントID}/.default']["User.Read"]でもトークンは取得できるのですが、生成されたアクセストークンをvalidationすると必ずエラーになってしまいます。アクセストークンをサーバー側で検証することでログインが正常に行われたかどうかを判定するのがログインの肝なので、このスコープ名には注意してください。Microsoftの提供するAPIを利用する場合はその該当するAPIをスコープにすればOKです。この場合は自分での検証はできません。詳しくは以下のリンク先を参照してください。

TypeScript用にプラグインの型定義も書いておきます。

auth.d.ts
import type { AuthPluginType } from "./authPlugin";

declare module "vue" {
interface ComponentCustomProperties {
$auth: AuthPluginType
}
}

組み込み

PublicClientApplicationの初期化はVue.jsとかよりも先に行います。

main.ts
import { createApp } from 'vue'
import App from './App.vue';
import { AuthPlugin, init } from './authplugin';
import { config } from './authconfig';

await init(config);

const app = createApp(App);
app.use(AuthPlugin, config);
app.mount('#app');

MSAL.jsは初期化時に、AzureADからのコールバックでフロントエンドが呼ばれた場合の、クエリー文字列にOAuthの認証コードが付与されている場合には、「コールバックが来たぞ!」と検知して、OpenID Connectの認証処理の続きを行ってくれます。そして、その後redirectStartPageで指定したURLにリダイレクトまでやってくれます。そのために、init()で待ち(正確にはauth.handleRedirectPromise()を待つ)を入れています。awaitを忘れるとエラーが出て認証に失敗します。

コンポーネントへの組み込みは以下の通りです。プラグインで作成したlogin()logout()を呼び出せるようにしています。あとは、アクセストークンもプラグイン経由で取得できますので、あとはこれをサーバーAPIリクエスト時にヘッダーに設定すればOKです。

App.vue
<script lang="ts">
export default {
methods: {
logout() {
this.$auth.logout();
}
},
async created() {
await this.$auth.login();
console.log(this.$auth.accessToken()); // アクセストークン表示
}
}
</script>

<template>
<div>
login test
</div>
<button v-on:click="logout">logout</button>
</template>

デバッグ

結構はまったのですが、ログインの途中でとまったときは、セッションストレージにゴミが残ります。この状態でMSAL.jsのAPIを呼んでも、処理中のものがあるというエラーになってしまうので、ブラウザを再起動するか、開発者ツールでセッションストレージを掃除します。

あとは、 auth.handleRedirectPromise() のPromiseのエラーとか返り値を見てみるのも良いです。

セキュリティ強度を変えるためのチューニング

今回は1人1台専用のマシンがある前提のコードになっているため、ブラウザ再起動でもセッションが残るようにlocalStorageに入れていますが、そうでない場合はブラウザを落としたら認証情報もリセットされるようにsessionStorageにしてあげた方が良いでしょう。上記のサンプルコードのauthConfig.tsで変更できます。

あとはこの形式だとサーバーを介さずにフロントだけで認証するため、サーバー側からアクセスの無効化などができません。フロントで作ったトークンをサーバーに送って「使っていいよ」というお墨付きを与える(あるいはユーザーごとに1セッションしか認めず、後からログインしたら先のログインは無効)みたいなロジックとかを作ればそのような問題には対処できるかもしれませんが、それであればフロントエンドだけで認証という方式ではなく、最初からアクセストークンの発行はサーバーに任せた方が良い気もします。

まとめ

PKCEの恩恵で、サーバーいらずの認証が実装できました。サーバーで認証する場合、サーバー側に設定を入れる必要があり、ダメだった場合に何度もデプロイしてテストしたり不便でしたが、とても簡単に実装できました。

サーバー側としては、JWTの検証だけは必要となりますので、そこだけ実装が必要です。MSAL.jsのリポジトリにサーバー側でのトークン検証のサンプル(Express利用)があるので、見てみると良いでしょう。