Azure Static Web Apps: x-ms-client-principalで安全なロールベース制御

SWA API認証を強化

初めに

ども!最近は人間とチャットするよりもAIとチャットすることが増えている龍ちゃんです。AIサービスの使い方が多岐にわたってきて、そのトピックだけでブログが執筆できそうなくらいです。

今回はAIとあまり関係ない、認証のお話です。内容としては「Azure Static Web Appsで組み込み認証とユーザー情報の取り扱い」となります。認証プロバイダーとしてGoogleを使用しています。

構築がしたことがない方は、「GoogleによるSSOを持つAzure Static Web Appsのアプリを作成する」の記事を参考に構築して試してみてください。

前提技術

Azure Static Web Apps(以降 SWA)をStandardプランで運用して、カスタム認証としてGoogleを設定していることが前提となります。カスタム認証はStandardプランから使用することができ、Freeプランで今回と同様の検証することができません。また、カスタム認証を有効化すると、他の認証プロバイダーが提供する組み込みプロバイダー(/.auth/login/github…など)が利用できなくなります。

SWA CLIを使用すれば、認証フローをローカルでエミュレートすることができます。Next.js+DevContainer環境でSWA CLIをセットアップしているブログがありますので、こちらを参考に構築するのをお勧めします

バックエンドは、将来的にSWAとAPI連携を活用して接続をする予定になります。Azure FunctionsやAzure App Serviceにデプロイされるのが想定されます。ローカルでの検証もできるので、任意の環境で大丈夫です。

私の検証環境では、nest.jsを使用して構築しています。コードとしては、TypeScriptになりますが概念としては他の言語でも通じることですので参考にしてみてください。

今回検証する内容としては以下になります。

  • staticwebapp.config.json のロールベースルーティング:参考リンク
  • API連携をした際にx-ms-client-principalを介してユーザー情報を渡す:参考リンク

staticwebapp.config.jsonを用いてロールベースのルーティングを定義する

こちらはプレビューの機能となります。本番環境で利用する場合は、検討して採用を決めてください。

staticwebapp.config.jsonの設定を追加することで、認証時にロールの判定ルートを追加することができます。制限事項として、SWA CLIではロール判定のルートを含めてエミュレートするため、実際の動きはAzure上にデプロイしてからのみ確認することができます。

認証からロール判定の流れは以下の流れになります。

設定項目としては、auth > rolesSource になります。こちらで設定したエンドポイントに認証後にPOSTリクエストが送られます。今回は/api/assingRolesと設定しています。

//staticwebapp.config.json
{
  "auth": {
    "rolesSource": "/api/assignRoles",
    "identityProviders": {
      "google": {
        "registration": {
          "clientIdSettingName": "GOOGLE_CLIENT_ID",
          "clientSecretSettingName": "GOOGLE_PROVIDER_AUTHENTICATION_SECRET"
        }
      }
    }
  },
  "routes": [
    {
      "route": "/admin/",
      "allowedRoles": ["admin"]
    },
    {
      "route": "/",
      "allowedRoles": ["authenticated"]
    }
   ],
  "responseOverrides": {
    "404": {
      "rewrite": "/404/index.html",
      "statusCode": 404
    },
    "401": {
      "statusCode": 302,
      "redirect": "/.auth/login/google"
    }
  }
}

POSTリクエスト付帯されるボディ情報としては以下になります。こちらは、Azure EntraIDを設定した場合のサンプルになります。こちらの情報をもとに、ロールの判定を返答することで、ユーザーにカスタムロールをAPI経由で設定することができます。

// 仮のJSON
{
  "identityProvider": "aad",
  "userId": "00aa00aa-bb11-cc22-dd33-44ee44ee44ee",
  "userDetails": "ellen@contoso.com",
  "claims": [
      {
          "typ": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
          "val": "ellen@contoso.com"
      },
      {
          "typ": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname",
          "val": "Contoso"
      },
      {
          "typ": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname",
          "val": "Ellen"
      },
      {
          "typ": "name",
          "val": "Ellen Contoso"
      },
      {
          "typ": "http://schemas.microsoft.com/identity/claims/objectidentifier",
          "val": "7da753ff-1c8e-4b5e-affe-d89e5a57fe2f"
      },
      {
          "typ": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier",
          "val": "00aa00aa-bb11-cc22-dd33-44ee44ee44ee"
      },
      {
          "typ": "http://schemas.microsoft.com/identity/claims/tenantid",
          "val": "3856f5f5-4bae-464a-9044-b72dc2dcde26"
      },
      {
          "typ": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
          "val": "ellen@contoso.com"
      },
      {
          "typ": "ver",
          "val": "1.0"
      }
  ]
}

それでは、nest.jsで設定方法について見ていきます。エンドポイントから返す値としては、以下のフォーマットに合わせる必要があります。

{ roles: string[] }

ロール判定の結果、空配列を返答した場合でもSWA側でanonymousauthenticated が自動で割り振られます。

