Azure SWA×Next.js認証API統合を実践解説【DevContainer〜本番まで】

Azure SWA×Next.js認証API統合を実践解説【DevContainer〜本番まで】

目次

挨拶

ども!6月は結構激しめにブログを投稿していたんですが、7月もがっつり忙しくなりそうで、ウハウハの龍ちゃんです。ブログサムネイル評価用プロンプトを結構な時間をかけて作りました。今までのように人にSlack投げて確認ではなく、生成AIにチャットを投げるだけで確認出来ており、サムネイルにも力が入っています。

さて!今回は「Azure Static Web Apps+Bicepの発展編:Azure Static Web Apps+Managed Functions+GitHub(カスタム認証)の環境をGitHub Actions after Bicepでデプロイ」というてんこ盛りの内容になっています。こちらを理解できれば、Azure Static Web Appsの基礎は出来上がったように感じます。

なかなかの長編になったので、初めての方は5日ほどかけてじっくりと、中級者以上の方はミスを指摘してやるってテンション感でご覧ください。

はじめに・概要

この記事で学べること

今回は、環境構築からデプロイまで横断的に解説していきます。以下にまとめます。

環境構築:DevContainer

  • ローカル環境でAzure Static Web AppsとAzure Functionsを開発するために必要な環境
  • ローカル環境からBicepでAzureリソースを管理するのに必要な環境

Azure Functions開発(Managed Functions Node with TypeScript)

  • APIの簡易的な仕様書から、TypeScriptによる実際のコーディング

Azure Static Web Apps開発(Next.js)

  • GitHub OAuthを活用した認証基盤
  • Managed Functionsと連携して認証状態を管理する方法

Bicep:ローカル環境からAzureリソースのコマンドデプロイ

  • デプロイ環境をコード一発でデプロイ・クリーンナップできるようなbashファイル管理

GitHub Actions

  • Azure Static Web AppsとAzure Functions(Managed Functions)をGitHub Actionsからデプロイする

設計から、Azure Static Web AppsとAPI統合、認証機能の実装方法。DevContainerからBicep、GitHub Actionsまでの一連の開発フローとして学習することができます。

以降はAzure Static Web AppsはSWAと省略した形で解説していきます。

技術スタック

使用している技術などは以下にまとめました。

カテゴリ技術・ツールバージョン備考
デプロイ環境Azure Static Web AppsStandardプラン:カスタム認証(GitHub)
Azure FunctionsManaged Functions
開発環境DevContainer
Azure CLI2.74.0
SWA CLI2.0.6
GitHub CLI2.74.2
Function Core Tools4.0.7317
フロントエンドNext.js15.3.4静的エクスポート
React19
バックエンドNode.js + TypeScriptAzure Functions v4 Programming Model

今回は、データベースなどは抜きにしてSWA内ですべてが完結しているように構成しています。1点注意点としては、カスタム認証を使用する関係上、SWAのプラン設定をStandardにする必要があります。無料枠でも十分強力ですが、認証プロバイダーの追加設定などはStandardプランからでないと使用することができません。認証機能が必要ない方は、Maneged Functionsも無料枠があるのでBicepテンプレートをカスタマイズしてご利用ください。

Azure Static Web Appsには、個人のプロジェクトや小規模なウェブサイトに適したFreeプランと、運用環境のアプリケーションや大規模なプロジェクトに適したStandardプランがあります。それぞれのプランで利用できる機能の範囲が異なり、特にManaged Functionsの利用方法に影響があります。

プラン別機能比較

機能Freeプラン<br/>(個人/小規模プロジェクト向け)Standardプラン<br/>(運用/大規模プロジェクト向け)
ウェブホスティング
GitHub/Azure DevOps統合
グローバル分散✔ (静的コンテンツ)✔ (静的コンテンツ)
SSL証明書✔ (無料、自動更新)✔ (無料、自動更新)
ステージング環境アプリあたり3つまでアプリあたり10個まで
アプリの最大サイズアプリあたり250MBアプリあたり500MB
カスタムドメインアプリあたり2つまでアプリあたり5つまで
API (Azure Functions)マネージド (SWAに統合)マネージド または 独自のFunctionsアプリ利用
認証プロバイダー事前構成済み (サービス定義済み)カスタム登録も可能
関数によるカスタムロール
プライベートエンドポイント
SLA (サービスレベル契約)なし99.95%
帯域幅サブスクリプションあたり月100GBまで (無料)サブスクリプションあたり月100GBまで (超過分課金)
カスタマーサポートなし

Managed Functionsが受ける影響

Azure Static Web AppsにおけるAPIバックエンドとしてのManaged Functionsは、選択するプランによって利用方法が大きく異なります。

Freeプランの場合

  • Managed Functionsのみが利用可能です。これはAzure Static Web Appsに組み込まれたFunctionsアプリであり、ユーザーが個別にAzure Functionsリソースを作成・管理する必要がありません
  • APIの実行回数には、月間100万回の無料実行が含まれます
  • コールドスタートが発生しやすく、API呼び出しの最初の応答に時間がかかる場合があります

Standardプランの場合

  • Managed Functionsの利用に加えて、既存の独自のAzure FunctionsアプリをStatic Web AppsのAPIバックエンドとして持ち込むことが可能です
  • 独自のFunctionsアプリを利用することで、HTTP以外のトリガー(例: タイマートリガー、キュートリガー)や、より複雑なバインディングを利用できるようになります
  • 既存のFunctionsアプリをStatic Web Appsに統合したい場合に特に便利です

どちらのプランを選ぶべきか?

以下の3つの観点から、Standardプランを検討してください:

1. アーキテクチャ戦略(マイクロサービス化)

  • フロントエンドとバックエンドを分離したマイクロサービス構成にしたい場合
  • 既存のAzure Functionsアプリを再利用したい、または独立して管理したい場合
  • HTTPエンドポイント以外のトリガー(タイマー、キュー等)が必要な場合

2. プロジェクトの成長・運用要件

  • 予想されるトラフィック量が多い、または帯域幅の無料枠を超える可能性がある場合
  • ミッションクリティカルなアプリケーションで99.95%のSLAが必要な場合
  • 3つ以上のステージング環境(開発、検証、本番等)が必要な場合

3. 認証・セキュリティ要件

  • カスタム認証プロバイダーの登録が必要な場合(GitHub、Google以外の独自認証等)
  • 関数によるカスタムロール制御が必要な場合
  • プライベートエンドポイントでのセキュアな通信が必要な場合

まとめ

基本的には、個人的なプロジェクトやシンプルなウェブサイトであればFreeプランで十分ですが、ビジネス用途や大規模なアプリケーションにはStandardプランの検討をお勧めします。

今回の記事では、カスタム認証機能を使用するためStandardプランを選択していますが、認証が不要な場合はFreeプランでも同様の構成を構築できます!というか、課金が毎秒走っていると考えると怖いので、極力Freeプランを使うようにしています。

最終的な成果物

今回解説するアプリの動画です。ページとAPIをそれぞれ2つずつ作成し、認証あり・なしのパターンを網羅できるよう設計しています。

前提条件・準備

前提条件は以下の通りです。

  • Azureで有効なサブスクリプションを持っている
  • GitHub.comに登録済み
  • Dockerを使用できる環境

今回は、Azure Static Web AppsのStandardプランを使用するため、有効なサブスクリプションが必要です。また、GitHub Actionsを使用してデプロイを行い、認証プロバイダーにGitHub OAuthを使用するため、GitHub.comのアカウントは必須となります。

Dockerに関しては、今回の技術スタックはNode.jsに統一しているため、ローカル環境に有効なNode.js環境があれば開発を進めることができます。ただし、Dockerが入っていればコピー&ペーストで環境が立ち上がるため、こちらを推奨します。

プロジェクト設計・アーキテクチャ

システム構成図

システム構成に関しては、上記のようになっています。作成するAzureリソースとしては、SWAのみとなっており、カスタム認証プロバイダーとしてGitHub OAuthを使用します。デプロイに関しては、Azureリソースに関してはBicep、アプリケーションのデプロイに関してはGitHub Actionsでそれぞれ管理します。

フロントエンドに関しては、Next.jsの静的エクスポートを使用してデプロイを行います。

ディレクトリ構造

.
├── .github                         # GitHub Actions用
├── api                             # API用ディレクトリ
├── frontend                        # フロント用ディレクトリ Next.js静的エクスポート
├── infra                           # Infrastructure as Code用ディレクトリ
└── swa-cli.config.json             # SWA CLIのローカル起動用設定ファイル

全体のディレクトリ構成としては上記のようになります。それぞれ、他のリソースを参照せずに個別のディレクトリでそれぞれ開発をすることができます。

フロントエンド設計

ページ構成と各ページの目的

それでは、今回のアプリケーションのページ構成について見ていきましょう。まず、作成するルート一覧を明示しておきますね。

作成ルート一覧

  • / – トップページ(パブリック)
  • /protected – 保護ページ(認証必須)

シンプルな構成ですが、それぞれに明確な役割があります。

