Angular SPAでAzure Speech音声・アバターを安全に使うトークン設計と実装ガイド

Azure Speech の音声合成や音声アバターを SPA(Angular)から使いたいが、ブラウザにサブスクリプションキーは絶対に置きたくない…そんなときに使えるのが「短期トークン配布 API」パターンです。本記事では、推奨アーキテクチャと Angular 実装、マネージド ID の正しい使い方まで一気に整理します。

目次

Azure Speech を SPA(Angular)から安全に使うべき理由

Azure Speech(Speech Service)のデモコードでは、ブラウザ側でサブスクリプションキー(リソースキー)を直接使って SDK を初期化する例がよく出てきます。しかし本番システムで同じことをすると、次のようなリスクを抱えることになります。

  • ブラウザの開発者ツールから サブスクリプションキーが丸見え(コンソールやソースマップ、Network タブなど)。
  • 漏えいしたキーを第三者が 別の環境から勝手に叩ける(レート超過や高額請求の原因)。
  • キーのローテーションが難しくなり、長期的に 運用負債 になる。

一方、Azure Speech では「サブスクリプションキーを /sts/v1.0/issueToken に渡すと、約 10 分間有効なアクセストークン(JWT)が取得できる」仕組みが提供されています。 この仕組みをうまく使うことで、ブラウザ側には「短命なトークンだけ」を渡し、長期の秘密情報はすべてバックエンドと Key Vault に閉じ込める設計にできます。

本記事のゴールは、次の 3 点です。

  • Angular SPA から Azure Speech を安全に呼び出すアーキテクチャを理解する。
  • バックエンドに「トークン配布 API」を実装し、Key Vault とマネージド ID を正しく使う。
  • 音声・アバター機能を支えるトークン更新ロジックやセキュリティ運用のポイントを押さえる。

推奨アーキテクチャの全体像

おすすめの構成はシンプルにまとめると次の通りです。

  • バックエンド(トークンプロバイダ):サブスクリプションキーを使って issueToken を叩き、短期トークンを発行するだけの API。
  • Angular SPA(フロント):Speech SDK を fromAuthorizationToken で初期化し、必要に応じてトークンを更新しながら音声・アバター機能を呼び出す。
  • Key Vault + マネージド ID:バックエンドがサブスクリプションキーなどの秘密情報を取得するためだけに利用。クライアントには一切渡さない。
  ユーザー (ブラウザ:Angular SPA)
          │ 1. 認証(Microsoft Entra ID 等)
          ▼
  フロントエンド (Angular)
          │ 2. /api/speech-token を呼び出し
          ▼
  バックエンド (Token Provider API)
          │ 3. マネージド ID で Key Vault へ
          │ 4. サブスクリプションキー取得
          │ 5. https://<region>.api.cognitive.microsoft.com/sts/v1.0/issueToken へ POST
          ▼
  Azure Speech STS
          │ 6. 約10分有効のトークン発行
          ▼
  バックエンド → フロント
          │ 7. 短期トークン + region を返却
          ▼
  フロント
          │ 8. SpeechConfig.fromAuthorizationToken(token, region)
          ▼
  Azure Speech 本体(音声・アバター)

このときの責務分担を表にすると、設計意図がよりクリアになります。

レイヤー主な責務保持する秘密情報
Angular SPAUI、音声操作、アバター制御、トークンの短期キャッシュなし(最大でも「短期トークン」をメモリ保持)
トークン配布 API認証済ユーザーからのトークン要求を受け、Speech トークンを発行して返すKey Vault から取り出したサブスクリプションキー(メモリ上)
Key Vaultサブスクリプションキーなど長期秘密の安全な保管Speech リソースキー、他のシークレット
マネージド IDバックエンドから Key Vault へ安全にアクセスするための ID秘密情報は持たず、Azure 側が管理する

やってはいけない 2 つのパターン

ブラウザにサブスクリプションキーを直接置く

もっとも危険なのが「デモコードのまま、サブスクリプションキーをフロントに埋め込む」パターンです。

  • JavaScript のバンドルをダウンロードすれば、キーは誰でも取得できる。
  • たとえ「環境変数」や「CI 秘密設定」を使っても、最終的にブラウザに配布されるなら意味がない。
  • CDN キャッシュやログに残る可能性もあり、漏えい後の影響範囲が読めない。

これは 本番環境では絶対に避けるべきアンチパターン です。

