[JavaScript] [Vue.js] 画像リソースを安全に配信したい

# はじめに

社内用のWebアプリをSPAで開発していますが、画像を配信するエンドポイントをどのように設定するかで悩む場面がありました。

状況としては以下のような感じです：

- 基本的に社内限定で使うアプリなので、Web APIのエンドポイントはほぼ全てが認証を必須にしている。
- 認証はJWT認証を使う。フロントエンドでlocalStorageから読み出したBearerトークンをヘッダーに渡す方式。
- 扱う画像は社外秘の情報を含む可能性が高いため、何らかの方法で配信用のエンドポイントを保護しておきたい。

# TL;DR

- URLで画像を指定する際はヘッダーの `Authorization` をいじれない！
- 今回はBlob URLを使って解決した。
  - JavaScriptのコードで保護されたエンドポイントからバイナリ形式で画像を取得する。
  - バイナリをBlob化して、`URL.createObjectURL()` でBlob URLを生成する。
  - 生成したBlob URLは `<img>` タグやフロントエンドのライブラリで使用可能。
  - Blobを使い終わったら、忘れずに `URL.revokeObjectURL()` で破棄しよう。

# どうにかして画像用のエンドポイントも保護したい

通常 `<img>` タグの `src` 属性には、ヘッダーに認証トークンを含む任意の情報を渡すことができません。また、同様の問題はフロント側のライブラリで画像リソースを指定するケースなどでも考えられます。
そこで、対策をいくつか考えてみました。

## 画像についてはエンドポイントの保護を諦めて、Web APIのURI設計でカバーする方法【危険！！】

最初に考えた方法です。 `src` 属性には通常通り配信用URIを指定して、JWTによる保護を諦めるというパターンです。

このまま無策だと流石にヤバいので、Web APIのURI設計でなんとかしようとしていました。以下のようにして、リソースの指定はUUIDだけで行うように徹底します。

```text
https://<mywebapp>.azurewebsites.net/api/resources/{UUID}/children/{UUID}/images/{UUID}
```

### 問題点

URLのパラメータにはUUIDしかないので、外部から画像を取得する通信を覗かれても「それが何を指すデータなのか」は判別できません。しかし、**画像配信用のエンドポイントが丸裸なのは変わりありません！**
リソースの指定を意味のある文字列で行うような状況よりはマシですが、依然としてセキュリティリスクは残っていることになります。

## クラウドストレージのSASトークンを利用する方法

もう一つは SAS (Shared Access Signatures) トークンの仕組みを利用する方式です。セキュリティ周りをWeb APIではなくクラウド側に任せるアプローチです。

やり方としては、Web API側で予め期限付きのSASトークンを生成しておいて、フロントエンドでそのトークンを使って画像を取得する感じです。

SASトークンについての詳しい説明は省略します。以下の記事などを参照してください。

https://learn.microsoft.com/ja-jp/azure/storage/common/storage-sas-overview

https://qiita.com/pleasurems/items/a8e752e3711b3d7a3bac

今回はAzure Blob Storageを使用するので、画像の配信はWeb API経由ではなくBlobのエンドポイントから直接行います。URIの末尾にWeb APIから一定時間ごとに発行されるSASトークンを指定します。

ということで、`<img>` タグに指定するURLは以下のようになります。

```text
https://<mystorageaccount>.blob.core.windows.net/<myContainer>/{UUID}.jpg?{SAS Token}
```

### 問題点

はじめはこの方式で考えていたんですが、採用には至りませんでした。

1. SASトークンの仕組みは期限付きでリソースを外部に共有するような状況では便利だが、今回はあくまで「ログイン時のみWebアプリからアクセスできる」という要件さえ満たせばよい。
2. 通常の認証とは別に、フロント側が一定時間ごとにAPIにSASトークンを要求する必要がある。
⇒要件に対して実装が大袈裟になる懸念がある。めんどくさそう……
3. 期限が設けられているとはいえ、URLパラメータに生のトークンを渡すのはちょっと抵抗感がある。

## Blob URLを使う方法 【採用！】

最後に真打ちです。フロントエンドのコードで画像データを取得して、**Blob URL** を経由して表示させる方法です。

これまでの候補と問題点を洗い出すと、やはり通常のJSONのやり取りと同様に「**JavaScript側からJWT認証を直接利用する**」という方法が最もシンプルで適していると考えられます。
そこで、以下のようなフローで画像リソースを扱うアプローチでやってみることにします。

> 1. フロントエンドで認証トークンを付加して、画像リソースのバイナリデータを取得する
> 2. 取得したデータをURLの形式に変換する
> 3. URLが取得できたら、あとは好きなように使う

ここで使えるのが `Blob` オブジェクトです。Blobを使うと、JavaScriptから生バイナリ形式でデータを扱うことができます。

https://developer.mozilla.org/ja/docs/Web/API/Blob

Blobのデータを外部から利用する際は、 `URL.createObjectURL(object)` メソッドでURL形式の参照を作成します。[^1]
このメソッドで取得したBlob URLですが、通常の画像リソースのURLと同様、`<img>` タグの `src` 属性や各種ライブラリでリソースを指定する際に使うことができます。

https://developer.mozilla.org/ja/docs/Web/API/URL/createObjectURL_static

[^1]: バイナリデータのURL化について、当初生データをBase64化する方法で考えていましたが、表示の際の負荷が大きいのでやめました。
一方、Blob URLは生データの実体ではなく参照なので、巨大な画像データでも一度Blobに読み込んでしまえばすぐに表示できます。これはいいですね……！