トップページ(/

トップページは、いわば「認証の玄関口」として設計しています。ここでの主な目的は以下の通りです:

  • 認証状態の可視化: ユーザーが現在ログインしているかどうかを一目で分かるようにする
  • 認証フローの起点: 未認証ユーザーにとってのログイン入口
  • ユーザー情報の表示: 認証済みユーザーには基本的なプロフィール情報を提示
  • ナビゲーションハブ: /protectedへのリンク

実装としては、認証状態に応じて動的にUIを切り替える設計になっています。未認証時は「GitHubでログイン」ボタンを表示し、認証済み時はユーザー名やアバター、そして保護ページへのリンクを表示します。これにより、ユーザーは自分の状態を即座に把握できるというわけですね。

保護ページ(/protected

保護ページは、Azure SWAの認証機能の真価を発揮する場所です。ここでの設計ポイントは:

  • Azure SWAによる保護: フロントエンドでの認証チェックに依存しない
  • 認証済みユーザー専用コンテンツ: より詳細なユーザー情報や機能の提供
  • セキュリティ情報の表示: 認証システムの仕組みを理解してもらう

この設計の面白いところは、ソースコード内で「認証チェック」を行わないことです。Azure SWAを使う場合は、サーバーレベルで保護されているため、そもそも未認証ユーザーはこのページにたどり着けません。

各ページのデザインは今回主題ではないので、生成AIを活用してデザインを生成してもらいます。

ルート保護設計とアクセスフロー

Azure SWAの最大の魅力の一つが、宣言的なルート保護による強力なセキュリティ機能です。設定一つで堅牢な認証システムを実現できるというのは、本当に画期的ですよね。

アクセスフロー全体像

今回の設計で重要となる点としては、401エラー(未認証)が発生した瞬間に、ユーザーを/.auth/login/githubに自動リダイレクトする仕組みです。これにより、ユーザーは「アクセス拒否」のような不親切な画面を見ることなく、スムーズに認証フローに誘導されます。

認証完了後は、元々アクセスしようとしていたページに戻ってくるのも、Azure SWAが自動的に処理してくれます。フロントエンド側で複雑なリダイレクト制御を書く必要がないというのは、大きなメリットです。

API設計

エンドポイント仕様の全体像

今回のシステムでは、以下の2つの主要エンドポイントを設計しました。

  • /api/user-info: ユーザー情報取得(認証状態問わず)
  • /api/protected-data: 保護されたデータ取得(認証必須)

/api/user-infoでは、フロントエンドから認証状態と未認証状態でのアクセスを確認するために、認証状態を問わずアクセスできるエンドポイントとして設計しています。

/api/protected-dataでは、認証されていない場合はエラーを返答し、ダミーデータを返すエンドポイントとして設計しています。

user-infoエンドポイントの設計

user-infoエンドポイントは、現在のユーザーの認証状態と基本情報を返すAPIです。未認証時でもエラーにならずにリクエストが成功するように設計します。

リクエスト仕様

  • Method: GET
  • Path: /api/user-info
  • Authentication: 不要
  • Parameters: なし

レスポンス型定義

interface UserInfo {
    userId: string | null;
    name: string | null;
    email: string | null;
    provider: string | null;
    roles: string[];
    isAuthenticated: boolean;
}

成功レスポンス例(認証済み)

{
    "userId": "github|12345678",
    "name": "yamada.taro",
    "email": "yamada.taro@example.com",
    "provider": "github",
    "roles": ["authenticated"],
    "isAuthenticated": true
}

成功レスポンス例(未認証)

{
    "userId": null,
    "name": null,
    "email": null,
    "provider": null,
    "roles": [],
    "isAuthenticated": false
}

ここで重要なのは、認証されていない場合もエラーではなく正常なレスポンスを返すことです。isAuthenticated: falseとして状態を明示することで、フロントエンド側で適切な処理分岐ができます。

protected-dataエンドポイントの設計

protected-dataエンドポイントは、認証が必須のAPIです。認証されていないユーザーには401エラーを返し、アクセスを拒否します。

リクエスト仕様

  • Method: GET
  • Path: /api/protected-data
  • Authentication: 必須
  • Parameters: なし

レスポンス型定義

interface ProtectedData {
    userId: string;
    message: string;
    timestamp: string;
    userNumber: number;
}

成功レスポンス例

{
    "userId": "github|12345678",
    "message": "こんにちは、ユーザーgithub|12345678さん!",
    "timestamp": "2024-12-15T15:45:30.000Z",
    "userNumber": 456
}

エラーレスポンスの統一設計

APIの一貫性を保つため、エラーレスポンス形式を統一しています。すべてのエラーレスポンスは以下の構造に従います。

エラーレスポンス型定義

interface ErrorResponse {
    error: string;
    message: string;
}

認証エラー(401 Unauthorized)

{
    "error": "Unauthorized",
    "message": "保護されたデータにアクセスするには認証が必要です"
}

サーバーエラー(500 Internal Server Error)

{
    "error": "Internal server error",
    "message": "Application Crash"
}

この統一により、フロントエンド側でのエラーハンドリングが大幅に簡素化されます。どのAPIでも同じ構造でエラー情報を取得できるためです。

認証チェック方法の設計

Azure SWAでは、認証済みユーザーの情報がx-ms-client-principalヘッダーにBase64エンコードされたJSON形式で渡されます。これは、Azure SWAの大きな特徴の一つですね。

ClientPrincipal型定義

interface ClientPrincipal {
    identityProvider: string;
    userId: string;
    userDetails: string;
    userRoles: string[];
}

認証チェックの流れは以下のようになります:

  1. リクエストヘッダーからx-ms-client-principalを取得
  2. Base64デコードしてJSON解析
  3. 成功時は認証済みとして処理、失敗時は未認証として処理

このヘッダーはAzure SWAのリバースプロキシによって自動的に付与されるため、偽装される心配はありません。

こちらの処理の流れは公式リファレンスにまとまっています。

開発環境構築(DevContainer)

この章では、DevContainer環境を構築していきます。作成するディレクトリ構成は以下になります。

.
└── .devcontainer
    └── devcontainer.json

DevContainer設定 devcontainer.json

今回の開発に必要なツールを全て含んだDevContainer設定を作成します。Node.js、Azure CLI、Functions Core Tools、SWA CLIを含む開発環境を構築していきましょう。

{
    "name": "Azure SWA include API & Bicep Development",
    "image": "mcr.microsoft.com/devcontainers/javascript-node:1-20-bullseye",
    "features": {
        "ghcr.io/devcontainers/features/azure-cli:1": {},
        "ghcr.io/devcontainers/features/github-cli:1": {}
    },
    // npm@11.4.2に関してはnoticeが出ていたので対応
    "postCreateCommand": "npm install -g npm@11.4.2 @azure/static-web-apps-cli azure-functions-core-tools@4",
    "customizations": {
        "vscode": {
            "extensions": [
                "ms-azuretools.vscode-bicep",
                "GitHub.vscode-github-actions",
                "ms-vscode.azure-account"
            ]
        }
    },
    "forwardPorts": [
        3000,
        7071,
        4280
    ],
    "mounts": [
        "source=${localEnv:HOME}/.azure,target=/home/node/.azure,type=bind,consistency=cached"
    ]
}

ベースイメージとインストール

ベースイメージとしては、Microsoftが提供しているNode環境を使用しています。Node.js 20系の安定版が含まれており、今回のプロジェクトに必要な環境が整っています。

featuresとして、Azure CLIとGitHub CLIを導入しています。これにより、Azureリソースの管理とGitHubとの連携が可能になります。

postCreateCommandで、npmパッケージとしてFunctions Core Tools@v4とSWA CLIをグローバルインストールしています。npmのバージョンアップも行っており、これは最新の機能とセキュリティ修正を適用するためです。

VS Code拡張機能

開発効率を上げるため、以下の拡張機能を自動インストールします:

  • Bicep: Azureリソース定義のシンタックスハイライトと補完
  • GitHub Actions: ワークフロー編集の支援
  • Azure Account: Azure認証とリソース管理

ポートフォワーディング

開発中に使用する主要ポートを事前に設定しています:

  • 3000: Next.jsフロントエンド
  • 7071: Azure Functions API
  • 4280: SWA CLI統合サーバー

マウント設定

"mounts": [
    "source=${localEnv:HOME}/.azure,target=/home/node/.azure,type=bind,consistency=cached"
]

ホスト側にある.azureディレクトリをマウントしています。これにより、ホスト側でaz loginをしていればDevContainer環境内でも認証済みとして扱うことができます。

セキュリティに関する注意
この設定は、Azure認証情報をコンテナ内で共有するため、セキュリティリスクがあります。使用する場合は十分に注意し、本番環境や共有環境では適切な認証方法を検討してください。

開発環境の起動確認

環境を立ち上げた後は、各CLIが正常にインストールされているか確認しましょう。

Azure CLI確認

az --version
# azure-cli                         2.74.0

# core                              2.74.0
# telemetry                          1.1.0
#
# Dependencies:
# msal                              1.32.3
# azure-mgmt-resource               23.3.0
#
# Python location '/opt/az/bin/python3'
# Config directory '/home/node/.azure'
# Extensions directory '/home/node/.azure/cliextensions'
# 
# Python (Linux) 3.12.10 (main, May 27 2025, 09:13:17) [GCC 10.2.1 20210110]
#
# Legal docs and information: aka.ms/AzureCliLegal
#
#
# Your CLI is up-to-date.

併せてマウント設定が正しく動作しているか。認証状態の確認をしておきましょう。

az account show

ホスト側で認証済みの場合、アカウント情報が表示されます。認証されていない場合は、以下のコマンドで認証を行ってください。

az login

GitHub CLI確認

gh --version
# gh version 2.74.2 (2025-06-18)
# https://github.com/cli/cli/releases/tag/v2.74.2

SWA CLI確認

swa --version
# 2.0.6

Functions Core Tools確認

func --version
# 4.0.7317

それぞれのバージョン情報が表示されれば成功です。これで、Azure Static Web Appsの開発に必要な全てのツールが使用可能になりました!

これで、DevContainer環境での開発準備が完了しました。次章からは、実際にAzure FunctionsのAPI実装に入っていきます。

Azure Functions API実装

この章では、Azure Functions環境を構築・実装をしていきます。作成するディレクトリ構成は以下になります。ファイルの作成自体は、直接作るのではなくFunctions Core Toolsを介して生成をしていきます。

プロジェクト初期化

.
└── api
    ├── .funcignore
    ├── .gitignore
    ├── host.json
    ├── local.settings.json
    ├── package.json
    ├── package-lock.json
    ├── src
    │   └── functions
    │       ├── protected-data.ts
    │       └── user-info.ts
    ├── tsconfig.json
    └── .vscode
        └── extensions.json

まずは、apiディレクトリを用意してディレクトリに遷移します。中で初期化コマンドを実行すると以下のファイルが自動生成され、npm installが実行されます。

mkdir api
cd api

func init --typescript

これで初期化は完了です。次は、各エンドポイントの作成を進めていきます。エンドポイントはFunctions Core Tools(公式リファレンス)で生成します。コマンドを通して作成することで、基本の関数が準備された状態でスムーズな開発を進めることができます。使用することができるテンプレートは以下のコマンドで取得することができます。

 func templates list

/api/user-info エンドポイント実装

/api/user-infoエンドポイントの作成を進めていきます。apiディレクトリで以下のコマンドを実行してください。

func new --name user-info --template "HTTP trigger" --authlevel "anonymous"

上記のコマンドでsrc/functions内にuser-info.tsというファイルが生成されます。認証レベル「匿名」でuser-infoというファイル名で作成されます。作成されたファイルの中には、--nameで指定した名前のパスに対してHELLO Worldを返す関数が作成されています。

中身を確認して、変更を加えてください。

import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions';

interface ClientPrincipal {
    identityProvider: string;
    userId: string;
    userDetails: string;
    userRoles: string[];
}

interface UserInfo {
    userId: string;
    name: string;
    email: string;
    provider: string;
    roles: string[];
    isAuthenticated: boolean;
}

export async function userInfo(request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> {
    context.log('Processing user-info request');

    try {
        // x-ms-client-principalヘッダーの取得
        const clientPrincipalHeader = request.headers.get('x-ms-client-principal');
        
        if (!clientPrincipalHeader) {
            context.log('ヘッダーにx-ms-client-principalが確認することができず、認証状態を確認することができませんでした。');
            return {
                status: 200,
                jsonBody: {
                    isAuthenticated: false,
                    userId: null,
                    name: null,
                    email: null,
                    provider: null,
                    roles: []
                }
            };
        }

        // Base64デコード
        const clientPrincipalData = Buffer.from(clientPrincipalHeader, 'base64').toString('utf-8');
        const clientPrincipal: ClientPrincipal = JSON.parse(clientPrincipalData);

        context.log('Client Principal:', clientPrincipal);

        // ユーザー情報の整形
        const userInfo: UserInfo = {
            userId: clientPrincipal.userId,
            name: clientPrincipal.userDetails,
            email: clientPrincipal.userDetails, // GitHubの場合はユーザー名,Googleならメールアドレス
            provider: clientPrincipal.identityProvider,
            roles: clientPrincipal.userRoles || [],
            isAuthenticated: true
        };

        return {
            status: 200,
            headers: {
                'Content-Type': 'application/json'
            },
            jsonBody: userInfo
        };

    } catch (error) {
        context.log('Applicatoin Crash', error);
        
        return {
            status: 500,
            jsonBody: {
                error: 'Internal server error',
                message: 'Failed to process user information'
            }
        };
    }
}

app.http('user-info', {
    methods: ['GET'],
    authLevel: 'anonymous',
    route: 'user-info',
    handler: userInfo
});

こちらの関数での処理は、現在のユーザーの認証状態と基本情報を返すAPIです。特徴としては、未認証時でもエラーにならずにリクエストが成功します。

コードは大きく3つに分かれています。

  • ヘッダーからx-ms-client-principalを取得し、ない場合はstatus:200(ユーザー情報なし)を返答
  • ヘッダーからデコードしてユーザー情報を取得し、status:200(ユーザー情報あり)を返答
  • 上記の処理で問題が発生した場合はstatus:500を返答

デコードの処理は、公式リファレンスで紹介されている手法をもとに構築しています。その他のAPIの実装はAPI設計に準拠しています。

/api/protected-data エンドポイント実装

/api/protected-dataエンドポイントの作成を進めていきます。apiディレクトリで以下のコマンドを実行してください。

func new --name protected-data --template "HTTP trigger" --authlevel "anonymous"

上記のコマンドでsrc/functions内にprotected-data.tsというファイルが生成されます。認証レベル「匿名」でprotected-dataというファイル名で作成されます。作成されたファイルの中には、--nameで指定した名前のパスに対してHELLO Worldを返す関数が作成されています。

中身を確認して、変更を加えてください。

import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions';

interface ClientPrincipal {
    identityProvider: string;
    userId: string;
    userDetails: string;
    userRoles: string[];
}

interface ProtectedData {
    userId: string;
    message: string;
    timestamp: string;
    userNumber: number;
}

// 認証チェック用のヘルパー関数
function checkAuthentication(request: HttpRequest, context: InvocationContext): ClientPrincipal | null {
    const clientPrincipalHeader = request.headers.get('x-ms-client-principal');
    
    if (!clientPrincipalHeader) {
        context.log('認証ヘッダーが見つかりませんでした');
        return null;
    }

    try {
        const clientPrincipalData = Buffer.from(clientPrincipalHeader, 'base64').toString('utf-8');
        return JSON.parse(clientPrincipalData);
    } catch (error) {
        context.log('Client principalの解析に失敗しました:', error);
        return null;
		}
}

// 軽量なユーザー固有データ生成
function generateUserData(userId: string): ProtectedData {
    // userIdをベースにした簡単なシード値
    const seed = userId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
    
    return {
        userId,
        message: `こんにちは、ユーザー${userId}さん!`,
        timestamp: new Date().toISOString(),
        userNumber: seed % 1000 // 0-999の範囲のユーザー番号
    };
}

export async function protectedData(request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> {
    context.log('protected-dataリクエストを処理中');

    try {
        // 認証チェック
        const clientPrincipal = checkAuthentication(request, context);
        
        if (!clientPrincipal) {
            return {
                status: 401,
                headers: {
                    'Content-Type': 'application/json'
                },
                jsonBody: {
                    error: 'Unauthorized',
                    message: '保護されたデータにアクセスするには認証が必要です'
                }
            };
        }

        context.log('認証済みユーザー:', clientPrincipal.userId);

        // 軽量なダミーデータを生成
        const userData = generateUserData(clientPrincipal.userId);

        return {
            status: 200,
            headers: {
                'Content-Type': 'application/json'
            },
            jsonBody: userData
        };

    } catch (error) {
        context.log('protected-dataリクエストの処理中にエラー:', error);
        
        return {
            status: 500,
            jsonBody: {
                error: 'Internal server error',
                message: '保護されたデータの取得に失敗しました'
            }
        };
    }
}

app.http('protected-data', {
    methods: ['GET'],
    authLevel: 'anonymous',  // SWAのManaged Functionsのため
    route: 'protected-data',
    handler: protectedData
});

こちらの関数の処理は、認証されていないユーザーには401エラーを返し、認証済みの場合はダミーデータを返答します。

処理として、/api/user-info と大差ありません。先ほどのヘッダーの取得からデコードまでをヘルパー関数として切り出しています。

処理取しては、大きく4つに分類されます。

  • checkAuthentication:ヘッダーからx-ms-client-principalを取得、デコードした情報を返答する。情報がない場合は、nullを返答
  • checkAuthenticationからの戻り値がnullstatus:401を返答
  • checkAuthenticationからの戻り値がユーザー情報:generateUserDataを使用してダミー情報を作成して、status:200で返答
  • 上記の処理で問題が発生した場合はstatus:500を返答

デコードの処理は、公式リファレンスで紹介されている手法をもとに構築しています。その他のAPIの実装はAPI設計に準拠しています。

ローカルでのAPI動作確認

それでは、作成したAPIが正しく動作するかローカル環境で確認していきましょう。

ローカル環境の制限事項

ローカル環境では認証ヘッダーが自動付与されないため、テスト範囲は限定的ですが、基本的な動作確認は可能です。

認証ヘッダーの模擬

  • Azure SWA の認証ヘッダー(x-ms-client-principal)は自動付与されません
  • 認証済み状態のテストはローカルでは困難です
  • 本格的な認証テストは本番環境またはSWA CLI統合環境で行う必要があります

Azure Functions API のローカル起動

まず、APIディレクトリで Functions Core Tools を使ってローカルサーバーを起動します。

cd api

func start

正常に起動すると、以下のような出力が表示されます:

Azure Functions Core Tools
Core Tools Version:       4.0.7317 Commit hash: N/A +5ca56d37938824531b691f094d0a77fd6f51af20 (64-bit)
Function Runtime Version: 4.1038.300.25164

[2025-06-30T14:09:22.328Z] Worker process started and initialized.

Functions:

        protected-data: [GET] http://localhost:7071/api/protected-data

        user-info: [GET] http://localhost:7071/api/user-info

For detailed output, run func with --verbose flag.

エンドポイントが正しく表示されていることを確認してください。

/api/user-info エンドポイントのテスト

まずは認証状態を問わずアクセス可能な user-info エンドポイントをテストします。こちらはcURLでもよいですし、ブラウザで実行してもよいです。

cURLでのテスト

curl http://localhost:7071/api/user-info

期待されるレスポンス

ローカル環境では認証ヘッダーが存在しないため、未認証状態のレスポンスが返されます:

{
  "userId": null,
  "name": null,
  "email": null,
  "provider": null,
  "roles": [],
  "isAuthenticated": false
}

確認ポイント

  • ステータスコードが 200 OK であること
  • レスポンスの型が設計通りであること
  • isAuthenticatedfalse になっていること
  • 各プロパティが正しく null または空配列になっていること

/api/protected-data エンドポイントのテスト

次に、認証が必須の protected-data エンドポイントをテストします。

cURLでのテスト

curl http://localhost:7071/api/protected-data

期待されるレスポンス

認証ヘッダーが存在しないため、401エラーが返されます:

{
  "error": "Unauthorized",
  "message": "保護されたデータにアクセスするには認証が必要です"
}

確認ポイント

  • ステータスコードが 401 Unauthorized であること
  • エラーレスポンスの形式が統一されていること
  • errormessage プロパティが含まれていること

デバッグとログ確認

開発中は、Function Core Tools のコンソール出力でデバッグ情報を確認できます。

ログ出力の確認

API関数内の context.log() による出力がコンソールに表示されます:

[2025-06-30T14:13:03.946Z] Executing 'Functions.protected-data' (Reason='This function was programmatically called via the host APIs.', Id=3570bfd5-7ed0-4ea7-a34f-262df44324ac)
[2025-06-30T14:13:03.951Z] protected-dataリクエストを処理中
[2025-06-30T14:13:03.951Z] 認証ヘッダーが見つかりませんでした
[2025-06-30T14:13:03.952Z] Executed 'Functions.protected-data' (Succeeded, Id=3570bfd5-7ed0-4ea7-a34f-262df44324ac, Duration=5ms)

詳細ログの表示

より詳細なログが必要な場合は、--verbose オプションを使用します:

func start --verbose

トラブルシューティング

開発時に詰まった点をトラブルシューティングとしてまとめておきます。

エンドポイントにアクセスできない

  • Functions Core Tools が正常に起動しているか確認
  • ポート 7071 が他のプロセスで使用されていないか確認
  • ファイアウォール設定を確認

TypeScript コンパイルエラー

  • npm run build でビルドエラーがないか確認
  • 型定義の不整合がないかチェック

レスポンスが期待通りでない

  • context.log() でデバッグ出力を追加
  • リクエストヘッダーが正しく設定されているか確認

ソースコードに変更を加えたのに変更が反映されない

  • func startは変更後のソースを読み取って同期してくれません
  • 一度Ctrl+Cで実行を落として、再起動してみてください

これで、ローカル環境でのAPI基本動作確認が完了しました。次に、フロントエンドの実装に移りましょう。認証機能を含む完全なテストは、SWA CLI での統合テスト環境で行います。

Azure Functions では通常、API の認証レベルを functionadminanonymous などから選択できます。しかし、Azure Static Web Apps の Managed Functions では anonymous を設定するのが適切です。

Azure Functions単体での認証レベル

  • function:Function Key が必要
  • admin:Master Key が必要
  • anonymous:認証不要

SWA + Managed Functions での認証の仕組み

Azure Static Web Apps では、認証・認可を SWA側で一元管理 します:

  1. SWAレベルでの保護staticwebapp.config.json でルート保護を定義
  2. 統合セキュリティ/.auth システムによる認証管理
  3. Functions側はanonymous:SWAが認証済みリクエストのみをFunctionsに転送

つまり、Functionsレベルでの認証をバイパスし、SWAの統合認証システムを活用することで、よりシームレスで一元化された認証管理が可能になります。Managed Functions では、この仕組みにより安全性を保ちながら開発効率を向上させることができるのです。

Next.jsフロントエンド実装

この章では、Next,js環境を構築・実装をしていきます。作成するディレクトリ構成は以下になります。初期設定はcreate-next-app経由で作成するため、作成するファイルはuseAuth.ts/page.tsx/protected>page.tsxの3ファイルになります。

./frontend
├── eslint.config.mjs
├── next.config.ts
├── next-env.d.ts
├── package.json
├── package-lock.json
├── postcss.config.mjs
├── README.md
├── public
├── src
│   ├── app
│   │   ├── favicon.ico
│   │   ├── globals.css
│   │   ├── layout.tsx
│   │   ├── page.tsx                        // トップページ
│   │   └── protected
│   │       └── page.tsx           // 認証済みルート
│   └── hooks
│       └── useAuth.ts                      // 認証系Hooks
└── tsconfig.json

Next.js設定(静的エクスポート対応)

まずはfrontendディレクトリを作成して、ディレクトリに移動します。作成自体は、npx create-next-appコマンドを通して自動生成を行っています。バージョンとしては15固定で、プロジェクト設定を行っています。

mkdir frontend

cd frontend

npx create-next-app@15 . --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"

静的エクスポート設定のためにnext.config.tsを変種する必要があります。

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  /* config options here */
  output: 'export',                        // 静的エクスポート設定
  distDir: 'out',                          // ビルドファイルのエクスポート先設定
  images: {
    unoptimized: true                      // 静的エクスポートでは使用できない画像圧縮の無効化
  }
};

export default nextConfig;

これで、静的エクスポートが実装することができます。

useAuth認証カスタムフック実装

こちらのファイルはhooksディレクトリを作成し、useAuth.tsというファイル内にコピー&ペーストをしてください。

import { useState, useEffect } from 'react';

interface UserInfo {
    isAuthenticated: boolean;
    user?: {
        name?: string;
        email?: string;
        login?: string;
    };
}

export const useAuth = () => {
    const [userInfo, setUserInfo] = useState<UserInfo>({ isAuthenticated: false });
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState<string | null>(null);

    useEffect(() => {
        const fetchUserInfo = async () => {
            try {
                const response = await fetch('/.auth/me');

                if (response.ok) {
                    const data = await response.json();

                    if (data.clientPrincipal) {
                        setUserInfo({
                            isAuthenticated: true,
                            user: {
                                name: data.clientPrincipal.userDetails,
                                email: data.clientPrincipal.userDetails,
                                login: data.clientPrincipal.userId
                            }
                        });
                    } else {
                        setUserInfo({ isAuthenticated: false });
                    }
                } else {
                    setUserInfo({ isAuthenticated: false });
                }
            } catch (err) {
                console.error('認証情報の取得に失敗しました:', err);
                setError('認証情報の取得に失敗しました');
                setUserInfo({ isAuthenticated: false });
            } finally {
                setLoading(false);
            }
        };

        fetchUserInfo();
    }, []);

    const login = () => {
        window.location.href = '/.auth/login/github';
    };

    const logout = () => {
        window.location.href = '/.auth/logout';
    };

    return {
        ...userInfo,
        loading,
        error,
        login,
        logout
    };
};

処理としては、ログインとログアウトロジックがあります。こちらは、認証プロバイダー(GitHub)としてチューニングされています。

SWAでは認証済みの場合、直接エンドポイントとして/.auth/meに認証情報が含まれます。そちらに対して確認を行うことで、フロントエンド側から認証状態の確認を行うことができます。

認証状態の確認中はuseStateloadingで制御をしています。

トップページ実装

こちらはsrc > app > page.tsxファイルを編集してください。まずはコピー&ペーストをしてください。

'use client';

import Link from 'next/link';
import { useAuth } from '@/hooks/useAuth';
import { useState } from 'react';

interface ApiUserInfo {
  userId: string;
  name: string;
  email: string;
  provider: string;
  roles: string[];
  isAuthenticated: boolean;
}

interface ApiError {
  error: string;
  message: string;
}

export default function HomePage() {
  const { isAuthenticated, user, loading, login, logout } = useAuth();
  const [apiData, setApiData] = useState<ApiUserInfo | null>(null);
  const [apiError, setApiError] = useState<string | null>(null);
  const [apiLoading, setApiLoading] = useState(false);

  const fetchUserInfo = async () => {
    setApiLoading(true);
    setApiError(null);
    setApiData(null);

    try {
      const response = await fetch('/api/user-info', {
        method: 'GET',
        headers: {
          'Content-Type': 'application/json',
        },
        credentials: 'include', // 認証情報を含める
      });

      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }

      const data: ApiUserInfo | ApiError = await response.json();

      if ('error' in data) {
        setApiError(data.message || data.error);
      } else {
        setApiData(data);
      }
    } catch (error) {
      console.error('API呼び出しエラー:', error);
      setApiError(error instanceof Error ? error.message : 'APIの呼び出しに失敗しました');
    } finally {
      setApiLoading(false);
    }
  };

  if (loading) {
    return (
      <div className="min-h-screen flex items-center justify-center">
        <div className="text-center">
          <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
          <p className="text-gray-600">認証状態を確認中...</p>
        </div>
      </div>
    );
  }

  return (
    <div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
      <div className="container mx-auto px-4 py-16">
        <div className="max-w-4xl mx-auto text-center">
          <h1 className="text-5xl font-bold text-gray-900 mb-8">
            Next.js 15 + Azure SWA
          </h1>
          <p className="text-xl text-gray-600 mb-12">
            GitHub認証を使用したセキュアなWebアプリケーション
          </p>

          <div className="bg-white rounded-lg shadow-lg p-8 mb-8">
            <h2 className="text-2xl font-semibold text-gray-800 mb-6">
              認証状態
            </h2>

            {isAuthenticated ? (
              <div className="space-y-4">
                <div className="flex items-center justify-center space-x-2">
                  <div className="w-3 h-3 bg-green-500 rounded-full"></div>
                  <span className="text-green-700 font-medium">認証済み</span>
                </div>

                {user && (
                  <div className="bg-gray-50 rounded-lg p-4 text-left max-w-md mx-auto">
                    <h3 className="font-medium text-gray-800 mb-2">ユーザー情報(クライアント側)</h3>
                    {user.name && (
                      <p className="text-sm text-gray-600">
                        <span className="font-medium">名前:</span> {user.name}
                      </p>
                    )}
                    {user.email && (
                      <p className="text-sm text-gray-600">
                        <span className="font-medium">メール:</span> {user.email}
                      </p>
                    )}
                    {user.login && (
                      <p className="text-sm text-gray-600">
                        <span className="font-medium">ログイン:</span> {user.login}
                      </p>
                    )}
                  </div>
                )}

                <div className="flex flex-col sm:flex-row gap-4 justify-center mt-6">
                  <Link
                    href="/protected"
                    className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-3 px-6 rounded-lg transition duration-200"
                  >
                    保護されたページへ
                  </Link>
                  <button
                    onClick={logout}
                    className="bg-gray-500 hover:bg-gray-600 text-white font-medium py-3 px-6 rounded-lg transition duration-200"
                  >
                    ログアウト
                  </button>
                </div>
              </div>
            ) : (
              <div className="space-y-4">
                <div className="flex items-center justify-center space-x-2">
                  <div className="w-3 h-3 bg-red-500 rounded-full"></div>
                  <span className="text-red-700 font-medium">未認証</span>
                </div>

                <p className="text-gray-600 mb-6">
                  保護されたコンテンツにアクセスするには、GitHubアカウントでログインしてください。
                </p>

                <button
                  onClick={login}
                  className="bg-gray-900 hover:bg-gray-800 text-white font-medium py-3 px-6 rounded-lg transition duration-200 flex items-center space-x-2 mx-auto"
                >
                  <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
                    <path fillRule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clipRule="evenodd" />
                  </svg>
                  <span>GitHubでログイン</span>
                </button>
              </div>
            )}
          </div>

          {/* API情報取得セクション */}

          <div className="bg-white rounded-lg shadow-lg p-8 mb-8">
            <h2 className="text-2xl font-semibold text-gray-800 mb-6">
              API情報取得
            </h2>

            <div className="space-y-4">
              <button
                onClick={fetchUserInfo}
                disabled={apiLoading}
                className="bg-green-600 hover:bg-green-700 disabled:bg-green-400 text-white font-medium py-3 px-6 rounded-lg transition duration-200 flex items-center space-x-2 mx-auto"
              >
                {apiLoading ? (
                  <>
                    <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
                    <span>取得中...</span>
                  </>
                ) : (
                  <>
                    <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
                    </svg>
                    <span>サーバーからユーザー情報を取得</span>
                  </>
                )}
              </button>

              {apiError && (
                <div className="bg-red-50 border border-red-200 rounded-lg p-4 text-left max-w-md mx-auto">
                  <h3 className="font-medium text-red-800 mb-2">エラー</h3>
                  <p className="text-sm text-red-600">{apiError}</p>
                </div>
              )}

              {apiData && (
                <div className={" rounded-lg p-4 text-left max-w-md mx-auto" +
                 (apiData.isAuthenticated ? ' bg-green-50 border border-green-200' : ' bg-red-50 border border-red-200')}
                 >
                  <h3 className={"font-medium  mb-2" + (apiData.isAuthenticated ? " text-green-800": " text-red-800")}>サーバー側ユーザー情報</h3>
                  <div className={"space-y-1 text-sm " + (apiData.isAuthenticated ? " text-green-700": " text-red-700")}>
                    <p><span className="font-medium">認証状態:</span> {apiData.isAuthenticated ? '認証済み' : '未認証'}</p>
                    <p><span className="font-medium">ユーザーID:</span> {apiData.userId}</p>
                    <p><span className="font-medium">名前:</span> {apiData.name}</p>
                    <p><span className="font-medium">メール:</span> {apiData.email}</p>
                    <p><span className="font-medium">プロバイダー:</span> {apiData.provider}</p>
                    <p><span className="font-medium">ロール:</span> {apiData.roles.length > 0 ? apiData.roles.join(', ') : 'なし'}</p>
                  </div>
                </div>
              )}
            </div>
          </div>


          <div className="text-center text-gray-500">
            <p className="text-sm">
              このアプリケーションはAzure Static Web Appsで認証を管理しています
            </p>
          </div>
        </div>
      </div>
    </div>
  );
}

ロジック部分

ロジック部分を抽出しました。こちらは、ボタンを押した際に/api/user-infoに対してGETリクエストを送信して問い合わせを行っています。APIリクエストの状態管理はuseStateapiLoadingでエラーに関してはuseStateapiErrorで管理をしています。

interface ApiUserInfo {
  userId: string;
  name: string;
  email: string;
  provider: string;
  roles: string[];
  isAuthenticated: boolean;
}

interface ApiError {
  error: string;
  message: string;
}

const [apiData, setApiData] = useState<ApiUserInfo | null>(null);
const [apiError, setApiError] = useState<string | null>(null);
const [apiLoading, setApiLoading] = useState(false);

const fetchUserInfo = async () => {
  setApiLoading(true);
  setApiError(null);
  setApiData(null);

  try {
    const response = await fetch('/api/user-info', {
      method: 'GET',
      headers: {
        'Content-Type': 'application/json',
      }
    });

    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }

    const data: ApiUserInfo | ApiError = await response.json();

    if ('error' in data) {
      setApiError(data.message || data.error);
    } else {
      setApiData(data);
    }
  } catch (error) {
    console.error('API呼び出しエラー:', error);
    setApiError(error instanceof Error ? error.message : 'APIの呼び出しに失敗しました');
  } finally {
    setApiLoading(false);
  }
};

表示部分

表示部分としてはコードとして膨大なので、ソース添付としては割愛します。処理としては単純です。

認証確認中はuseAuthから提供されるloadingをフラグとしてローディング画面を表示しています。

認証後は、APIアクセス中はapiLoadingでボタンの非活性化とローディングの表示、エラーが発生した場合はエラーの表示を行っています。

protectedページ実装

こちらはsrc > app > protected > page.tsxファイルを新規作成してください。まずはコピー&ペーストをしてください。

'use client';

import Link from 'next/link';
import { useAuth } from '@/hooks/useAuth';
import { useState } from 'react';

interface ProtectedData {
  userId: string;
  message: string;
  timestamp: string;
  userNumber: number;
}

interface ApiError {
  error: string;
  message: string;
}

export default function ProtectedPage() {
  const { user, loading, logout } = useAuth();
  const [protectedData, setProtectedData] = useState<ProtectedData | null>(null);
  const [apiError, setApiError] = useState<string | null>(null);
  const [apiLoading, setApiLoading] = useState(false);

  const fetchProtectedData = async () => {
    setApiLoading(true);
    setApiError(null);
    setProtectedData(null);

    try {
      const response = await fetch('/api/protected-data', {
        method: 'GET',
        headers: {
          'Content-Type': 'application/json',
        }
      });

      if (!response.ok) {
        if (response.status === 401) {
          throw new Error('認証が必要です');
        }
        throw new Error(`HTTP error! status: ${response.status}`);
      }

      const data: ProtectedData | ApiError = await response.json();
      
      if ('error' in data) {
        setApiError(data.message || data.error);
      } else {
        setProtectedData(data);
      }
    } catch (error) {
      console.error('保護されたAPI呼び出しエラー:', error);
      setApiError(error instanceof Error ? error.message : '保護されたデータの取得に失敗しました');
    } finally {
      setApiLoading(false);
    }
  };

  if (loading) {
    return (
      <div className="min-h-screen flex items-center justify-center">
        <div className="text-center">
          <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
          <p className="text-gray-600">ユーザー情報を読み込み中...</p>
        </div>
      </div>
    );
  }

  return (
    <div className="min-h-screen bg-gradient-to-br from-green-50 to-emerald-100">
      <div className="container mx-auto px-4 py-16">
        <div className="max-w-4xl mx-auto">
          <div className="text-center mb-12">
            <div className="flex items-center justify-center space-x-2 mb-4">
              <div className="w-4 h-4 bg-green-500 rounded-full"></div>
              <span className="text-green-700 font-medium text-lg">保護されたエリア</span>
            </div>
            <h1 className="text-4xl font-bold text-gray-900 mb-4">
              🎉 認証成功!
            </h1>
            <p className="text-xl text-gray-600">
              このページは認証済みユーザーのみがアクセスできます
            </p>
          </div>

          <div className="bg-white rounded-lg shadow-lg p-8 mb-8">
            <h2 className="text-2xl font-semibold text-gray-800 mb-6 text-center">
              あなたの情報
            </h2>
            
            {user ? (
              <div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-lg p-6 mb-6">
                <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
                  {user.name && (
                    <div className="bg-white rounded-lg p-4">
                      <div className="text-sm text-gray-500 font-medium">名前</div>
                      <div className="text-lg text-gray-900">{user.name}</div>
                    </div>
                  )}
                  {user.email && (
                    <div className="bg-white rounded-lg p-4">
                      <div className="text-sm text-gray-500 font-medium">メールアドレス</div>
                      <div className="text-lg text-gray-900">{user.email}</div>
                    </div>
                  )}
                  {user.login && (
                    <div className="bg-white rounded-lg p-4">
                      <div className="text-sm text-gray-500 font-medium">GitHubユーザー名</div>
                      <div className="text-lg text-gray-900">@{user.login}</div>
                    </div>
                  )}
                  <div className="bg-white rounded-lg p-4">
                    <div className="text-sm text-gray-500 font-medium">アクセス日時</div>
                    <div className="text-lg text-gray-900">
                      {new Date().toLocaleString('ja-JP')}
                    </div>
                  </div>
                </div>
              </div>
            ) : (
              <div className="text-center py-8">
                <p className="text-gray-500">ユーザー情報が見つかりません</p>
              </div>
            )}

            <div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6">
              <h3 className="text-lg font-semibold text-blue-900 mb-3">
                🔒 セキュリティ情報
              </h3>
              <ul className="text-blue-800 space-y-2">
                <li>• このページはAzure SWAレベルで保護されています</li>
                <li>• 未認証ユーザーは自動的にGitHubログインにリダイレクトされます</li>
                <li>• 認証情報はAzure Static Web Appsで安全に管理されています</li>
              </ul>
            </div>

            <div className="flex flex-col sm:flex-row gap-4 justify-center">
              <Link
                href="/"
                className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-3 px-6 rounded-lg transition duration-200 text-center"
              >
                ホームに戻る
              </Link>
              <button
                onClick={logout}
                className="bg-red-500 hover:bg-red-600 text-white font-medium py-3 px-6 rounded-lg transition duration-200"
              >
                ログアウト
              </button>
            </div>
          </div>

          {/* 保護されたデータ取得セクション */}
          <div className="bg-white rounded-lg shadow-lg p-8 mb-8">
            <h2 className="text-2xl font-semibold text-gray-800 mb-6 text-center">
              🛡️ 保護されたデータ
            </h2>
            
            <div className="space-y-6">
              <div className="text-center">
                <p className="text-gray-600 mb-4">
                  サーバー側で認証を確認し、あなた専用のデータを取得します
                </p>
                <button
                  onClick={fetchProtectedData}
                  disabled={apiLoading}
                  className="bg-purple-600 hover:bg-purple-700 disabled:bg-purple-400 text-white font-medium py-3 px-6 rounded-lg transition duration-200 flex items-center space-x-2 mx-auto"
                >
                  {apiLoading ? (
                    <>
                      <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
                      <span>データ取得中...</span>
                    </>
                  ) : (
                    <>
                      <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
                      </svg>
                      <span>保護されたデータを取得</span>
                    </>
                  )}
                </button>
              </div>

              {apiError && (
                <div className="bg-red-50 border border-red-200 rounded-lg p-6">
                  <div className="flex items-center space-x-2 mb-2">
                    <svg className="w-5 h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
                    </svg>
                    <h3 className="font-medium text-red-800">エラー</h3>
                  </div>
                  <p className="text-sm text-red-600">{apiError}</p>
                </div>
              )}

              {protectedData && (
                <div className="bg-gradient-to-r from-purple-50 to-pink-50 border border-purple-200 rounded-lg p-6">
                  <div className="flex items-center space-x-2 mb-4">
                    <svg className="w-5 h-5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
                    </svg>
                    <h3 className="font-medium text-purple-800">取得成功 🎯</h3>
                  </div>
                  
                  <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
                    <div className="bg-white rounded-lg p-4">
                      <div className="text-sm text-gray-500 font-medium">ユーザーID</div>
                      <div className="text-lg text-gray-900 font-mono">{protectedData.userId}</div>
                    </div>
                    
                    <div className="bg-white rounded-lg p-4">
                      <div className="text-sm text-gray-500 font-medium">ユーザー番号</div>
                      <div className="text-lg text-gray-900 font-bold ">
                        #{protectedData.userNumber}
                      </div>
                    </div>
                    
                    <div className="bg-white rounded-lg p-4 md:col-span-2">
                      <div className="text-sm text-gray-500 font-medium">パーソナライズメッセージ</div>
                      <div className="text-lg text-gray-900">{protectedData.message}</div>
                    </div>
                    
                    <div className="bg-white rounded-lg p-4 md:col-span-2">
                      <div className="text-sm text-gray-500 font-medium">データ生成時刻</div>
                      <div className="text-lg text-gray-900 font-mono">
                        {new Date(protectedData.timestamp).toLocaleString('ja-JP')}
                      </div>
                    </div>
                  </div>

                  <div className="mt-4 p-3 bg-purple-100 rounded-lg">
                    <p className="text-sm text-purple-700">
                      💡 このデータはあなたのユーザーIDに基づいて動的に生成され、
                      サーバー側で認証を確認した後にのみ提供されます。
                    </p>
                  </div>
                </div>
              )}
            </div>
          </div>

          <div className="text-center text-gray-500">
            <p className="text-sm">
              Azure Static Web Apps + GitHub認証で実現したセキュアなアプリケーション
            </p>
          </div>
        </div>
      </div>
    </div>
  );
}

ロジック部分

ロジック部分を抽出しました。こちらは、ボタンを押した際に/api/protected-dataに対してGETリクエストを送信して問い合わせを行っています。APIリクエストの状態管理はuseStateapiLoadingでエラーに関してはuseStateapiErrorで管理をしています。

SWA側で401エラーが発生した場合は、アクセスできないように組みますが将来的なエラーハンドリングのために仮で実装しています。

interface ProtectedData {
  userId: string;
  message: string;
  timestamp: string;
  userNumber: number;
}

interface ApiError {
  error: string;
  message: string;
}

const [protectedData, setProtectedData] = useState<ProtectedData | null>(null);
const [apiError, setApiError] = useState<string | null>(null);
const [apiLoading, setApiLoading] = useState(false);

const fetchProtectedData = async () => {
  setApiLoading(true);
  setApiError(null);
  setProtectedData(null);

  try {
    const response = await fetch('/api/protected-data', {
      method: 'GET',
      headers: {
        'Content-Type': 'application/json',
      }
    });

    if (!response.ok) {
      if (response.status === 401) {
        throw new Error('認証が必要です');
      }
      throw new Error(`HTTP error! status: ${response.status}`);
    }

    const data: ProtectedData | ApiError = await response.json();
    
    if ('error' in data) {
      setApiError(data.message || data.error);
    } else {
      setProtectedData(data);
    }
  } catch (error) {
    console.error('保護されたAPI呼び出しエラー:', error);
    setApiError(error instanceof Error ? error.message : '保護されたデータの取得に失敗しました');
  } finally {
    setApiLoading(false);
  }
};

表示部分

表示部分としてはコードとして膨大なので、ソース添付としては割愛します。処理としては単純です。

認証確認中はuseAuthから提供されるloadingをフラグとしてローディング画面を表示しています。

認証後は、APIアクセス中はapiLoadingでボタンの非活性化とローディングの表示、エラーが発生した場合はエラーの表示を行っています。

ローカルでのフロントエンド動作確認

ローカルでフロントエンドのテストをします。APIとの接続を行っていないので、APIアクセスはすべて失敗するので画面上のテストのみになります。

npm run dev

# http://localhost:3000

ローカル結合テスト(SWA CLI)

ここでは、SWA CLIを使用してフロントエンドとAzure Functionsの結合テストを行っていきます。SWA CLIを使うことで、SWAに上げた時の挙動を認証部分を含めてエミュレーターで確認することができます。

SWA CLI設定

プロジェクトルートで、以下のコマンドを入力することで対話的にswa-cli.config.jsonを作成することが可能です。

swa init

設定項目は公式のこちらにまとまっています。若干設定が複雑な部分があるため、今回の環境に合わせた構成ファイルに対して解説します。

swaConfigLocationに関してのみ次の節で解説を行います。

フロントもバックもSWA CLIで起動する

こちらの構成ファイルでは、swa startでフロントもバックのリソースも立ち上がるようになります。利点としては、コマンド一つで立ち上げることができるので楽な点ですね。

フロントに関しては、devコマンドで起動されているためフロントのコードを変更することで自動的に更新(ホットリロード)が入ります。バックに関しては、更新を反映させるためには一度コマンドを落として再度実行する必要があります。

{
  "$schema": "https://aka.ms/azure/static-web-apps-cli/schema",
  "configurations": {
    "swa-api-bicep-sample": {
      "appLocation": "frontend",
      "apiLocation": "api",
      "outputLocation": "out",
      "apiLanguage": "node",
      "appBuildCommand": "npm run build",
      "apiBuildCommand": "npm run build --if-present",
      "run": "npm run dev",
      "appDevserverUrl": "http://localhost:3000",
      "swaConfigLocation": "frontend/config/local"
    }
  }
}

フロントのみSWA CLIで起動してバックは起動済みのものを使用する

こちらの構成ファイルでは、swa startでフロントのリソースのみを立ち上げ、バックエンドのリソースは提供済みのものを使用します。こちらの利点としては、バックエンドのリソースのみを分離して立ち上げ直しができる点ですね。その代わり、実行にはfunc start コマンドを別途実行してあげる必要があります。

{
  "$schema": "https://aka.ms/azure/static-web-apps-cli/schema",
  "configurations": {
    "swa-api-bicep-sample": {
      "appLocation": "frontend",
      "apiLocation": "api",
      "outputLocation": "out",
      "apiLanguage": "node",
      "appBuildCommand": "npm run build",
      "apiBuildCommand": "npm run build --if-present",
      "run": "npm run dev",
      "appDevserverUrl": "http://localhost:3000",
      "apiDevserverUrl": "http://localhost:7071",
      "swaConfigLocation": "frontend/config/local"
    }
  }
}

SWA設定ファイル(staticwebapp.config.json)

こちらのファイルは、認証ルートの定義やSWA側でサーバー側で発生したエラーをキャッチしてルーティングを制御します。先ほどのswa-cli.config.jsonswaConfigLocationを設定していましたが、これは開発環境と本番環境で二つのファイルを構成する必要があるためです。開発環境では、認証プロバイダーの設定をSWA CLIのエミュレーターで代用します。本番環境では、SWAの環境変数から認証プロバイダーに必要な情報を読み取って認証プロバイダーを構成します。

本番環境用の構成ファイルを使用してSWA CLIでローカルで立ち上げると認証時に失敗します。

SWA 設定ファイルは大部分がフロントのルーティング定義であるため、frontend > configとディレクトリを定義してその中にディレクトリ単位でlocalprodと分割して同名ファイルを保存します。

./frontend
└── config
    ├── local
    │   └── staticwebapp.config.json
    └── prod
        └── staticwebapp.config.json

開発環境用

SWAに認証されるとユーザーには自動的に「authenticated」と「anoymous」というロールが割り振られます。SWA Configでは、ロールによって表示することができるルート制御というのがあり、今回であれば/protectedルートではauthenticatedロールにアクセスを許可しています。それ以外のルートに関しては、200で許可しています。もし未認証の方が/protectedにアクセスした場合はSWA側で401エラーが発生して、自動的に認証画面にリダイレクトするようにresponseOverrdesで設定しています。ここをエラー用のページに設定しておけば、自動でエラー画面を表示するなんてこともできます。

platformの設定はAzure Functionsの設定になります。今回であればnode系を使用していたため設定しています。

{
  "routes": [
    {
      "route": "/protected/*",
      "allowedRoles": ["authenticated"]
    },
    {
      "route": "/*",
      "statusCode": 200
    }
  ],
  "responseOverrides": {
    "401": {
      "redirect": "/.auth/login/github",
      "statusCode": 302
    }
  },
  "platform": {
    "apiRuntime": "node:18"
  }
}

本番環境

大枠は開発環境と同じですが、こちらではauthでカスタム認証プロバイダーの設定がされています。設定方法は公式のこちらで言及があります。こちらはSWAの環境変数から値を読み取っているため、SWA側の設定としてGITHUB_CLIENT_IDGITHUB_CLIENT_SECRETを設定してあげる必要があります。こちらは、Azureのリソース作成の手順の際に合わせて設定します。

{
  "auth": {
    "identityProviders": {
      "github": {
        "registration": {
          "clientIdSettingName": "GITHUB_CLIENT_ID",
          "clientSecretSettingName": "GITHUB_CLIENT_SECRET"
        }
      }
    }
  },
  "routes": [
    {
      "route": "/protected/*",
      "allowedRoles": ["authenticated"]
    },
    {
      "route": "/*",
      "statusCode": 200
    }
  ],
  "responseOverrides": {
    "401": {
      "redirect": "/.auth/login/github",
      "statusCode": 302
    }
  },
  "platform": {
    "apiRuntime": "node:18"
  }
}

フロントエンド + API結合確認

それでは、接続してテストを開始しましょう。これまでの設定が完了していれば、ルートディレクトリで以下のコマンドでエミュレーターが起動します。もし起動しない方は、swa-cli.config.jsonの設定を再度確認してください。http://localhost:7071 readyで1分以上固まっている方は、apiディレクトリでfunc startコマンドを実行してください。

# swa-cli.config.jsonでの設定による起動
swa start

動作確認テストの実施

さて、ここまででフロントエンドとAPIの実装が完了しましたね!いよいよ統合テストの時間です。今回は正常系の動作確認に集中して進めていきます。Azure SWAの素晴らしいところは、認証周りの面倒な処理を自動でやってくれることなので、エラーハンドリングよりも「ちゃんと期待通りに動いているか」を確認することが重要ですね。

実際のテストでは、未認証状態と認証状態の両方で動作を確認していきます。特に認証フローがスムーズに動作するかどうかが、このプロジェクトの肝になる部分です。

未認証状態での動作確認

まずは未認証状態から確認していきましょう。ブラウザでシークレットモードを開くか、既存のセッションをクリアしてからテストを開始します。

トップページ(/)での確認項目

  • ページにアクセスすると「未認証状態」と表示される
  • 「サーバーからユーザー情報を取得」ボタンをクリックしても、ユーザー情報は取得できない(nullやundefinedが返される)
  • 「GitHubでログイン」ボタンが表示され、クリックすると認証画面に遷移する

保護ページ(/protected)での確認項目

  • URLを直接入力してアクセスを試みると、自動的に認証画面に遷移する

この時点では、Azure SWAが未認証ユーザーを自動的に認証フローに誘導してくれるので、エラー画面が表示されることはありません。これがSWAの便利なところですね!

認証状態での動作確認

続いて、GitHub認証を完了した状態での動作を確認します。認証が成功すると、トップページにリダイレクトされるはずです。認証画面は以下のような画面になります。Providerに関しては、埋められた状態で開かれるはずです。UsernameはGitHub認証の場合はユーザー名になります。(表示名ではありません @以下です)

トップページ(/)での確認項目

  • ページ上部に「認証状態」と表示される
  • 「サーバーからユーザー情報を取得」ボタンをクリックすると、GitHubから取得したユーザー情報が表示される
  • 「保護されたページ」ボタンと「ログアウト」ボタンが表示される

保護ページ(/protected)での確認項目

  • ページに正常にアクセスできる
  • 「保護されたデータを取得」ボタンをクリックすると、サーバーサイドのAPIからダミーデータが取得される

認証が正しく動作していれば、Azure Functionsに送信されるx-ms-client-principalヘッダーにユーザー情報が含まれているので、APIから適切なレスポンスが返ってきます。

テスト実施時のポイント

テストを実施する際に気をつけておきたいポイントをいくつか挙げておきますね。

Azure SWAの自動処理について: 未認証時の保護リソースへのアクセスや、/api/protected-dataへの直接アクセスは、SWAの設定により自動的に認証画面にリダイレクトされます。そのため、401エラーが画面に表示されることはなく、ユーザーは常にスムーズな認証フローを体験できます。

セッション管理の確認: ページをリロードしても認証状態が維持されることを確認してください。これはAzure SWAが内部的にセッション管理を行っているためです。

API呼び出しの動作: 認証状態でのAPI呼び出しでは、Azure SWAが自動的に認証ヘッダーを付与してくれるので、フロントエンド側で特別な処理は不要です。これも開発者としてはとても楽な部分ですね。

今回のテストはシンプルですが、実際のプロダクションで必要な基本的な動作はすべてカバーできています。正常系がしっかり動作することを確認できれば、Azure SWAとNext.js、Azure Functionsの統合は成功です!

インフラ構築(Bicep)

これまでの章でローカルの開発は完了しました。ここからは、実際にAzure環境へのデプロイをするための準備を行います。具体的にはBicepとパラメーターファイルを作成してAzureのリソースの定義を行い、作成したファイルを使用してGitHubにデプロイに必要な変数の定義とAzureリソースを作成するbashファイルを用意します。

注意点:ここら先はAzure Static Web Appsに対してリソースの作成を行います。今回はStandardプランでリソースを作成するため、もし不安な方はFree版でのデプロイで練習をしておきましょう。別の環境を使って詳細にデプロイするためのガイドを書いているので、こちらのブログを参照して練習してみてください。

事前準備

Bicepファイルとパラメーターファイルの説明などはこちらで詳しく解説しています。ぜひ一度お読みください。

こちらの章で目指す内容は以下になります。

  • Azure CLI:Azure Static Web AppsのリソースをStandardプランで作成する
  • 手動:GitHub認証プロバイダーの設定完了
  • GitHub CLI:GitHub Enviromentにデプロイに必要な情報が設定されている

作成に当たって目指すディレクトリ構成は以下になります。

./infra
├── bicep
│   ├── main.bicep
│   └── parameters.json
└── scripts
    └── deploy.sh

GitHubの設定

まずは今まで設定したリソースをGitHub上にpushしておきましょう。GitHub CLIでenvironmentを設定するためには事前にenvironmentを作成しておく必要があります。productionという名前でenvironmentを作成してください。設定は公式のドキュメントを参考に設定してください。 値の登録自体は、GitHub CLI経由で実行することができます。ですが、Enviromentの作成はGitHub API経由でしか行うことができません。ちょっと設定がややこしいので、ここは手動で作成しています。

ここで取得するべき情報としてはリポジトリのURLになります。

Bicepテンプレート作成 main.bicep

詳細な設定に関しては公式リファレンスを参照してください。

@description('Static Web App名')
param staticWebAppName string

@description('デプロイするリージョン')
param location string = resourceGroup().location

@description('GitHubリポジトリのURL')
param repositoryUrl string

@description('デプロイ対象のブランチ')
param branch string = 'main'

@description('フロントエンドのソースフォルダパス')
param appLocation string = 'frontend'

@description('APIのソースフォルダパス')
param apiLocation string = 'api'

@description('ビルド出力フォルダパス')
param outputLocation string = 'out'

@description('GitHub OAuth App のClient ID')
param githubClientId string

@description('GitHub OAuth App のClient Secret')
@secure()
param githubClientSecret string

// Static Web App リソース
resource staticWebApp 'Microsoft.Web/staticSites@2024-11-01' = {
  name: staticWebAppName
  location: location
  sku: {
    name: 'Standard'
    tier: 'Standard'
  }
  properties: {
    repositoryUrl: repositoryUrl
    branch: branch
    buildProperties: {
      appLocation: appLocation
      apiLocation: apiLocation
      outputLocation: outputLocation
      skipGithubActionWorkflowGeneration: false
    }
    stagingEnvironmentPolicy: 'Enabled'
  }
}

// アプリケーション設定(GitHub OAuth用の環境変数)
resource staticWebAppSettings 'Microsoft.Web/staticSites/config@2022-03-01' = {
  name: 'appsettings'
  parent: staticWebApp
  properties: {
    GITHUB_CLIENT_ID: githubClientId
    GITHUB_CLIENT_SECRET: githubClientSecret
  }
}

// アウトプット
@description('Static Web Appsのエンドポイント')
output appBaseUrl string = 'https://${staticWebApp.properties.defaultHostname}'

@description('Static Web Appsの名前')
output resourceName string = staticWebAppName

@description('GitHubリポジトリのURL')
output repositoryUrl string = repositoryUrl

@description('フロントエンドのソースフォルダパス')
output appLocation string = appLocation

@description('APIのソースフォルダパス')
output apiLocation string = apiLocation

@description('ビルド出力フォルダパス')
output outputLocation string = outputLocation

課金に重要なパラメーターとしてはskuになります。ここをFreeにするかStandardにするかで課金が走るか決定します。(個人的には検証目的で建てたらすぐ消しましょう)

staticWebAppSettingsに関しては、SWAでカスタム認証プロバイダー(GitHub)を構成するのに必要な値を環境変数として設定しています。機密情報であるため、パラメーターファイルには記述せずに環境変数として定義して、デプロイ時のみ呼び出して埋め込むという方法で管理します。

パラメーターファイルで定義している内容をそのままアウトプットしている値が複数ありますが、こちらは後続のbashファイルでGitHub CLIを通してEnvriomentにValiableとして登録するために出力しています。

パラメータファイル設定 parameters.json

{
  "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "staticWebAppName": {
      "value": "swa-api-deploy-bicep"
    },
    "location": {
      "value": "East Asia"
    },
    "repositoryUrl": {
      "value": "https://github.com/xxxxxxxxxxxxxxxx/xxxxxxxxxxxxxxxxxxxxxx"
    },
    "branch": {
      "value": "main"
    },
    "appLocation": {
      "value": "deploy/frontend"
    },
    "apiLocation": {
      "value": "api"
    },
    "outputLocation": {
      "value": "."
    }
  }
}