マネージド ID や Azure AD アクセストークンをそのままクライアントに渡す

「マネージド ID で取得した Azure AD アクセストークンをブラウザに渡して、Speech SDK に食べさせればよいのでは?」というアイデアも危険です。

  • Azure AD のアクセストークンは 60〜90 分程度の有効期限 が一般的で、短期トークンより攻撃者にとって扱いやすい。
  • 権限のスコープ次第では、Speech 以外の Azure リソースにもアクセスできてしまう。
  • ブラウザに配布した瞬間、XSS 等で盗まれれば大きな被害につながる。

Azure 公式のガイダンスでも、クライアントに長期・高権限のトークンを渡すのではなく、バックエンドで必要最小限の短期トークンに交換することが推奨されています。

したがって、マネージド ID は 「バックエンド → Key Vault や Speech リソースへのアクセス」にだけ使う、というスタンスを徹底しましょう。

バックエンド実装:トークン配布 API を作る

ここからは、Node.js(TypeScript)を例に「トークン配布 API」を実装するパターンを見ていきます(.NET でも考え方は同じです)。

前提となる Azure リソース

  • Azure Speech リソース(キーとリージョンを保持)
  • Azure Key Vault(Speech のサブスクリプションキーを格納)
  • バックエンド(App Service / Azure Functions / コンテナなど)
  • バックエンドに割り当てたシステム割り当てマネージド ID

Key Vault への権限設定

  • Key Vault に speech-subscription-key のような名前でシークレットを作成。
  • アクセス ポリシーまたは RBAC で、バックエンドのマネージド ID に「Secret Get」権限を付与。

Node.js(Express)でのトークン配布 API 実装例

簡略化したサンプルです。実際には認証(JWT 検証など)を必須にしてください。

import express from 'express';
import axios from 'axios';
import { SecretClient } from '@azure/keyvault-secrets';
import { DefaultAzureCredential } from '@azure/identity';

const app = express();
const port = process.env.PORT || 3000;

const keyVaultName = process.env.KEY_VAULT_NAME!;
const kvUrl = `https://${keyVaultName}.vault.azure.net`;
const speechKeySecretName = process.env.SPEECH_KEY_SECRET_NAME || 'speech-subscription-key';
const speechRegion = process.env.SPEECH_REGION!; // 例: japaneast, eastus など

const credential = new DefaultAzureCredential();
const secretClient = new SecretClient(kvUrl, credential);

async function getSpeechSubscriptionKey(): Promise<string> {
  const secret = await secretClient.getSecret(speechKeySecretName);
  if (!secret.value) {
    throw new Error('Speech subscription key not found in Key Vault.');
  }
  return secret.value;
}

// 認証必須エンドポイントにすること!
app.post('/api/speech-token', async (req, res) => {
  try {
    // ここでユーザーの認証・認可チェックを行う(省略)

    const subscriptionKey = await getSpeechSubscriptionKey();

    const tokenEndpoint =
      `https://${speechRegion}.api.cognitive.microsoft.com/sts/v1.0/issueToken`;

    const response = await axios.post(
      tokenEndpoint,
      null,
      {
        headers: {
          'Ocp-Apim-Subscription-Key': subscriptionKey,
          'Content-Type': 'application/x-www-form-urlencoded',
          'Content-Length': '0'
        }
      }
    );

    const token = response.data as string;

    // Speech トークンは約 10 分有効
    const expiresInSeconds = 600;

    res.json({
      token,
      region: speechRegion,
      expiresIn: expiresInSeconds
    });
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: 'Failed to issue speech token' });
  }
});

app.listen(port, () => {
  console.log(`Token provider listening on port ${port}`);
});

/sts/v1.0/issueToken への POST で、サブスクリプションキーを 10 分有効なトークンに交換できる点がポイントです。

レスポンス設計の例

クライアント側で扱いやすいよう、シンプルな JSON を返します。

{
  "token": "<authorization token string>",
  "region": "japaneast",
  "expiresIn": 600
}

この expiresIn は「おおよその有効期限(秒)」をクライアントに伝えるための目安です。実際には JWT のクレームに有効期限が入っていますが、フロントで JWT を解析しない設計にしておくと責務がシンプルになります。

Angular フロントエンドからトークンを使う

次に、Angular からトークン配布 API を呼び出し、Speech SDK を初期化する実装を見ていきます。

Speech SDK のインストール

npm install microsoft-cognitiveservices-speech-sdk --save