import { Body, Controller, Get, Post } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  // https://learn.microsoft.com/ja-jp/azure/static-web-apps/authentication-custom?tabs=google%2Cfunction
  @Post('/api/assignRoles')
  async postHello(@Body() req: { userId: string; userDetails: string }): Promise<{ roles: string[] }> {
    // proiderがgoogleの場合はuserDetailsはメールアドレス情報になっている
    // 不安な場合は/.auth/meのエンドポイントを確認
    // 受け取った情報をもとにrolesを割り当てて返信する
    // 何もrolesがない場合はから配列を返答する
    
    // 検証のためメールアドレスによる判定を行う
    const roles = await this.appService.assignRoles(req.userDetails);
    
    
    return { roles: roles };
  }
}

動作確認としては、SWA側で/.auth/meにアクセスすることで確認することができます。ローカルでは、ロール判定も含めてエミュレートするため、エミュレート認証情報が表示されています。

こちらの画像上Users rolesに文字列で設定することで再現することができます。

ロールの設定が完了すれば、staticwebapp.config.jsonallowedRolesで設定することで特定のロールのみが見れるページを制御することが可能になります。

バックエンドでの認証ユーザーの情報にどのようにアクセスするのか?

先ほどのロールベースでの確認で/.auth/meというエンドポイントを使用しました。フロントエンドからGETリクエストを投げることで、認証情報を取得することができます。

技術としてはフロントから自分のIDを取得・指定してAPIアクセスができます。これは、ユーザIDを改ざんしてAPIアクセスすることができてしまうため非推奨です。

SWAからAPI連携しているバックへの認証情報の受け渡しは、ヘッダーに自動挿入されているx-ms-client-principalを活用して行います。こちらは、Base64でエンコードされた情報として贈られるためでコードをすることで、バックエンドで認証情報を取得することができます。デコードされて受け取ることができる値は以下になります。

type clientPrincipal = {
  userId: string;             // ユーザーID
  userRoles: string[];        // ユーザーロール
  identityProvider: string;   // 認証プロバイダー
  userDetails: string;        // ユーザー情報 Googleが認証プロバイダーの場合はメールアドレス
};

それでは、実装に入っていきます。実装としてはnest.jsのGuardを使用してデコードとリクエストに積み替えを行い、contorllerでは情報を使うだけという実装にしていきます。

まずはGuardを実装します。デコードの方法としては、公式のサンプルを参考に実装しています。処理としては、以下になります。

  • ヘッダーからx-ms-client-principalの取得・確認
  • デコード
  • リクエストに詰め替え
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { Observable } from 'rxjs';
import  as base64 from 'base-64';
import  as utf8 from 'utf8';

type clientPrincipal = {
  userId: string;
  userRoles: string[];
  identityProvider: string;
  userDetails: string;
};
// このガードは、Azure Static Web Apps (SWA) の認証ヘッダーを検証するために使用されます。
// SWAは、認証されたユーザーの情報を 'x-ms-client-principal' ヘッダーに含めて送信します。
// このガードは、ヘッダーが存在し、正しい形式であることを確認し、
// ユーザー情報をリクエストオブジェクトに追加します。
// 認証されていない場合は、UnauthorizedExceptionをスローします。
// 参考: https://learn.microsoft.com/ja-jp/azure/static-web-apps/user-information?tabs=javascript
@Injectable()
export class AuthGuardToSwaGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
    const request = context.switchToHttp().getRequest();
    const clientPrincipalEncoded = request.headers['x-ms-client-principal'] as string;

    if (!clientPrincipalEncoded) {
      // 認証情報ヘッダーがない場合、認証されていないとしてスロー
      throw new UnauthorizedException('X-MS-CLIENT-PRINCIPAL header not found. User is not authenticated.');
    }

    try {
      // Base64 デコードと UTF-8 デコード
      const decoded = utf8.decode(base64.decode(clientPrincipalEncoded));
      console.log(JSON.parse(decoded));

      const clientPrincipal: clientPrincipal = JSON.parse(decoded);

      request.user = clientPrincipal.userId;
      request.userRoles = clientPrincipal.userRoles;

      return true; // 認証成功
    } catch (error) {
      console.error('Failed to decode or parse x-ms-client-principal:', error);
      // デコードやパースに失敗した場合、不正なヘッダーとしてスロー
      throw new UnauthorizedException('Invalid X-MS-CLIENT-PRINCIPAL header format.');
    }
  }
}

Controller実装です。Guardを挿入するとリクエストに積み替えられて取得することができます。

import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
import { AppService } from './app.service';
import { AuthGuardToSwaGuard } from './common/guard/auth-guard-to-swa/auth-guard-to-swa.guard';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}
  
  // SWAの認証情報が必要
  @UseGuards(AuthGuardToSwaGuard)
  @Get('/api/hello')
  async getHello(@Req() req): Promise<string> {
    console.log('getHello called with user:', req.user);
    console.log('getHello called with userRoles:', req.userRoles);
    
    return "hello";
  }
}