appLocation/apiLocation/outputLocationに関しては後続で設定するGitHub Actionsと連携した値になります。

repositoryUrlに関しては自分のリポジトリの値に変更してください。

デプロイ用bashスクリプト deploy.sh

Bicepファイルとパラメーターファイルが完成したのでデプロイする準備が整いました。ですが、このままではAzure CLIのコマンドとGitHub CLIコマンドを別々にたたく必要があります。それを解消するためにデプロイの流れをbashファイルにまとめます。

まずは、ルートディレクトリに.envファイルを作成してください。こちらのファイルは.gitと同じディレクトリ(リポジトリルート)に配置するようにしてください。

GITHUB_CLIENT_ID=xxxxxxxxxxxxxxxxx
GITHUB_CLIENT_SECRET=xxxxxxxxxxxxxxxxx
GITHUB_ENVIRONMENT="production"

RESOURCE_GROUP_NAME=swa-bicep-test

GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRETに関しては、まだ設定していないのでマスクしたままで大丈夫です。こちらの設定には、SWAをデプロイしてURLを確定させる必要があります。

これで.envが立派な機密情報になりました。.gitignoreに設定して間違ってもpushしないように気を付けましょう。

#!/bin/bash

# エラー時に停止
set -e

log_info() { echo -e "\033[1;34m$1\033[0m"; }
log_success() { echo -e "\033[1;32m$1\033[0m"; }
log_warning() { echo -e "\033[1;33m$1\033[0m"; }
log_error() { echo -e "\033[1;31m$1\033[0m"; }

