Vue 3のComposition APIとTypeScript用 msal-browserプラグイン

[dev.to](https://dev.to/matarillo/msal-browser-plugin-for-vue-3-composition-api-and-typescript-408g)にも投稿したのですが、こちらにも。

[Microsoft Authentication Library (MSAL)](https://learn.microsoft.com/ja-jp/entra/identity-platform/msal-overview)は、簡単に言えばEntra ID(Azure AD)やAzure AD B2Cへ接続して認証認可をするためのライブラリーです。いろんな言語環境に対応していますが、その中の一つである [msal-browser](https://www.npmjs.com/package/@azure/msal-browser) はSPAで認証認可してIDトークンやアクセストークンを得るためのもの。

MSのサンプルコードにはReactやAngular、Vanilla.jsなどで使う例があるのですが、Vueはなかったので、[MSAL.jsを使ってウェブフロントエンドだけでAzureAD認証する | フューチャー技術ブログ](https://future-architect.github.io/articles/20221118a/) を参考にして書いてみた。

## ソース

```ts:src/plugins/msal.ts
import { ref, computed, ComputedRef, inject } from "vue";
import { type Plugin, type Ref } from 'vue';

import {
  Configuration,
  PublicClientApplication,
  LogLevel,
  AuthenticationResult,
} from "@azure/msal-browser";

export type Msal = {
  isAuthenticated: ComputedRef<boolean>,
  result: Ref<AuthenticationResult | null>,
  login: () => Promise<void>,
  logout: () => Promise<void>,
};

export const createMsal = async () => {
  const config: Configuration = {
    auth: {
      clientId: import.meta.env.VITE_AUTH_CLIENTID,
      authority: import.meta.env.VITE_AUTH_AUTHORITY,
      knownAuthorities: [import.meta.env.VITE_AUTH_TENANT_DOMAIN],
      redirectUri: import.meta.env.VITE_AUTH_REDIRECT_URI,
    },
    cache: {
      cacheLocation: "localStorage",
      storeAuthStateInCookie: false,
    },
    system: {
      loggerOptions: {
        loggerCallback: (level, message, containsPii) => {
          if (containsPii) {
            return;
          }
          switch (level) {
            case LogLevel.Error:
              console.error(message);
              return;
            case LogLevel.Warning:
              console.warn(message);
              return;
            case LogLevel.Info:
              console.info(message);
              return;
            case LogLevel.Verbose:
              console.debug(message);
              return;
          }
        },
      },
    },
  };

  const scopes = (import.meta.env.VITE_AUTH_SCOPES as string).split(' ').filter(Boolean);
  const client = new PublicClientApplication(config);
  await client.initialize();
  const result = ref(await client.handleRedirectPromise());
  const isAuthenticated = computed(() => result.value != null);
  const login = async () => {
    if (client.getAllAccounts().length > 0) {
      client.setActiveAccount(client.getAllAccounts()[0]);
      result.value = await client.acquireTokenSilent({
        redirectUri: config.auth.redirectUri,
        scopes: scopes
      });
    } else {
      await client.acquireTokenRedirect({
        redirectStartPage: location.href,
        redirectUri: config.auth.redirectUri,
        scopes: scopes
      });
    }
  };
  const logout = async () => {
    await client.logoutRedirect();
  }

  const msalPlugin: Plugin = {
    install(app) {
      app.provide<Msal>("msal", { isAuthenticated, result, login, logout });
    }
  }
  return msalPlugin;
};

export const useMsal = () => {
  return inject<Msal>("msal")!;
}
```

## 使い方

`await`しなくても大丈夫な気もするけど、いちおう。

```ts:src/main.ts
import { createApp } from "vue";
import { createMsal } from "@/plugins/msal";

import App from "@/App.vue";

const app = createApp(App);
app.use(pinia);

(async () => {
  const msal = await createMsal();
  app.use(msal);
  app.mount("#app");
})();
```

```vue:src/component/HelloWorld.vue
<script setup lang="ts">
import { useMsal } from '@/plugins/msal';

defineProps<{ msg: string }>()

const { isAuthenticated, result, login, logout } = useMsal();
console.log(result.value?.idToken);
console.log(result.value?.accessToken);
</script>

<template>
  <h1>{{ msg }}</h1>

  <div class="card">
    <template v-if="isAuthenticated">
      <p>
        Hello, {{ result?.account.name }} !
      </p>
      <button type="button" @click="logout">Logout</button>
    </template>
    <template v-else>
      <button type="button" @click="login">Login</button>
    </template>
  </div>
</template>

<style scoped>
</style>
```