確認のため、コンソールに出力しています。あとは、ユーザIDやロールなどの情報をもとに制御することができます。

コラム: staticwebapp.config.json vs. バックエンドAPIでのアクセス制限

SWAでは認証・認可のアプローチとして2つの方法があることがわかりました。設定ファイルでの制御とバックエンドAPIでの制御、これらはどう違うのでしょうか?実際のプロジェクトでどちらを選ぶべきか、迷うところですよね。 実は、この2つのアプローチはそれぞれ異なるタイミングで動作し、得意分野も違います。以下のシーケンス図で、両者の動作の違いを見てみましょう。

比較表:どちらを選ぶべきか?

制御の場所Azure Static Web Apps (SWA) エッジノード / フロントエンドバックエンドAPIアプリケーション内
処理のタイミングAPIへのリクエストがSWAに到達した直後(API実行前)APIがリクエストを受け取り、処理を開始した後
実装方法設定ファイル (staticwebapp.config.json) の記述プログラミング言語(TypeScript/NestJS)、ガード、デコレーター、ミドルウェア、ロジックコード
制御の粒度粗い(URLパスベースの許可/拒否)細かい(特定のエンドポイント、メソッド、データのフィールド、ビジネスロジック)
主なユースケース– エンドポイント全体へのアクセス制限 – 特定のAPIグループへのアクセス制限 – 不要なバックエンドAPI呼び出しの防止– データレベルのアクセス制御(例:自分のデータのみ表示) – 情報のマスキング/匿名化 – 特定のアクションの制限(例:更新/削除) – 動的な条件に基づくアクセス制御 – より複雑なビジネスルールに基づく認可
パフォーマンス高速(APIが呼び出されないため)わずかなオーバーヘッド(APIが呼び出され、処理が実行されるため)
設定の柔軟性静的(デプロイが必要)動的(コード変更とデプロイが必要だが、データベースなどと連携すれば実行時にも柔軟な制御が可能)
開発の容易性シンプル(設定ファイルの記述のみ)複雑(コードの記述、フレームワークの学習、設計が必要)
エラーハンドリングSWAが自動的に401/403を返すカスタムエラーレスポンス(例:NestJSのForbiddenException)を細かく制御可能
推奨される利用最初の防衛線として、大まかなアクセス制御詳細なビジネスロジックに基づいた認可、データ処理、複雑なロール要件

使い分けの提案

それぞれの特徴を理解した上で、実際の開発ではどのように使い分けるべきでしょうか?

staticwebapp.config.jsonを使うべき場面

  • 「最初の防衛線」として、粗い粒度(URLパス全体)でのアクセス制限に最適です。バックエンドへの不要なリクエストを防ぐことで、パフォーマンスの向上とセキュリティの強化を同時に実現できます。
  • 管理者専用ページへのアクセス制限
  • 認証済みユーザーのみがアクセス可能なエリアの制御
  • 特定のAPIエンドポイントグループへの一律制限

バックエンドAPIを使うべき場面

  • 「より詳細な制御」が必要な場合に必須となります。データレベル、アクションレベル、または複雑なビジネスロジックに基づいた認可を実現したい場合は、こちらを選択しましょう。情報マスキングやフィルタリングもここで実現できます。

特に、アクセスするユーザーによってデータが変動するケースでは、APIベースでの認証が重要になります。

具体例:

  • ブログ記事の閲覧制限: 一般ユーザーは公開記事のみ、プレミアムユーザーは全記事を閲覧可能にする場合
  • 投稿コンテンツの編集権限: 投稿されたコメントの編集・削除権限を、投稿者本人と管理者のみに制限する場合
  • ダウンロード容量制限: ダウンロード可能なファイルの容量制限を、無料ユーザーは100MB、有料ユーザーは1GBまでとする場合

両方を組み合わせるアプローチ

最も堅牢で柔軟なシステムを構築するには、両方を組み合わせることをお勧めします。

  1. staticwebapp.config.jsonで大まかなアクセス制御を行い、不要なAPI呼び出しをブロック
  2. バックエンドAPIで詳細なビジネスロジックに基づいた認可処理を実装

この多層防御のアプローチにより、パフォーマンスとセキュリティの両方を最適化できます。

まとめ

今回は、Azure Static Web Appsにおける認証とユーザー制御について、フロントエンドとバックエンドの両方のアプローチを詳しく見てきました。特に、x-ms-client-principalを活用した安全なユーザー特定と、きめ細かなロールベース制御の実装方法について解説しました。この知識を活かして、より堅牢なWebアプリケーションの開発に取り組んでいただければ幸いです。

ご覧いただきありがとうございます! この投稿はお役に立ちましたか?

役に立った 役に立たなかった

0人がこの投稿は役に立ったと言っています。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です