DEPLOYMENT_NAME="swa-deployment-$(date +%Y%m%d-%H%M%S)"

CONFIG_FILE="$(git rev-parse --show-toplevel)/.env"

if [ -f "$CONFIG_FILE" ]; then
    echo "📁 設定ファイル読み込み: $CONFIG_FILE"
    source "$CONFIG_FILE"
else
    echo "⚠️  設定ファイルが見つかりません: $CONFIG_FILE"
    echo "   .env.example をコピーして設定してください"
    exit 1
fi

# 必要な環境変数の確認(機密情報のみ)
required_vars=("GITHUB_CLIENT_ID" "GITHUB_CLIENT_SECRET" "RESOURCE_GROUP_NAME")
for var in "${required_vars[@]}"; do
    if [ -z "${!var}" ]; then
        echo "エラー: 環境変数 $var が設定されていません"
        exit 1
    fi
done

# GitHub CLIがインストールされているかチェック
if ! command -v gh &> /dev/null; then
    log_error "GitHub CLI (gh) がインストールされていません"
    log_error "インストール方法: https://cli.github.com/"
    exit 1
fi

# GitHub CLIの認証チェック
if ! gh auth status &> /dev/null; then
    log_error "GitHub CLIが認証されていません"
    log_error "認証方法: gh auth login"
    exit 1