TypeScript でも型定義が同梱されているので、そのまま利用できます。

トークン取得用サービスの実装(speech-token.service.ts)

トークンの「取得」と「キャッシュ」を 1 カ所に閉じ込めておくと、アプリ全体で再利用しやすくなります。

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { lastValueFrom } from 'rxjs';

interface SpeechTokenResponse {
  token: string;
  region: string;
  expiresIn: number; // 秒
}

@Injectable({ providedIn: 'root' })
export class SpeechTokenService {
  private token: string | null = null;
  private region: string | null = null;
  private expiresAt: number | null = null; // Unix ms

  constructor(private http: HttpClient) {}

  async getValidToken(): Promise<{ token: string; region: string }> {
    const now = Date.now();

    // 期限の 1 分前までは再利用
    if (this.token && this.expiresAt && now < this.expiresAt - 60_000) {
      return { token: this.token, region: this.region! };
    }

    const response = await lastValueFrom(
      this.http.post<SpeechTokenResponse>('/api/speech-token', {})
    );

    this.token = response.token;
    this.region = response.region;
    this.expiresAt = now + response.expiresIn * 1000;

    return { token: this.token, region: this.region };
  }

  // 必要なら手動でトークンをクリアするメソッドを用意しても良い
  clear() {
    this.token = null;
    this.region = null;
    this.expiresAt = null;
  }
}

トークンは メモリにのみ保持し、localStorage や sessionStorage には保存しない 点が重要です。ブラウザ再読込時は再度 API を叩いて取得し直します。

Speech SDK をラップするサービスの実装例(azure-speech.service.ts)

import { Injectable } from '@angular/core';
import * as sdk from 'microsoft-cognitiveservices-speech-sdk';
import { SpeechTokenService } from './speech-token.service';

@Injectable({ providedIn: 'root' })
export class AzureSpeechService {
  private speechConfig: sdk.SpeechConfig | null = null;

  constructor(private tokenService: SpeechTokenService) {}

  private async getSpeechConfig(): Promise<sdk.SpeechConfig> {
    const { token, region } = await this.tokenService.getValidToken();

    if (!this.speechConfig) {
      // サブスクリプションキーではなく Authorization Token で初期化
      this.speechConfig = sdk.SpeechConfig.fromAuthorizationToken(token, region);
    } else {
      // 既存 config のトークンだけを更新することも可能
      this.speechConfig.authorizationToken = token;
    }

    return this.speechConfig;
  }

  async synthesizeText(text: string): Promise<void> {
    const config = await this.getSpeechConfig();
    const audioConfig = sdk.AudioConfig.fromDefaultSpeakerOutput();
    const synthesizer = new sdk.SpeechSynthesizer(config, audioConfig);

    return new Promise((resolve, reject) => {
      synthesizer.speakTextAsync(
        text,
        result => {
          synthesizer.close();
          if (result.reason === sdk.ResultReason.SynthesizingAudioCompleted) {
            resolve();
          } else {
            reject(result.errorDetails);
          }
        },
        error => {
          synthesizer.close();
          reject(error);
        }
      );
    });
  }

  async recognizeOnce(): Promise<string> {
    const config = await this.getSpeechConfig();
    config.speechRecognitionLanguage = 'ja-JP';

    const audioConfig = sdk.AudioConfig.fromDefaultMicrophoneInput();
    const recognizer = new sdk.SpeechRecognizer(config, audioConfig);

    return new Promise((resolve, reject) => {
      recognizer.recognizeOnceAsync(
        result => {
          recognizer.close();
          if (result.reason === sdk.ResultReason.RecognizedSpeech) {
            resolve(result.text);
          } else {
            reject(result.errorDetails);
          }
        },
        error => {
          recognizer.close();
          reject(error);
        }
      );
    });
  }
}

ここでのポイントは次の通りです。

  • SpeechConfig.fromSubscription ではなく、fromAuthorizationToken を必ず使う。
  • トークン更新時は speechConfig.authorizationToken = newToken のように差し替えられる。
  • 長時間のセッション(チャットアバターなど)では、一定時間ごとに getSpeechConfig() 内でトークンを更新するイメージで運用する。

コンポーネント側からの利用例

import { Component } from '@angular/core';
import { AzureSpeechService } from './azure-speech.service';