# 実装例 (JavaScript)

ということで、とりあえずJavaScriptで実装を書いてみます。

```js:blob.js
// 保護されたエンドポイントにあるリソースの参照をBlob URLの形式で取得する
const getBlobURL = async (url, contentType) => {
  // localStorageから認証トークンを取得する
  const authToken = localStorage.getItem('AuthToken')

  // 認証用トークンをヘッダーに付加して、画像リソースを取得する
  const response = await fetch(url, {
    method: 'GET',
    headers: {
      Authorization: 'Bearer ' + authToken,
      'Content-Type': contentType,
    },
  })
  // 得られたresponseをBlobに変換し、Blob URLを返す
  const blob = await response.blob()
  return URL.createObjectURL(blob)
}

// 指定した <img> 要素に対して、保護されたエンドポイントから取得した画像を表示する
const setAuthImageSrc = async (selector, url, contentType) => {
  const blobUrl = await getBlobURL(url, contentType)
  const image = document.querySelector(selector)
  image.setAttribute('src', blobUrl)
}

// モジュールとして使用する場合、getBlobURLメソッドを公開する (次に示すVue.jsによる実装例に必要)
// export { getBlobURL }
```

上記の `setAuthImageSrc` メソッドを実行すると、指定した `img` 要素に対して非同期で画像リソースを表示してくれます。
これでとりあえず要件は達成できました。Blob URL、めちゃくちゃ便利ですね！

## 注意点: Blobの解放について

便利なBlob URLですが、注意点として「**使用後にはこまめに解放した方が望ましい**」という事項があります。

`URL.createObjectURL()` でBlobへの参照が作られると、メモリ上にBlobの中身が保持された状態になります。
Blobへの参照が不必要になったタイミングで解放してあげないと、ページを閉じたり再読み込みしない限り、メモリ上にずっとBlobのデータが居座り続けることになります。
特に今回はサイズが大きめの画像リソースを扱うので、このようなメモリリークは馬鹿になりません。

Blobのリソースを解放するには `URL.revokeObjectURL()` メソッドを使います。引数には解放したいBlobのURLを指定します。

https://developer.mozilla.org/ja/docs/Web/API/URL/revokeObjectURL_static

React、Vue、BlazorといったSPAフレームワークを使用している場合、表示対象となるリソースの変更時や利用対象のコンポーネントを破棄するタイミングで呼び出してやると良いでしょう。[^2]

なお実装対象がSPAではない場合、ページを遷移すると自動的にBlobも解放されるため、破棄はほぼ不要です。

[^2]: 一度しか使わない場合、Blob URLを使い終わった直後に即解放することもできます。リソースの解放後もimgタグ内で画像は表示されたままですが、右クリックや開発者ツールでのDLはできなくなります。

# 実装例 (Vue.js 3.x + TypeScript)

ということで、上記のリソースの解放も踏まえて実装してみた結果がこちらです。
以下はVue.js 3.x + TypeScriptでの実装例です。ついでにですが、普通に `<img>` タグ感覚で使えるようにコンポーネント化もしておきました。

```vue:AuthImage.vue
<script setup lang="ts">
import { onMounted, onUnmounted, watch, ref } from 'vue'
import { getBlobURL } from '@/utils/blob.js'  // 先のスクリプトをライブラリとして使う

// リソースの解放に必要なので、Blob URLはコンポーネント内で保持しておく
let blobURL = ref('')

// Props
const props = defineProps({
  src: { type: String, required: true },
  contentType: { type: String, default: 'application/octet-stream' } 
})

// mount時に画像リソースを読み込む
onMounted(() => loadBlobSrc())

// props.srcが変更されるたびに、画像リソースを読み込む
watch(() => props.src, () => loadBlobSrc())

const loadBlobSrc = async () => {
  URL.revokeObjectURL(blobURL.value)  // 前のリソースを破棄する
  blobUrl.value = await getBlobURL(props.src, props.contentType)
}

// unmount時にBlobを解放する
onUnmounted(() => {
  URL.revokeObjectURL(blobURL.value)
})
</script>

<template>
<img v-if="blobURL" :src="blobURL" />
</template>
```

```vue:AuthImagePage.vue
<script setup lang="ts">
import AuthImage from '@/components/AuthImage.vue'
</script>

<template>
<AuthImage src="/api/images/image-with-jwt-auth.jpg" />
</template>
```

`contentType` のpropsはお好みで指定しますが、`application/octet-stream` のままでもOKです。

これでJWT認証でエンドポイントを保護したまま、画像のリソースを表示することができました🎉

# 次回予告的な何か

今回はJavaScript (TypeScript) + Vue.js での実装方法を紹介しました。
ですが、実は自分が現在担当している案件だとフロントエンドにVue.jsやTypeScriptは使用していません。

実際のところ、現在は [Blazor](https://dotnet.microsoft.com/ja-jp/apps/aspnet/web-apps/blazor) を使ってフロントエンドをC#で組んでいます。仕事でも趣味でも、自分が普段使っているフレームワークもBlazorの方です。
（一年以上ぶりにVue.jsに触りましたが、思い出すのがちょっと大変でした……）

BlazorでもJavaScriptのコードを呼び出すことはできるので、基本的には上記のやり方でOKです。
ですが、Blazor用の便利なライブラリ[^3] もあるので、次回以降はそのへんを踏まえた実装例についても書いていこうかなと思います。

ありがとうございました！

[^3]: <https://github.com/xaviersolau/BlazorJsBlob>