fi

# リポジトリのルートディレクトリかチェック
if ! git rev-parse --is-inside-work-tree &> /dev/null; then
    log_error "Gitリポジトリ内で実行してください"
    exit 1
fi

# 現在のリポジトリ情報を取得(デバッグ用)
CURRENT_REPO=$(gh repo view --json nameWithOwner --jq '.nameWithOwner' 2>/dev/null || echo "unknown")
log_info "📍 対象リポジトリ: $CURRENT_REPO"

# リソースグループ作成(存在しない場合)
log_info ""
log_info "📦 ローカル開発用リソースグループ確認・作成..."
if ! az group show --name $RESOURCE_GROUP_NAME &> /dev/null; then
    log_info "🔧 リソースグループ作成中: $RESOURCE_GROUP_NAME"
    az group create --name $RESOURCE_GROUP_NAME --location $LOCATION
    log_success "✅ リソースグループ作成完了"
else
    log_success "✅ リソースグループ既存: $RESOURCE_GROUP_NAME"
fi

echo "Azure Static Web Appをデプロイ中..."

az deployment group validate \
  --resource-group "$RESOURCE_GROUP_NAME" \
  --template-file infra/bicep/main.bicep \
  --parameters @infra/bicep/parameters.json \
  --parameters githubClientId="$GITHUB_CLIENT_ID" \
  --parameters githubClientSecret="$GITHUB_CLIENT_SECRET"