@Component({
  selector: 'app-speech-demo',
  template: `
    <button (click)="speak()">読み上げ</button>
    <button (click)="recognize()">音声認識</button>
    <p>認識結果: {{ recognizedText }}</p>
  `
})
export class SpeechDemoComponent {
  recognizedText = '';

  constructor(private speech: AzureSpeechService) {}

  async speak() {
    await this.speech.synthesizeText('こんにちは。Azure Speech と Angular です。');
  }

  async recognize() {
    this.recognizedText = await this.speech.recognizeOnce();
  }
}

音声アバター(Chat Avatar)でも同じパターンで使える

Azure Speech の音声アバター(Chat Avatar)機能を利用する場合も、基本的な認証パターンは同じです。

  • SpeechConfig の初期化方法が Authorization Token + Region である点はまったく同じ。
  • アバター用の SDK / WebRTC 接続部分で、SpeechConfig またはトークン文字列を渡すだけ。
  • セッションが 10 分を超える場合は、裏でトークンを更新する実装が必要。

つまり、バックエンドのトークン配布 API と Angular 側のトークン管理サービスをしっかり作っておけば、テキスト読み上げ・音声認識・アバターのどれに対しても 認証パターンを使い回せる というメリットがあります。

トークンの性質と更新タイミングの設計

ここで、Speech トークンの性質と更新設計を一度整理しておきます。

項目値の例備考
発行元/sts/v1.0/issueTokenSpeech リソースキーと引き換えに発行
形式JWT 文字列クライアント側でパースする必要はない
有効期限約 10 分公式ドキュメントでも 10 分有効と案内
推奨再取得タイミング9 分経過または残り 10〜20% 時ネットワーク遅延や時計ずれのバッファを考慮

運用上のおすすめは次のようなイメージです。

0 分   1    2    3    4    5    6    7    8    9   10 分
|----------------------------|----------|
  トークン発行・利用期間         再取得バッファ

・発行直後〜9分までは同じトークンを再利用
・8〜9分あたりでバックグラウンドで新しいトークンを取得
・新トークンを SpeechConfig に差し替え、セッション継続

長時間セッションを扱う場合は、次のような「自動更新フック」を設計しておくと安心です。

  • 定期的に getValidToken() を呼び出すタイマー(例:5 分ごと)。
  • Speech SDK のエラーが「トークン期限切れ」のときに自動でトークン再取得してリトライ。
  • バックエンド側で expiresIn を短め(例:540 秒)にして返すことで、余裕を持った更新を促す。

マネージド ID の正しい使い方とアンチパターン

マネージド ID の「正しい役割」

マネージド ID(Managed Identity)は、あくまで Azure 内のリソース同士を安全に認証するためのしくみ です。

  • App Service や Azure Functions に「システム割り当て ID」を有効化する。
  • Key Vault や Storage、Service Bus などに対して「この ID には Secret Get だけ許可」のように権限を付与。
  • バックエンドコードからは DefaultAzureCredential を使うだけで、秘密キーなしでリソースにアクセスできる。

ここで重要なのは、マネージド ID 自体をクライアントに渡すことはできないし、渡す設計にしてもいけない という点です。マネージド ID は Azure 内の「裏口的な認証手段」であり、ブラウザやモバイルアプリに配るものではありません。

よくある誤解パターン

  • 「マネージド ID で取得したトークンをブラウザに渡して、Speech SDK に使わせれば安全なのでは?」
    → そのトークンは通常、Speech 以外のリソースにもアクセスできる可能性があり、かつ有効期限も長いため、漏えいしたときの被害が大きくなります。
  • 「クライアントから直接 Key Vault を呼べばいいのでは?」
    → Key Vault は基本的にサーバーサイドから使うことが前提であり、クライアントから直接叩くことは想定されていません。

結論として、マネージド ID は 「バックエンドの味方」 としてだけ使い、ブラウザ側は常に「短期トークン」だけを扱う設計を徹底しましょう。

セキュリティ・運用設計のチェックポイント

ここまでの内容を踏まえ、運用面で必ず押さえておきたいポイントを整理します。

認証と認可

  • /api/speech-token は必ず 認証必須 にする(匿名アクセス禁止)。
  • Microsoft Entra ID(Azure AD)や B2C、独自 JWT など、アプリ全体で使っている認証方式に合わせる。
  • 必要なら、「音声機能利用可能なロール」を別途設け、ロールに応じて利用可否を制御する。