# Bicepテンプレートでデプロイ(機密情報のみ環境変数から渡す)
az deployment group create \
  --resource-group "$RESOURCE_GROUP_NAME" \
  --template-file infra/bicep/main.bicep \
  --parameters @infra/bicep/parameters.json \
  --parameters githubClientId="$GITHUB_CLIENT_ID" \
  --parameters githubClientSecret="$GITHUB_CLIENT_SECRET" \
  --name ${DEPLOYMENT_NAME}

# 全outputを一度に取得
OUTPUTS=$(az deployment group show \
  --resource-group "$RESOURCE_GROUP_NAME" \
  --name "$DEPLOYMENT_NAME" \
  --query "properties.outputs" -o json)

echo "✅ デプロイ結果を取得しました"

# 各値を抽出
APP_BASE_URL=$(echo "$OUTPUTS" | jq -r '.appBaseUrl.value')
STATIC_WEB_APP_NAME=$(echo "$OUTPUTS" | jq -r '.resourceName.value')
REPOSITORY_URL=$(echo "$OUTPUTS" | jq -r '.repositoryUrl.value')
APP_LOCATION=$(echo "$OUTPUTS" | jq -r '.appLocation.value')
API_LOCATION=$(echo "$OUTPUTS" | jq -r '.apiLocation.value')
OUTPUT_LOCATION=$(echo "$OUTPUTS" | jq -r '.outputLocation.value')

# 結果を表示
log_success ""
log_success "Azure Static Web Appのデプロイが完了しました!"
log_success "アプリのベースURL: $APP_BASE_URL"
log_success "リソース名: $STATIC_WEB_APP_NAME"
log_success "リポジトリURL: $REPOSITORY_URL"
log_success "アプリの場所: $APP_LOCATION"
log_success "APIの場所: $API_LOCATION"
log_success "出力の場所: $OUTPUT_LOCATION"

# === 新機能: デプロイトークンの取得とGitHub Environment設定 ===

log_info ""
log_info "🔑 Azure Static Web Appsのデプロイトークンを取得中..."

# デプロイトークンを取得
DEPLOY_TOKEN=$(az staticwebapp secrets list \
  --name "$STATIC_WEB_APP_NAME" \
  --resource-group "$RESOURCE_GROUP_NAME" \
  --query "properties.apiKey" -o tsv)

if [ -z "$DEPLOY_TOKEN" ]; then
    log_error "デプロイトークンの取得に失敗しました"
    exit 1
fi

log_success "✅ デプロイトークンを取得しました"

# GitHub環境の作成と設定
log_info ""
log_info "🌍 GitHub環境 '$GITHUB_ENVIRONMENT' を設定中..."

# 注意: GitHub CLIは現在のGitリポジトリを自動認識します
# このスクリプトはGitリポジトリ内で実行する必要があります

# Environment変数の設定
log_info ""
log_info "📝 Environment変数を設定中..."

# app_location
if gh variable set APP_LOCATION --body "$APP_LOCATION" --env "$GITHUB_ENVIRONMENT" 2>/dev/null; then
    log_success "✅ 変数 'app_location' を設定: $APP_LOCATION"
else
    log_error "❌ 変数 'app_location' の設定に失敗しました"
fi

# api_location
if gh variable set API_LOCATION --body "$API_LOCATION" --env "$GITHUB_ENVIRONMENT" 2>/dev/null; then
    log_success "✅ 変数 'api_location' を設定: $API_LOCATION"
else
    log_error "❌ 変数 'api_location' の設定に失敗しました"
fi

# output_location
if gh variable set OUTPUT_LOCATION --body "$OUTPUT_LOCATION" --env "$GITHUB_ENVIRONMENT" 2>/dev/null; then
    log_success "✅ 変数 'output_location' を設定: $OUTPUT_LOCATION"
else
    log_error "❌ 変数 'output_location' の設定に失敗しました"
fi

# デプロイトークンをシークレットに設定
log_info ""
log_info "🔐 デプロイトークンをシークレットに設定中..."

# 注意: gh secret setは現在のGitリポジトリの指定した環境にシークレットを設定します
if gh secret set AZURE_STATIC_WEB_APPS_API_TOKEN --body "$DEPLOY_TOKEN" --env "$GITHUB_ENVIRONMENT" 2>/dev/null; then
    log_success "✅ シークレット 'deploy_token' を設定しました"
else
    log_error "❌ シークレット 'deploy_token' の設定に失敗しました"
    log_warning "⚠️  手動で設定してください:"
    log_warning "   リポジトリ設定 > Environments > $GITHUB_ENVIRONMENT > Secrets"
    log_warning "   シークレット名: deploy_token"
    log_warning "   値: [デプロイトークン]"
fi

# 最終結果サマリー
log_success ""
log_success "🎉 すべての設定が完了しました!"
log_success ""
log_success "📋 設定内容サマリー:"
log_success "  - Azure Static Web App: $STATIC_WEB_APP_NAME"
log_success "  - アプリURL: $APP_BASE_URL"
log_success "  - GitHub環境: $GITHUB_ENVIRONMENT"
log_success "  - 設定された変数:"
log_success "    • app_location: $APP_LOCATION"
log_success "    • api_location: $API_LOCATION"
log_success "    • output_location: $OUTPUT_LOCATION"
log_success "  - 設定されたシークレット:"
log_success "    • deploy_token: [設定済み]"
log_success ""
log_success "🚀 GitHub Actionsでのデプロイが可能になりました!"

長いbashファイルですが、以下のステップを順次実行しています。

  1. 環境変数の取得
  2. 各種環境の確認
  3. Bicepを使用したデプロイ検証・実行
  4. Azureリソースから情報取得
  5. GitHub CLIを経由したSecret・Valiableの設定
  6. リザルト表示

各コマンドに関しての詳細な説明はこちらで行っています。権限を振って実行してみてください。

chmod +x ./infra/scripts/deploy.sh

./infra/scripts/deploy.sh


# リザルトで以下が出たら成功!!

#📋 設定内容サマリー:
#  - Azure Static Web App: swa-api-deploy-bicep
#  - アプリURL: https://blue-bay-01b32fb00.2.azurestaticapps.net
#  - GitHub環境: production
#  - 設定された変数:
#    • app_location: deploy/frontend
#    • api_location: api
#    • output_location: .
#  - 設定されたシークレット:
#    • deploy_token: [設定済み]

1分ぐらいで実行されると思います。リザルトが表示されたら、「アプリURL」にアクセスしてみてください。Welcomeページが表示されれば成功です。

環境作成

無事デプロイが確認出来たら、次は認証プロバイダーの設定を行いましょう。GitHub > Settings > Developer Settingsにアクセスしてください。

リポジトリのSettingsではなくUser Settingsのほうにアクセスしてね!

OAuth Appsの作成を行いましょう。

設定項目としては、

設定項目
Application name自由に決めてください
Homepage URLアプリURL
Authorization callback URLアプリURL/.auth/login/github/callback
Enable Device FlowTrue

設定をするとClient IDとClient Secretが表示されます。そちらの値を.envファイルに入れて再度deploy.shを起動させましょう。

./infra/scripts/deploy.sh


# リザルトで以下が出たら成功!!

#📋 設定内容サマリー:
#  - Azure Static Web App: swa-api-deploy-bicep
#  - アプリURL: https://blue-bay-01b32fb00.2.azurestaticapps.net
#  - GitHub環境: production
#  - 設定された変数:
#    • app_location: deploy/frontend
#    • api_location: api
#    • output_location: .
#  - 設定されたシークレット:
#    • deploy_token: [設定済み]

これで、SWAの環境変数にGITHUB_CLIENT_ID/GITHUB_CLIENT_SECRETが設定されました。もし不安な方はAzure Portalから確認しましょう。

CI/CD構築(GitHub Actions)

ここでは、GitHub Actionsの設定を行っています。前提条件として、GitHubリポジトリのEnviroment(production)に値が設定済みである必要があります。

Name説明
AZURE_STATIC_WEB_APPS_API_TOKENSWAのデプロイトークン
API_LOCATIONAPIのディレクトリ
APP_LOCATIONアプリのディレクトリ
OUTPUT_LOCATIONビルドファイルの位置

今回作成するファイルのディレクトリ構成は以下になります。

./.github
└── workflows
    └── deploy.yaml

ワークフロー設計・アーキテクチャ

ワークフローでは、フロントビルドとデプロイを分離しています。フロントのビルドと並行してAPIのテストを実行するように設計しています。

  • build-frontend: フロントエンドの並列ビルド
  • test-api: APIの並列ビルド
  • deploy-swa: アーティファクトを使用したデプロイ

視覚化した図を以下に示します。

全体ワークフロー

こちらのファイルをコピー&ペーストしてGitHub >Actionsのタブから手動実行しましょう。設定が完璧であれば、無事デプロイされるかと思います。

name: Build and Deploy to Azure Static Web Apps

on:
  workflow_dispatch:

jobs:
  # フロントエンドビルドジョブ
  build-frontend:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Repository
        uses: actions/checkout@v4

      - name: Setup Node.js for Frontend
        uses: actions/setup-node@v4
        with:
          node-version: "18"
          cache: "npm"
          cache-dependency-path: "frontend/package-lock.json"

      - name: Install Frontend Dependencies
        working-directory: ./frontend
        run: npm ci

      - name: Build Frontend
        working-directory: ./frontend
        run: npm run build

      - name: Upload Frontend Build Artifacts
        uses: actions/upload-artifact@v4
        with:
          name: frontend-build
          path: |
            frontend/out/
          retention-days: 1

  # APIビルドジョブ
  test-api:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Repository
        uses: actions/checkout@v4

      - name: Setup Node.js for API
        uses: actions/setup-node@v4
        with:
          node-version: "18"
          cache: "npm"
          cache-dependency-path: "api/package-lock.json"

      - name: Install API Dependencies
        working-directory: ./api
        run: npm install

      - name: Build API
        working-directory: ./api
        run: npm run build

      - name: Run API Tests
        working-directory: ./api
        run: npm run test

  # Azure Static Web Appsデプロイジョブ
  deploy-swa:
    needs: [build-frontend, test-api]
    runs-on: ubuntu-latest
    environment: production
    env:
      APP_LOCATION: ${{ vars.APP_LOCATION || 'deploy/frontend' }}
      OUTPUT_LOCATION: ${{ vars.OUTPUT_LOCATION || '.' }}
      API_LOCATION: ${{ vars.API_LOCATION || 'api' }}

    steps:
      - name: Checkout Repository
        uses: actions/checkout@v4

      - name: Download Frontend Artifacts
        uses: actions/download-artifact@v4
        with:
          name: frontend-build
          path: ./deploy/frontend
      - name: Copy Static Web App Configuration
        run: |
          cp frontend/config/prod/staticwebapp.config.json deploy/frontend/
      - name: check
        working-directory: ./deploy/frontend/
        run: |
          ls -la
          cat staticwebapp.config.json

      - name: Deploy to Azure Static Web Apps
        uses: Azure/static-web-apps-deploy@v1
        with:
          azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
          repo_token: ${{ secrets.GITHUB_TOKEN }}
          action: "upload"
          app_location: ${{ env.APP_LOCATION }}
          api_location: ${{ env.API_LOCATION }}
          output_location: "${{ env.OUTPUT_LOCATION }}"
          skip_app_build: true
          skip_api_build: false

フロントエンドビルドジョブ

こちらでは、フロントエンドのビルドを行い結果をArtifactとして保存しています。actions/setup-node@v4を利用してnode_modulesをキャッシュしています。

  build-frontend:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Repository
        uses: actions/checkout@v4

      - name: Setup Node.js for Frontend
        uses: actions/setup-node@v4
        with:
          node-version: "18"
          cache: "npm"
          cache-dependency-path: "frontend/package-lock.json"

      - name: Install Frontend Dependencies
        working-directory: ./frontend
        run: npm ci

      - name: Build Frontend
        working-directory: ./frontend
        run: npm run build

      - name: Upload Frontend Build Artifacts
        uses: actions/upload-artifact@v4
        with:
          name: frontend-build
          path: |
            frontend/out/
          retention-days: 1

APIテストジョブ

こちらでは、バックエンドのビルドを行いテストを実行しています。今回はテストが失敗したら通知を行う等の対応を行っていませんが、本格運用ではここにテスト関連の処理をまとめて切り出すことができます。

actions/setup-node@v4を利用してnode_modulesをキャッシュしています。

  # APIビルドジョブ
  test-api:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout Repository
        uses: actions/checkout@v4

      - name: Setup Node.js for API
        uses: actions/setup-node@v4
        with:
          node-version: "18"
          cache: "npm"
          cache-dependency-path: "api/package-lock.json"

      - name: Install API Dependencies
        working-directory: ./api
        run: npm install

      - name: Build API
        working-directory: ./api
        run: npm run build

      - name: Run API Tests
        working-directory: ./api
        run: npm run test

SWAデプロイジョブ実装

こちらでは、以下の手順を順次実行しています。

  • 「フロントエンドビルドジョブ」で作成した静的ファイルを復元
  • 内部に本番環境用ルート定義を埋め込み
  • Azure/static-web-apps-deploy@v1を使用してデプロイ
    • フロントエンドはビルド済みファイルをデプロイのみ任せて実行
    • バックエンドはビルドとデプロイを任せて実行
  # Azure Static Web Appsデプロイジョブ
  deploy-swa:
    needs: [build-frontend, test-api]
    runs-on: ubuntu-latest
    environment: production
    env:
      APP_LOCATION: ${{ vars.APP_LOCATION || 'deploy/frontend' }}
      OUTPUT_LOCATION: ${{ vars.OUTPUT_LOCATION || '.' }}
      API_LOCATION: ${{ vars.API_LOCATION || 'api' }}

    steps:
      - name: Checkout Repository
        uses: actions/checkout@v4

      - name: Download Frontend Artifacts
        uses: actions/download-artifact@v4
        with:
          name: frontend-build
          path: ./deploy/frontend
      - name: Copy Static Web App Configuration
        run: |
          cp frontend/config/prod/staticwebapp.config.json deploy/frontend/
      - name: check
        working-directory: ./deploy/frontend/
        run: |
          ls -la
          cat staticwebapp.config.json

      - name: Deploy to Azure Static Web Apps
        uses: Azure/static-web-apps-deploy@v1
        with:
          azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
          repo_token: ${{ secrets.GITHUB_TOKEN }}
          action: "upload"
          app_location: ${{ env.APP_LOCATION }}
          api_location: ${{ env.API_LOCATION }}
          output_location: "${{ env.OUTPUT_LOCATION }}"
          skip_app_build: true
          skip_api_build: false

Copy Static Web App Configurationでは、本番環境用のstaticwebapp.config.jsonを復元したフロントのビルド済みディレクトリにコピーしています。

Azure/static-web-apps-deploy@v1では、skip_app_buildskip_api_buildでアクション内のビルドプロセスを制御しています。そもそもこのアクションは、アプリの構成を自動で読み取りビルドとデプロイを行っています。フロントエンドでルーティングファイルの制御を行うためにフロントエンドはビルドをスキップしてデプロイだけで使用しています。

GitHub Actions効率化紹介

今回のワークフローでは、ビルド時間の短縮とリソース効率化を重視した設計を行いました。特に並列処理とキャッシュ戦略により、従来の逐次処理と比較して大幅な時間短縮を実現できます。

独立したNodeキャッシュ戦略

各ジョブで異なるcache-dependency-pathを指定することで、フロントエンドとAPIで独立したキャッシュを管理できます。これにより、一方の依存関係が変更されても他方のキャッシュが無効化されることを防げます。

# フロントエンドジョブでのキャッシュ設定
- name: Setup Node.js for Frontend
  uses: actions/setup-node@v4
  with:
    node-version: "18"
    cache: "npm"
    cache-dependency-path: "frontend/package-lock.json"

# APIジョブでのキャッシュ設定
- name: Setup Node.js for API
  uses: actions/setup-node@v4
  with:
    node-version: "18"
    cache: "npm"
    cache-dependency-path: "api/package-lock.json"

cache-dependency-pathを明示的に指定することで、GitHub Actionsはそのファイルのハッシュをキャッシュキーとして使用します。フロントエンドとAPIで異なるpackage-lock.jsonを参照することで、以下のメリットがあります:

  • フロントエンドの依存関係のみ変更時:APIのキャッシュはそのまま利用
  • APIの依存関係のみ変更時:フロントエンドのキャッシュはそのまま利用
  • 両方変更時:それぞれ個別にキャッシュを再構築

フロントエンドジョブではnpm ciを使用しています。これはCI環境向けの最適化されたコマンドで、package-lock.jsonを厳密に遵守し、高速インストールを実現します。

アーティファクト活用による並列処理最適化

アーティファクトを使用することで、ビルドジョブとデプロイジョブを完全に分離できます。これにより、デプロイ時にビルドを再実行する必要がなくなり、処理時間を大幅に短縮できます。

# ビルドジョブでのアーティファクト保存
- name: Upload Frontend Build Artifacts
  uses: actions/upload-artifact@v4
  with:
    name: frontend-build
    path: |
      frontend/out/
    retention-days: 1

# デプロイジョブでのアーティファクト復元
- name: Download Frontend Artifacts
  uses: actions/download-artifact@v4
  with:
    name: frontend-build
    path: ./deploy/frontend

今回の構成では、build-frontendtest-apiが並列で実行されます。これにより、従来の逐次処理と比較して大幅な時間短縮が期待できます。

将来的なテストへの拡張性

段階的テスト導入への対応

現在のAPIテストジョブは、将来的なテスト拡張を見据えた設計になっています。

- name: Build API
  working-directory: ./api
  run: npm run build

- name: Run API Tests
  working-directory: ./api
  run: npm run test

テスト失敗時の処理拡張

本格運用時には、テスト失敗時の通知機能を追加できます。以下のような拡張が可能です:

- name: Run API Tests
  working-directory: ./api
  run: npm run test
  continue-on-error: false  # テスト失敗時にワークフローを停止

# 将来的な拡張例
- name: Notify Test Failure
  if: failure()
  uses: actions/github-script@v6
  with:
    script: |
      github.rest.issues.createComment({
        issue_number: context.issue.number,
        owner: context.repo.owner,
        repo: context.repo.repo,
        body: 'APIテストが失敗しました。詳細はワークフローログを確認してください。'
      })

並列テスト実行の利点

フロントエンドビルドとAPIテストを並列実行することで、以下のメリットがあります:

  • 時間効率: 最も時間のかかる処理に全体時間が依存
  • 早期発見: APIテストの失敗を早期に検出
  • リソース効率: GitHub Actionsの同時実行枠を有効活用

シークレット・環境変数の効率的管理

GitHub Actionsでは、Secretsと環境変数を適切に分離して管理することが重要です。

environment: production
env:
  APP_LOCATION: ${{ vars.APP_LOCATION || 'deploy/frontend' }}
  OUTPUT_LOCATION: ${{ vars.OUTPUT_LOCATION || '.' }}
  API_LOCATION: ${{ vars.API_LOCATION || 'api' }}

Secrets(機密情報)にはAZURE_STATIC_WEB_APPS_API_TOKENなどのデプロイトークンを、Variables(環境固有設定)にはAPP_LOCATIONなどのディレクトリ設定を使い分けます。

本番環境での動作確認

お疲れさまでした!ここまでで無事にAzure SWAのデプロイが完了しましたね。でも、ここからが本当の勝負です。実際に本番環境で全ての機能が期待通りに動作するかを確認していきましょう。

私も最初の頃は「ローカルで動いてるから大丈夫だろう」と思っていましたが、本番環境では思わぬ設定の違いでハマることがよくありました。特に認証周りは環境による差が出やすい部分なので、しっかりと検証していきますね。

もし何かうまく動作しない部分があった場合は、まずはブラウザの開発者ツールでエラーがないか確認して、必要に応じてAzure Portalでのログ確認も行ってみてください。認証周りは環境設定に依存する部分が多いので、OAuth Appの設定やSWAの環境変数も再度チェックしてみると良いでしょう。

未認証状態での動作確認

シークレットモードやプライベートブラウジングで本番URLにアクセスしてみてください。まずはトップページ(/)から確認します。

期待される動作:

  • ページ上部に「未認証状態」と表示される
  • 「GitHubでログイン」ボタンが表示される
  • 「サーバーからユーザー情報を取得」ボタンをクリックしても、ユーザー情報は取得できない(isAuthenticated: falseが返される)

次に、保護されたページへの直接アクセスを試してみます。URLバーに /protected を入力してアクセスしてください。

期待される動作:

  • 自動的にGitHub認証画面にリダイレクトされる
  • エラー画面が表示されることはない

これがAzure SWAの素晴らしいところですね。設定ファイル(staticwebapp.config.json)で定義したルート保護が自動的に働いて、未認証ユーザーを認証フローに誘導してくれます。

GitHub認証の実行

「GitHubでログイン」ボタンをクリックして、実際の認証フローを確認します。

認証フローの流れ:

  1. ボタンクリックで /.auth/login/github にリダイレクト
  2. GitHub認証画面が表示される
  3. GitHubでの認証完了後、元のページにリダイレクト

GitHub認証画面では、作成したOAuth Appの情報が表示されます。App nameやAuthorization callback URLが正しく設定されていることを確認してください。

認証完了後は、トップページに戻ってきて以下のような変化が確認できるはずです:

  • ページ上部に「認証済み」と表示される
  • ユーザー情報(GitHubのユーザー名など)が表示される
  • 「保護されたページへ」「ログアウト」ボタンが表示される

セッション管理の確認

認証状態でページをリロードしても、認証状態が維持されることを確認してください。これはAzure SWAが内部的にセッション管理を行っているためです。