レート制限

  • ユーザー ID または IP 単位でトークン発行回数を制限(例:1 分あたり 5 回まで)。
  • Azure API Management や Application Gateway / WAF と組み合わせると設定しやすい。
  • 異常なアクセスパターン(大量発行、深夜帯の集中など)を検知し、アラートにつなげる。

CORS 設定

  • /api/speech-token の CORS は 自社ドメインのみに限定 する。
  • *(ワイルドカード)は極力使わない。
  • 開発環境と本番環境で許可オリジンを明確に分ける。

ログと監査

  • ログに 絶対にトークン本体やサブスクリプションキーを出力しない。
  • ログに残すべき情報の例:
    • 発行日時
    • 発行対象ユーザー ID
    • クライアント IP(可能であれば)
    • 利用したリージョン
  • 分析基盤(Log Analytics / Application Insights 等)と連携し、不審なパターンの検知ルールを用意する。

キーのローテーション

  • Speech リソースキーを 2 本持ち、順次ローテーションする運用を設計。
  • Key Vault 側で新しいバージョンを追加し、古いバージョンを削除するフローを決める。
  • ローテーション時は、バックエンドを優先的に新キーに切り替え、問題がなければ旧キーを失効させる。

よくある質問とその答え

Q. Speech トークンの有効期限はどのくらい?

A. 一般的に 約 10 分 です。Microsoft のドキュメントでも、issueToken で発行されるトークンは 10 分有効と説明されています。 連続利用する場合は、残り時間に余裕を持って再取得しましょう。

Q. デモである「API キー入力欄」はどう置き換えればいい?

A. 次のような変更が基本です。

  • 画面上の「API キー入力欄」を廃止する。
  • ユーザーがログインしていることを前提に、フロントから /api/speech-token を呼び出す。
  • 取得したトークンとリージョンで Speech SDK を初期化する。

認証はアプリ全体のログイン機能に任せ、ユーザーが何も意識せずに安全なトークンが使われる設計が理想です。

Q. 音声アバターでもこの方式は使える?

A. はい、基本的に同じ方式で利用できます。音声合成・音声認識・アバターのいずれも、Speech SDK 初期化時の認証手段が「サブスクリプションキー」か「短期トークン」かの違いだけです。アバター用 SDK がトークン文字列または SpeechConfig を受け取るのであれば、その前段として今回紹介したトークン配布 API を挟めば OK です。

Q. Azure AD(Microsoft Entra)のアクセストークンを直接クライアントに渡してもいい?

A. 一般的にはおすすめできません。Azure AD のアクセストークンは有効期限が長く、権限も広くなりがちです。 代わりに、バックエンドでそのトークンを使って Speech の短期トークンに交換し、ブラウザには短命なトークンだけを渡す構成が推奨されます。

チェックリスト:実装前に確認したい項目

最後に、本記事で紹介した内容をもとにチェックリストを整理します。プロジェクトのレビュー資料としてもそのまま使える形にしています。

チェック項目状態メモ
クライアントにサブスクリプションキーを一切置いていない□ 未対応 / □ 対応済環境変数・設定ファイルも含めて確認
バックエンドにトークン配布 API(認証必須)を用意した□ 未対応 / □ 対応済/api/speech-token など
Key Vault + マネージド ID でサブスクリプションキーを取得している□ 未対応 / □ 対応済マネージド ID に Secret Get だけ付与
issueToken で短期トークンを発行している□ 未対応 / □ 対応済リージョンとエンドポイント URL の確認
Angular 側で SpeechConfig.fromAuthorizationToken を利用している□ 未対応 / □ 対応済fromSubscription を使っていないか確認
トークンの期限前更新・レート制限・監査ログ・CORS 制御を実装済み□ 未対応 / □ 対応済運用チームと連携して要件を整理

まとめ:Angular + Azure Speech の安全な定番パターン

本記事では、SPA(Angular)から Azure Speech の音声・アバター機能を安全に使うための設計を、

  • サブスクリプションキーをブラウザに一切出さない
  • バックエンドに「トークン配布 API」を用意する
  • Key Vault + マネージド ID で秘密情報を安全に取得する
  • Speech SDK は fromAuthorizationToken で初期化する

という 4 つの軸から解説しました。

このパターンさえ押さえておけば、テキスト読み上げ・音声認識・音声アバターといった機能を、ビジネス要件や UI デザインに合わせて安心して拡張していけます。これから Angular で Azure Speech を本番導入する方は、まずはシンプルな「トークン配布 API」から実装を始めてみてください。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次