また、別タブで同じサイトを開いても、認証状態が共有されることも確認してみましょう。

API動作テスト

認証が正しく動作することが確認できたら、次はAPIの動作を確認していきます。

user-infoエンドポイントのテスト

認証済み状態で「サーバーからユーザー情報を取得」ボタンをクリックしてください。

期待される結果:

{
  "userId": "github|あなたのGitHubユーザーID",
  "name": "あなたのGitHubユーザー名",
  "email": "あなたのGitHubユーザー名",
  "provider": "github",
  "roles": ["authenticated"],
  "isAuthenticated": true
}

これで、Azure SWAからAzure Functionsに認証ヘッダー(x-ms-client-principal)が正しく送信されて、API側で適切にデコードされていることが確認できます。

ブラウザの開発者ツールのネットワークタブでも確認してみてください。/api/user-infoへのリクエストが200で成功していて、上記のようなレスポンスが返っていることが確認できるはずです。

未認証状態でのuser-infoテスト

シークレットモードで未認証状態でも同じボタンをクリックしてみてください。

期待される結果:

{
  "userId": null,
  "name": null,
  "email": null,
  "provider": null,
  "roles": [],
  "isAuthenticated": false
}

このAPIは認証状態を問わずアクセス可能な設計になっているので、未認証でもエラーにならずに適切なレスポンスが返ることを確認できます。

protected-dataエンドポイントのテスト

保護されたページ(/protected)にアクセスして、「保護されたデータを取得」ボタンをクリックしてください。

期待される結果:

{
  "userId": "github|あなたのGitHubユーザーID",
  "message": "こんにちは、ユーザーgithub|あなたのGitHubユーザーIDさん!",
  "timestamp": "2024-12-15T15:45:30.000Z",
  "userNumber": 数値
}

userNumberは、ユーザーIDをベースにした簡単なシード値から生成される0-999の範囲の数値です。同じユーザーなら常に同じ値が返されることも確認してみてください。

未認証でのprotected-dataアクセステスト

これは少し技術的なテストですが、直接APIエンドポイントにアクセスしてみましょう。

シークレットモードで https://あなたのサイトURL/api/protected-data に直接アクセスしてください。

期待される動作:

  • 401エラーが返される
  • または、Azure SWAのルート保護により認証画面にリダイレクトされる

実際には、SWAの設定によってはAPI直接アクセスも認証フローに誘導される可能性があります。どちらの動作でも、未認証ユーザーがデータにアクセスできないことが重要です。

セキュリティチェック

最後に、セキュリティ面での動作を確認していきます。

ルート保護の確認

未認証状態で以下のURLに直接アクセスしてみてください:

  • /protected
  • /protected/任意のパス

どちらも認証画面にリダイレクトされることを確認してください。これは staticwebapp.config.json で設定した以下のルールが動作しているためです:

{
  "route": "/protected/*",
  "allowedRoles": ["authenticated"]
}

認証情報の適切な伝達

開発者ツールのネットワークタブで、認証済み状態でのAPI呼び出しを確認してください。

重要なポイント:

  • API呼び出し時に、フロントエンド側で特別な認証ヘッダーを設定していない
  • Azure SWAが自動的に x-ms-client-principal ヘッダーを付与している
  • このヘッダーは開発者ツールでは見えないが、サーバー側では受信できている

これがAzure SWAの大きなメリットの一つですね。フロントエンド側で複雑な認証処理を書く必要がなく、SWAが自動的に認証情報をバックエンドに伝達してくれます。

セッション継続性の確認

認証済み状態で以下を試してみてください:

  • ページのリロード
  • 別タブでの同サイトアクセス
  • ブラウザを閉じて再度開く(セッションストレージのテスト)

通常のWebアプリケーションと同様に、ブラウザを閉じるまでは認証状態が維持されることを確認してください。

ログアウト機能の確認

「ログアウト」ボタンをクリックして、正常にログアウトできることを確認してください。

期待される動作:

  1. /.auth/logout にリダイレクト
  2. 認証状態がクリアされる
  3. トップページに戻り、未認証状態の表示になる

お疲れさまでした!これで本番環境での動作確認は完了です。すべての機能が期待通りに動作していれば、Azure SWA + Next.js + Azure Functionsの認証統合システムが正常に稼働していることが確認できました。

リソースのクリーナップ

今回はAzure SWAをStandardプランで使用しています。こちらは動かしていれば、課金が走るようになっています。検証が確認できたら、心苦しいですがクリーナップしましょう。

Azure Portalからの削除でもよいのですが、せっかくなのでクリーナップ用のbashスクリプトを作成しました。bashスクリプト化しておくことのメリットはコマンド一つで環境の作成・削除ができる点です。GitHubのOAuth Appsの設定とEnvironmentの作成は手動ですが、完全自動化ではないですけどね…

クリーナップスクリプト

#!/bin/bash

# エラー時に停止
set -e

log_info() { echo -e "\033[1;34m$1\033[0m"; }
log_success() { echo -e "\033[1;32m$1\033[0m"; }
log_warning() { echo -e "\033[1;33m$1\033[0m"; }
log_error() { echo -e "\033[1;31m$1\033[0m"; }


CONFIG_FILE="$(git rev-parse --show-toplevel)/.env" 2>/dev/null || CONFIG_FILE=".env"

if [ -f "$CONFIG_FILE" ]; then
    log_info "📁 設定ファイル読み込み: $CONFIG_FILE"
    source "$CONFIG_FILE"
else
    log_error "⚠️  設定ファイルが見つかりません: $CONFIG_FILE"
    log_error "   .env.example をコピーして設定してください"
    exit 1
fi

# 必要な環境変数の確認
required_vars=("RESOURCE_GROUP_NAME" "STATIC_WEB_APP_NAME")
for var in "${required_vars[@]}"; do
    if [ -z "${!var}" ]; then
        log_error "エラー: 環境変数 $var が設定されていません"
        exit 1
    fi
done

log_info "🎯 削除対象: $STATIC_WEB_APP_NAME (リソースグループ: $RESOURCE_GROUP_NAME)"

# Azure CLIがインストールされているかチェック
if ! command -v az &> /dev/null; then
    log_error "Azure CLI (az) がインストールされていません"
    log_error "インストール方法: https://docs.microsoft.com/cli/azure/install-azure-cli"
    exit 1
fi

# Azure CLIの認証チェック
if ! az account show &> /dev/null; then
    log_error "Azure CLIが認証されていません"
    log_error "認証方法: az login"
    exit 1
fi

# リソースグループの存在確認
log_info ""
log_info "📦 リソースグループの確認..."
if ! az group show --name $RESOURCE_GROUP_NAME &> /dev/null; then
    log_warning "⚠️  リソースグループ '$RESOURCE_GROUP_NAME' が見つかりません"
    log_warning "   削除する必要がありません"
    exit 0
else
    log_success "✅ リソースグループ確認: $RESOURCE_GROUP_NAME"
fi

# Static Web Appリソースの存在確認
log_info ""
log_info "🔍 Azure Static Web App リソースを確認中..."

# 指定されたリソースの存在確認
if az staticwebapp show \
    --name "$STATIC_WEB_APP_NAME" \
    --resource-group "$RESOURCE_GROUP_NAME" \
    --output none 2>/dev/null; then
    
        # リソース詳細の取得
    SWA_DETAILS=$(az staticwebapp show \
        --name "$STATIC_WEB_APP_NAME" \
        --resource-group "$RESOURCE_GROUP_NAME" \
        --query "{name:name, defaultHostname:defaultHostname, sku:sku.name, location:location}" \
        -o json)
    
    log_success "✅ 削除対象リソースが見つかりました:"
    echo "$SWA_DETAILS" | jq -r '"  • 名前: \(.name)"'
    echo "$SWA_DETAILS" | jq -r '"  • URL: \(.defaultHostname)"'
    echo "$SWA_DETAILS" | jq -r '"  • プラン: \(.sku)"'
    echo "$SWA_DETAILS" | jq -r '"  • リージョン: \(.location)"'
    
    RESOURCE_EXISTS=true
else
    log_warning "⚠️  指定されたAzure Static Web App '$STATIC_WEB_APP_NAME' が見つかりません"
    log_warning "   リソースグループ: $RESOURCE_GROUP_NAME"
    log_info "削除する必要がありません"
    exit 0
fi

# 確認プロンプト
log_warning ""
log_warning "⚠️  以下の操作を実行します:"
log_warning "   1. Azure Static Web App '$STATIC_WEB_APP_NAME' の削除"
log_warning "   2. リソースグループ '$RESOURCE_GROUP_NAME' は保持します"
log_warning "   3. GitHub環境設定は保持します"
log_warning ""

read -p "続行しますか? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
    log_info "操作をキャンセルしました"
    exit 0
fi

# === Azure Static Web App リソースの削除 ===

log_info ""
log_info "🗑️  Azure Static Web App リソースを削除中..."

log_info "🔧 削除中: $STATIC_WEB_APP_NAME"

if az staticwebapp delete \
    --name "$STATIC_WEB_APP_NAME" \
    --resource-group "$RESOURCE_GROUP_NAME" \
    --yes 2>/dev/null; then
    log_success "✅ 削除完了: $STATIC_WEB_APP_NAME"
else
    log_error "❌ 削除失敗: $STATIC_WEB_APP_NAME"
    exit 1
fi

log_success "✅ Azure Static Web App リソースの削除が完了しました"

# === 削除完了サマリー ===

log_success ""
log_success "🎉 クリーンアップが完了しました!"
log_success ""
log_success "📋 実行内容サマリー:"
log_success "  ✅ Azure Static Web App削除: $STATIC_WEB_APP_NAME"
log_success "  ✅ リソースグループ保持: $RESOURCE_GROUP_NAME"
log_success "  ✅ GitHub環境設定保持: 変更なし"
log_success ""
log_success "📌 注意事項:"
log_success "  • リソースグループ '$RESOURCE_GROUP_NAME' は保持されています"
log_success "  • GitHub環境設定はそのまま残っているので、再デプロイ可能です"
log_success ""
log_success "🚀 Azure リソースのクリーンアップ完了!"

クリーナップスクリプトの実行

作成時と同じく、環境変数を .env ファイルから読み込んで削除処理を実行します。既にdeployスクリプト実行時に .env ファイルは設定済みのはずなので、そのまま使用できます。

chmod +x ./infra/scripts/cleanup.sh

./infra/scripts/cleanup.sh

実行すると、以下のような確認が表示されます:

🎯 削除対象: swa-api-deploy-bicep (リソースグループ: swa-bicep-test)
⚠️  以下の操作を実行します:
 1. Azure Static Web App 'swa-api-deploy-bicep' の削除
 2. リソースグループ 'swa-bicep-test' は保持します
 3. GitHub環境設定は保持します

続行しますか? (y/N):

y を入力すると削除処理が開始されます。

削除される内容と保持される内容

削除されるもの:

  • Azure Static Web Appリソース
  • 関連するManaged Functions
  • カスタム認証プロバイダー設定

保持されるもの:

  • リソースグループ(他のリソースがある可能性を考慮)
  • GitHub Environment設定(再デプロイ時に再利用可能)
  • GitHub OAuth App(再利用可能)

完全削除を希望する場合

もし完全にクリーンな状態に戻したい場合は、以下も手動で削除してください:

Azure側:

  • リソースグループ全体(他にリソースがない場合)
  • 必要に応じて Azure CLI の認証情報もクリア

GitHub側:

  • OAuth App(Settings > Developer settings > OAuth Apps から削除)
  • Environment設定(リポジトリの Settings > Environments から削除)

これで、課金を気にすることなく安心して検証を終了できますね。再度環境を作りたい場合は、deployスクリプトを実行すれば簡単に復旧できるのも、Infrastructure as Codeのメリットです!

まとめ

今回は Azure Static Web Apps と Azure Functions を使った Next.js アプリケーションの認証 API 統合について、DevContainer での開発環境構築から本番デプロイまでの完全なワークフローを詳しく見てきました。

技術統合のポイント

この構成の最大の魅力は、フロントエンドとバックエンドを統一プラットフォームで管理できる点にあります。Azure SWA が提供する認証機能と Azure Functions の API を組み合わせることで、複雑な認証フローを比較的シンプルに実装できました。特に x-ms-client-principal ヘッダーを使った認証情報の受け渡しは、SWA 独自の仕組みとして理解しておくと実装がスムーズになります。

開発体験の向上

DevContainer を使った開発環境は、チーム開発での環境差異を解消する大きなメリットがありました。Azure CLI や SWA CLI、Functions Core Tools がすべて統一環境で利用でき、ローカルでの統合テストも swa start コマンド一つで実行できる点は、開発効率を大幅に向上させます。

運用面での実用性

GitHub Actions を使った CI/CD パイプラインでは、フロントエンドビルドと API テストを並列実行することで、デプロイ時間の短縮を実現しました。また、Bicep を使った IaC により、インフラの再現性と管理性も確保できています。Standard プランは課金が発生しますが、カスタム認証プロバイダーや SLA が必要な本格的なプロジェクトには必須の選択肢です。

コメント

ここまで出来たら、Azure Static Web Appsの基本は一通り理解できたかともいます。あとは運用の要望に合わせてチューニングするエンタープライズの領域に入っていきます。Bicepを使用して人間の手が入る可能性を減らせば減らすほど、問題が少なくなると思うので積極的にスクリプト化できるものはしていきましょう!

長々とした記事ですがお疲れ様でした!!

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

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

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

コメントを残す

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