ネイティブアプリからALBでJWT検証をしたい

ネイティブアプリからALBでJWT検証をしたい

2025.11.25

はじめに

こんにちは。コンサルティング部の津谷です。
最近認証周りを触っております。今回はネイティブアプリで受け取ったアクセストークン情報をALBに渡して検証できるのか試してみようと思います。ALB側でJWT検証が可能になったので活用してみます。

以前の認証方式は?

そもそもALB側でJWT検証ができるようになる前は、バックエンドAPI側でトークンを検証する仕組みを組む必要がありました。11月のアップデートで、ALB側でJWTの検証をしてくれるようになりました。これによって、バックエンド側に検証の仕組みを作らず、ビジネスロジックに特化した開発ができそうです。便利な機能ですね。

https://docs.aws.amazon.com/ja_jp/elasticloadbalancing/latest/application/listener-verify-jwt.html

ALB⇔auth0の認証と何がちがう?

auth0へ認証する場合は、認証・認可のフローでOIDCプロトコルを使います。ALB側でもOIDCでトークンの発行元を設定したりとか、認証時のコールバック設定とかいろいろすればよいのですが、クライアントで利用するようなアプリからバックエンドAPIを叩きたいとなったらどうしましょう。

ブラウザから直接ALBにアクセスする際のトークンのやり取りについての仕組みは以下で検証していますので、ご参照ください。
https://dev.classmethod.jp/articles/auth0-alb-openid-connect/
上記の例では、ALBとauth0でサーバーサイドのトークン交換を行っていました。あくまでブラウザにかえすのはALBが保持するCookie情報のみです。

クライアントアプリは、基本的にトークンをauth0と直接やり取りすることになるので、トークンの保護を考える必要があります。トークンをブラウザに露出しない仕組み・ローカルであっても認可コードを改ざんできないような仕組みなどアプリ側で実装するロジックは結構複雑です。トークンの保管方法も考える必要があります。WindowsならDPAPIなどの暗号化機能もありますが、マルウェアを仕込まれて同一ユーザでも攻撃の対象になりえるなど、、、

話外れましたが、上のブログでも書いたOIDCによるトークン連携と今回のアプリ⇔IdPのトークン連携の違いを図にまとめてみました。
スクリーンショット 2025-11-18 171914

ブラウザからALBに直アクセスする前に、認証をauth0にリダイレクトする方式は、トークンとトークンを受け取るための認可コードをブラウザ側に渡していません。ネイティブアプリの場合は、auth0との認証認可情報のやりとりを作りこむ必要があります。ブラウザ経由でトークンを直接返すのは、危ないのでローカルサーバをアプリ側で立てて、認可コードをローカル宛てのコールバックで送信させる必要があります。また、このトークンが正常なのか検証する仕組みもいままでALBでサポートしていませんでした。ここがアップデートで実装されたということです。

実際にやってみる

auth0でアプリケーションの登録からやっていきます。ネイティブアプリ用のクライアントを作成します。
スクリーンショット 2025-11-25 085406
「ドメイン」「クライアントID」「クライアントシークレット」は控えておきます。ネイティブアプリが認証をする際に必要になります。
スクリーンショット 2025-11-25 085417
設定をする際は、ネイティブアプリを選択するようにします。
スクリーンショット 2025-11-25 085423
認証時に、コールバックするURLはローカルホストを指定します。ネイティブアプリとの認証時には、ブラウザを返して認可コードやトークンをやり取りするため、ブラウザ上の露出を防ぐ必要があります。同一マシン上でのみリッスンを許可することで、リクエスト元の改ざんを防ぐ(CSRF対策)ことが可能になります。
RFC8252にも記載がございます。
https://datatracker.ietf.org/doc/html/rfc8252#section-7.3

auth0の設定が終わったら、Windowsで簡単に認証認可のロジックを組んでみます。Visual Studioをインストールして.NETを実行環境として使いました。WinFormsの機能を使ってUIを簡単に作ってくれるのでバックエンドの検証だけしたいとき便利ですね。

インストールしたVisual Studio(IDEツール)でローカルにアプリケーション作ってきましょう。Copilotや、ClaudeCodeを活用していい感じのコードを実装しました。(本来であればドメイン名やクライアント情報はハードコーディングせず環境変数設定や外部のセキュアなパラメータストアから呼出しするようにしましょう。検証なのでお許しください。)

//JSONシリアライズ用にNewtonsoft.Jsonを使用
using Newtonsoft.Json;
// .NET Framework 4.7.2の環境で動作するC#コード
using System;
using System.Collections.Generic;

// プロセス起動用(ブラウザの起動など)
using System.Diagnostics;
// ランダム文字列生成用
using System.Linq;
// ローカルHTTPサーバー用
using System.Net;
// Auth0へ認証リクエストを送信するためのHTTPクライアント
using System.Net.Http;
using System.Net.Http.Headers;
// CRSF対策やPKCE用
using System.Security;
// 文字列のエンコード用
using System.Text;
// 非同期処理用
using System.Threading.Tasks;
// Windowsフォーム用
using System.Windows.Forms;

namespace TokenManagerApp
{
public partial class Form1 : Form
{
    //Auth0への認証設定
    //Auth0のテナントドメイン
    private const string Auth0Domain = "xxxx.us.auth0.com";
    //Auth0のクライアントIDとクライアントシークレット
    private const string ClientId = "xxxx";
    private const string ClientSecret = "xxxx";
    //認可コードのコールバックURL(ローカルサーバーのURL)
    private const string CallbackUrl = "http://localhost:3000/callback";
    //リクエスト元がデスクトップアプリであることを検証するState値
    private string _state;           
    //Auth0から送られる認可コードの漏洩を防ぐ(Proof Key for Exchangeの仕組み)
    private string _codeVerifier;

    // Auth0 の API Identifier(Auth0 Dashboard > APIs の Identifier)をここに設定
    // 例: "https://xxxx.com"
    private const string Audience = "https://xxxx.com";

    //トークン管理クラス(トークンの保存・読み込み・削除を行う)
    private TokenManager _tokenManager;
    //HTTPリスナー(ローカルサーバー:ポート3000で待ち受ける)
    private HttpListener _httpListener;

    public Form1()
    {
        // フォーム初期化
        InitializeComponent();
        // トークンマネージャー初期化
        _tokenManager = new TokenManager();
    }

    private void Form1_Load(object sender, EventArgs e)
    {
        labelStatus.Text = "準備完了";
        labelTokenStatus.Text = "トークンなし";
        buttonLogout.Enabled = false;
        buttonAccessAPI.Enabled = false; 
    }

    // ログインボタン
    private async void buttonLogin_Click(object sender, EventArgs e)
    {
        labelStatus.Text = "ローカルサーバーを起動中...";
        buttonLogin.Enabled = false;

        try
        {
            // ローカルサーバー起動
            await StartLocalServerAsync();
            // ブラウザでAuth0の認証ページを開く
            OpenBrowserForAuth();
            labelStatus.Text = "ブラウザが開きました。Auth0 で認証してください。";
        }
        catch (Exception ex)
        {
            MessageBox.Show($"エラー: {ex.Message}", "エラー");
            labelStatus.Text = "エラーが発生しました。";
            buttonLogin.Enabled = true;
        }
    }

    // ローカルサーバーを起動
    private async Task StartLocalServerAsync()
    {
        // HTTPリスナー初期化
        _httpListener = new HttpListener();
        // ポート3000で待ち受け
        _httpListener.Prefixes.Add("http://localhost:3000/");
        // リスナー開始
        _httpListener.Start();
        // バックグラウンドでリクエストを待ち受け
        _ = Task.Run(async () =>
        {
            while (_httpListener.IsListening)
            {
                try
                {
                    // コールバックリクエストを待機
                    var context = await _httpListener.GetContextAsync();
                    // コールバック処理
                    await HandleCallbackAsync(context);
                }
                catch
                {
                    break;
                }
            }
        });

        await Task.Delay(500);
    }

    // ブラウザを開く
    // OpenBrowserForAuth を audience を渡すように置換
    private void OpenBrowserForAuth()
    {
        _state = GenerateRandomString(32);
        _codeVerifier = GenerateRandomString(128);
        var codeChallenge = GenerateCodeChallenge(_codeVerifier);

        MessageBox.Show(
            $"State: {_state}\n" +
            $"Code Verifier: {_codeVerifier.Substring(0, Math.Min(20, _codeVerifier.Length))}...\n" +
            $"Code Challenge: {codeChallenge.Substring(0, Math.Min(20, codeChallenge.Length))}...",
            "PKCE/State 情報");

        // audience を含めて、API 用のアクセス トークン(JWT)を要求する
        var authUrl = $"https://{Auth0Domain}/authorize?" +
            $"client_id={ClientId}&" +
            $"redirect_uri={Uri.EscapeDataString(CallbackUrl)}&" +
            $"response_type=code&" +
            $"scope=openid profile email offline_access&" +
            $"audience={Uri.EscapeDataString(Audience)}&" +
            $"state={_state}&" +
            $"code_challenge={codeChallenge}&" +
            $"code_challenge_method=S256";

        Process.Start(new ProcessStartInfo(authUrl) { UseShellExecute = true });
    }

    // コールバック処理
    private async Task HandleCallbackAsync(HttpListenerContext context)
    {
        // HTTPリクエストとレスポンスを取得
        var request = context.Request;
        var response = context.Response;

        try
        {
            if (request.Url.LocalPath == "/callback")
            {
                var code = request.QueryString["code"];
                var returnedState = request.QueryString["state"];

                // State を検証(CSRF 対策)
                if (returnedState != _state)
                {
                    throw new SecurityException("State mismatch - CSRF attack detected!");
                }

                this.Invoke(new Action(() =>
                {
                    labelStatus.Text = "トークンを取得中...";
                }));
                // 認可コードをトークンに交換
                var tokenResponse = await ExchangeCodeForTokenAsync(code);
                //トークンを保存
                _tokenManager.SaveToken(tokenResponse);
                ShowTokenDiagnostics(tokenResponse);

                var html = @"

~htmlの記載は中略~

                var buffer = System.Text.Encoding.UTF8.GetBytes(html);
                response.ContentLength64 = buffer.Length;
                response.ContentType = "text/html; charset=utf-8";
                //HTMLレスポンスをブラウザに送信
                await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);

                this.Invoke(new Action(() =>
                {
                    labelTokenStatus.Text = "トークン取得済み";
                    labelTokenExpiry.Text = $"有効期限: {tokenResponse.ExpiresAt:yyyy-MM-dd HH:mm:ss}";
                    labelStatus.Text = "トークンを取得しました";
                    buttonLogin.Enabled = false;
                    buttonLogout.Enabled = true;
                    buttonAccessAPI.Enabled = true;
                }));
                // ローカルサーバー停止
                _httpListener.Stop();
            }
        }
        catch (Exception ex)
        {
            this.Invoke(new Action(() =>
            {
                labelStatus.Text = $"エラー: {ex.Message}";
            }));
        }
        finally
        {
            response.Close();
        }
    }

    // 認可コードをトークンに交換
    // ExchangeCodeForTokenAsync:PKCE (client_secret 送らない) のまま
    private async Task<TokenResponse> ExchangeCodeForTokenAsync(string code)
    {
        // PKCE を使う公開クライアントとして client_secret は送らない
        var values = new List<KeyValuePair<string, string>>
        {
            new KeyValuePair<string, string>("grant_type", "authorization_code"),
            new KeyValuePair<string, string>("code", code),
            new KeyValuePair<string, string>("client_id", ClientId),
            new KeyValuePair<string, string>("redirect_uri", CallbackUrl),
            new KeyValuePair<string, string>("code_verifier", _codeVerifier)
        };

        using (var httpClient = new HttpClient())
        {
            httpClient.Timeout = TimeSpan.FromSeconds(30);
            var content = new FormUrlEncodedContent(values);

            var resp = await httpClient.PostAsync($"https://{Auth0Domain}/oauth/token", content);
            var body = await resp.Content.ReadAsStringAsync();

            // デバッグ表示(UI に出力)
            this.Invoke(new Action(() => textboxResponse.Text = $"Token endpoint status: {(int)resp.StatusCode} {resp.ReasonPhrase}\r\n{body}"));

            // 生レスポンスを一時ファイルに保存(デバッグ用)
            try
            {
                var rawPath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "auth0_token_response.json");
                System.IO.File.WriteAllText(rawPath, body, System.Text.Encoding.UTF8);
                this.Invoke(new Action(() => textboxResponse.Text += $"\r\n\nSaved token response to: {rawPath}"));
            }
            catch { /* 無害 */ }

            if (!resp.IsSuccessStatusCode)
            {
                throw new InvalidOperationException($"Token endpoint returned {(int)resp.StatusCode}: {body}");
            }

            var token = JsonConvert.DeserializeObject<TokenResponse>(body);

            // access_token を別ファイルに保存(全文取得用)
            try
            {
                if (!string.IsNullOrEmpty(token?.AccessToken))
                {
                    var tokPath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "auth0_access_token.txt");
                    System.IO.File.WriteAllText(tokPath, token.AccessToken, System.Text.Encoding.UTF8);
                    this.Invoke(new Action(() => textboxResponse.Text += $"\r\nSaved access token to: {tokPath}"));
                }
            }
            catch { /* 無害 */ }

            return token;
        }
    }
    // ログアウトボタン
    private void buttonLogout_Click(object sender, EventArgs e)
    {
        _tokenManager.ClearToken();
        labelTokenStatus.Text = "トークンなし";
        labelTokenExpiry.Text = "";
        labelStatus.Text = "ログアウトしました";
        buttonLogin.Enabled = true;
        buttonLogout.Enabled = false;
        buttonAccessAPI.Enabled = false;
    }

    // ランダム文字列を生成 
    private string GenerateRandomString(int length)
    {
        //使用可能な文字セット
        const string chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~";
        //関数型のランダム生成器
        var random = new Random();
        // 数値の範囲からランダムに文字を選択して結合 
        return new string(Enumerable.Range(0, length)
            .Select(_ => chars[random.Next(chars.Length)])
            .ToArray());
    }

    // Code Challenge を生成(PKCE)
    private string GenerateCodeChallenge(string codeVerifier)
    {
        //VerifierをSHA256でハッシュ化し、Base64 URLエンコード
        using (var sha256 = System.Security.Cryptography.SHA256.Create())
        {
            //文字列をバイト配列に変換してハッシュ化
            var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier));
            return Convert.ToBase64String(hash)
                .Replace("+", "-")
                .Replace("/", "_")
                .TrimEnd('=');
        }
    }

    // API アクセスボタン
    // 保存されたトークンを読み込む
    private async void buttonAccessAPI_Click(object sender, EventArgs e)
    {
        try
        {
            var token = _tokenManager.LoadToken();
            if (token == null)
            {
                MessageBox.Show("トークンがありません。先にログインしてください。");
                return;
            }

            if (token.ExpiresAt < DateTime.UtcNow)
            {
                MessageBox.Show("トークンが期限切れです。再度ログインしてください。");
                _tokenManager.ClearToken();
                return;
            }

            // TLS を明示
            System.Net.ServicePointManager.SecurityProtocol =
                SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls;

            // 開発用: サーバ証明書検証を一時的に無効化(本番禁止)
            var handler = new HttpClientHandler
            {
                AllowAutoRedirect = false,
                UseProxy = true,
                Proxy = WebRequest.DefaultWebProxy,
                UseDefaultCredentials = true,
                // .NET Framework 4.7+ では ServerCertificateCustomValidationCallback が利用可能
                ServerCertificateCustomValidationCallback = (request, cert, chain, errors) =>
                {
                    // デバッグ用に証明書情報を出力
                    try
                    {
                        var sb = new StringBuilder();
                        sb.AppendLine($"Cert Subject: {cert?.Subject}");
                        sb.AppendLine($"Cert Issuer: {cert?.Issuer}");
                        sb.AppendLine($"SSLPolicyErrors: {errors}");
                        this.Invoke(new Action(() => textboxResponse.Text = sb.ToString()));
                    }
                    catch { }

                    // true を返すと検証をスキップして接続を許可する(開発用)
                    return true;
                }
            };
            using (var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) })
            {
                client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token.AccessToken);
                client.DefaultRequestHeaders.Accept.ParseAdd("application/json");

                HttpResponseMessage response = null;
                string body = null;
                try
                {
                    response = await client.GetAsync("https://<albのドメイン名>.ap-northeast-1.elb.amazonaws.com/");
                    body = await response.Content.ReadAsStringAsync();
                }
                catch (HttpRequestException hre)
                {
                    var text = $"HttpRequestException: {hre.Message}\nInner: {hre.InnerException?.Message}\n{hre}";
                    this.Invoke(new Action(() => textboxResponse.Text = text));
                    labelStatus.Text = "ネットワークエラー";
                    return;
                }
                catch (TaskCanceledException tce)
                {
                    this.Invoke(new Action(() => textboxResponse.Text = $"Timeout/Cancelled: {tce}"));
                    labelStatus.Text = "タイムアウト";
                    return;
                }

                // ヘッダー・ステータス・ボディを詳細表示
                var sb = new StringBuilder();
                sb.AppendLine($"Status: {(int)response.StatusCode} {response.ReasonPhrase}");
                sb.AppendLine("Response Headers:");
                foreach (var h in response.Headers) sb.AppendLine($"{h.Key}: {string.Join(", ", h.Value)}");
                foreach (var h in response.Content.Headers) sb.AppendLine($"{h.Key}: {string.Join(", ", h.Value)}");
                if (response.Headers.Location != null) sb.AppendLine($"Location: {response.Headers.Location}");
                sb.AppendLine("");
                sb.AppendLine("Body (truncated 5000 chars):");
                sb.AppendLine(body?.Substring(0, Math.Min(5000, body?.Length ?? 0)) ?? "<empty>");

                this.Invoke(new Action(() => textboxResponse.Text = sb.ToString()));

                if ((int)response.StatusCode >= 200 && (int)response.StatusCode < 300)
                {
                    MessageBox.Show("成功");
                }
                else
                {
                    MessageBox.Show($"HTTP エラー: {(int)response.StatusCode} {response.ReasonPhrase}");
                }
            }
        }
        catch (Exception ex)
        {
            this.Invoke(new Action(() => textboxResponse.Text = ex.ToString()));
            labelStatus.Text = "例外が発生しました。";
            MessageBox.Show($"例外: {ex.Message}", "エラー");
        }
    }

    // トークン内容を解析して textboxResponse に表示(デバッグ用)
    private void ShowTokenDiagnostics(TokenResponse token)
    {
        // UI スレッドで実行されていなければ UI スレッドに転送
        if (this.InvokeRequired)
        {
            this.Invoke(new Action(() => ShowTokenDiagnostics(token)));
            return;
        }

        if (token == null)
        {
            textboxResponse.Text = "Token is null";
            return;
        }

        var sb = new System.Text.StringBuilder();
        sb.AppendLine($"token_type: {token.TokenType}");
        // デバッグ時のみ全文を表示(運用時は省略推奨)
        sb.AppendLine($"access_token (full): {token.AccessToken}");
        sb.AppendLine($"id_token: {(string.IsNullOrEmpty(token.IdToken) ? "<none>" : (token.IdToken.Length > 80 ? token.IdToken.Substring(0, 80) + "..." : token.IdToken))}");
        sb.AppendLine($"expires_in: {token.ExpiresIn}");
        sb.AppendLine($"expires_at (UTC): {token.ExpiresAt:yyyy-MM-dd HH:mm:ss}");

        if (!string.IsNullOrEmpty(token.AccessToken))
        {
            if (token.AccessToken.Contains("."))
            {
                // JWT の可能性 → ペイロードをデコード
                try
                {
                    var payloadJson = DecodeJwtPayload(token.AccessToken);
                    sb.AppendLine();
                    sb.AppendLine("Access token payload (pretty):");
                    sb.AppendLine(payloadJson);

                    try
                    {
                        var payloadObj = JsonConvert.DeserializeObject<dynamic>(payloadJson);
                        string aud = payloadObj.aud != null ? payloadObj.aud.ToString() : "<no aud>";
                        string iss = payloadObj.iss != null ? payloadObj.iss.ToString() : "<no iss>";
                        long exp = payloadObj.exp != null ? (long)payloadObj.exp : 0;
                        var expDt = (exp > 0) ? DateTimeOffset.FromUnixTimeSeconds(exp).UtcDateTime.ToString("yyyy-MM-dd HH:mm:ss") : "<no exp>";
                        sb.AppendLine();
                        sb.AppendLine($"aud: {aud}");
                        sb.AppendLine($"iss: {iss}");
                        sb.AppendLine($"exp (UTC): {expDt}");
                    }
                    catch { /* ignore */ }
                }
                catch (Exception ex)
                {
                    sb.AppendLine();
                    sb.AppendLine($"JWT decode error: {ex.Message}");
                }
            }
            else
            {
                sb.AppendLine();
                sb.AppendLine("Access token does not look like a JWT (opaque token).");
            }
        }

        textboxResponse.Text = sb.ToString();
    }

    // base64url デコードして JSON ペイロードを返す
    private string DecodeJwtPayload(string jwt)
    {
        var parts = jwt.Split('.');
        if (parts.Length < 2) throw new ArgumentException("Invalid JWT format");
        string payload = parts[1];

        // base64url -> base64
        string padded = payload.Replace('-', '+').Replace('_', '/');
        switch (padded.Length % 4)
        {
            case 2: padded += "=="; break;
            case 3: padded += "="; break;
            case 0: break;
            default: padded += ""; break;
        }

        var bytes = Convert.FromBase64String(padded);
        var json = Encoding.UTF8.GetString(bytes);
        // 整形して返す
        try
        {
            var obj = JsonConvert.DeserializeObject<object>(json);
            return JsonConvert.SerializeObject(obj, Formatting.Indented);
        }
        catch
        {
            return json;
        }
    }
}

// トークンレスポンス
class TokenResponse
{
    [JsonProperty("access_token")]
    public string AccessToken { get; set; }

    [JsonProperty("id_token")]
    public string IdToken { get; set; }

    [JsonProperty("refresh_token")]
    public string RefreshToken { get; set; }

    [JsonProperty("expires_in")]
    public int ExpiresIn { get; set; }

    [JsonProperty("token_type")]
    public string TokenType { get; set; }

    public DateTime ExpiresAt => DateTime.UtcNow.AddSeconds(ExpiresIn);

    public bool IsExpired() => ExpiresAt < DateTime.UtcNow;

    public bool IsExpiringSoon(int minutesThreshold = 5)
        => ExpiresAt < DateTime.UtcNow.AddMinutes(minutesThreshold);
}
}

フォームはこんな感じのものを作りました。
スクリーンショット 2025-11-25 085007
ログインを押下すると、ローカルサーバを起動します。リスナーのポートは検証なのでなんでもいいのですが3000としておきました。auth0から認可コードを受け取る用途で利用しています。認証に利用するクライアントアプリの情報とともに、StateとCode Verifierをランダム生成しています。ネイティブアプリの認証では、auth0とのトークンやり取りを自前で実装する必要があるためセキュリティ対策も考慮する必要があります。
Stateをauth0へのリクエストのクエリパラメータに含めることで、auth0から認可コードを受け取った際にState値の一致可否を見て、リクエスト元はネイティブアプリであるかを検証しています。また、Code Verifierでは、生成したランダム値をSHA256でハッシュ化してCode Challenge値としてauth0に渡しておきます。認可コードが送られてきた際に、攻撃者が認可コードをのっとるのを防ぐことができます。認可コードをHttpクライアント経由でauth0のトークンエンドポイントに送信する際に、Code VerifierをPost内容に含めることで、バックエンド(auth0)でハッシュ保存されたCode Challengeを復号した結果が一致しているかを検証し、正当であればトークンを送るという仕組みです。

今回はALBでJWT検証をする際に、Authorizationヘッダーにアクセストークンを含めるためにレスポンスとしてテキストボックスに全文を返すようにしていますが、本番利用の際はやらないでください。(ターミナルでcurlしてアクセスが成功しているか確かめるために利用します)

APIアクセスを押下すると、認可コードにCode Verifierを添付してALBのドメインにアクセスする仕組みを組んでいます。バックエンドには"Hello Lambda!"の静的ページを返すだけのLambdaを実装しており、認可が成功するとテキストボックスにレスポンスのステータス200とボディが帰ってくるような流れになっています。

受け取ったトークンはローカルで保管することになりますが、安全な保管が必要です。今回は検証なので、同一マシン・同一ユーザでのトークン復号に制限するまでに留めます。(Windows標準のDPAPI暗号化を利用します)ただし、マルウェアが同一ユーザーで実行された場合は保護されません。本番利用の際はローカルでOS標準のセキュアストレージに格納するなどの対策をした方が良いかと思います。WindowsならCredential Managerとかをメソッドで入れ込んでロジックを組むとよいかと思いました。クラスを分けて以下のように暗号化も実施してみました。

// .NET Framework 4.7.2の環境で動作するC#コード
using System;
// ファイル操作用
using System.IO;
// 文字列のエンコード用
using System.Text;
//JSONシリアライズ用にNewtonsoft.Jsonを使用
using Newtonsoft.Json;
// DPAPIを使用するための名前空間(暗号機能)
using System.Security.Cryptography;

namespace TokenManagerApp
{
class TokenManager
{
    private readonly string _tokenStorePath;
    // アプリ固有のエントロピー(同一マシン・同一ユーザーでしか復号できないようにする追加情報)
    //DPAPIで暗号化する際に追加のセキュリティ情報として使用される
    private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("TokenManagerApp_Entropy_v1");

    public TokenManager()
    {
        //C:\Users\[ユーザー名]\AppData\Roaming
        _tokenStorePath = Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
            "TokenManagerApp", "tokens.json");

        var dir = Path.GetDirectoryName(_tokenStorePath);
        if (!string.IsNullOrEmpty(dir))
        {
            Directory.CreateDirectory(dir);
        }
    }

    // トークンを保存する
    public void SaveToken(TokenResponse token)
    {
        if (token == null) throw new ArgumentNullException(nameof(token));
        // TokenResponseオブジェクトをJSON文字列に変換(Null値は無視)
        var json = JsonConvert.SerializeObject(token, new JsonSerializerSettings
        {
            NullValueHandling = NullValueHandling.Ignore
        });

        var encrypted = ProtectData(json);

        // 安全に書き込む(tmp → 上書き)
        var tmpPath = _tokenStorePath + ".tmp";
        try
        {
            File.WriteAllBytes(tmpPath, encrypted);
            // 上書き(既存ファイルがない場合は単純移動)
            if (File.Exists(_tokenStorePath))
            {
                File.Replace(tmpPath, _tokenStorePath, null);
            }
            else
            {
                File.Move(tmpPath, _tokenStorePath);
            }

            // 必要なら隠し属性を付与
            try { File.SetAttributes(_tokenStorePath, FileAttributes.Hidden); } catch { /* noop */ }
        }
        finally
        {
            if (File.Exists(tmpPath))
            {
                try { File.Delete(tmpPath); } catch { /* noop */ }
            }
        }
    }

    public TokenResponse LoadToken()
    {
        if (!File.Exists(_tokenStorePath))
            return null;

        try
        {
            var encrypted = File.ReadAllBytes(_tokenStorePath);
            var json = UnprotectData(encrypted);
            if (string.IsNullOrEmpty(json)) return null;
            return JsonConvert.DeserializeObject<TokenResponse>(json);
        }
        catch (CryptographicException ex)
        {
            // 復号に失敗した(異なるユーザー/マシン、またはエントロピー違い)
            throw new InvalidOperationException("トークンの復号に失敗しました(DPAPI)。ユーザー/マシンが変わっていないか確認してください。", ex);
        }
        catch (Exception ex)
        {
            throw new InvalidOperationException("トークン読み込み中にエラーが発生しました。", ex);
        }
    }

    public void ClearToken()
    {
        try
        {
            if (File.Exists(_tokenStorePath))
                File.Delete(_tokenStorePath);
        }
        catch
        {
            // 削除失敗は無視してもよいがログ出力を推奨
        }
    }

    private byte[] ProtectData(string data)
    {
        var bytes = Encoding.UTF8.GetBytes(data);
        return ProtectedData.Protect(bytes, Entropy, DataProtectionScope.CurrentUser);
    }

    private string UnprotectData(byte[] encryptedData)
    {
        var decrypted = ProtectedData.Unprotect(encryptedData, Entropy, DataProtectionScope.CurrentUser);
        return Encoding.UTF8.GetString(decrypted);
    }
}
}

トークンをネイティブアプリ側で保管するロジックまで組めました。あとは、ALBでJWT検証を行う仕組みを実装していきます。まずは、auth0側でAPIを作成しておきます。先ほど作成したクライアントは、あくまでトークンを返すだけで誰がそのトークンを使ってアクセスを許可するのかを考慮する必要があります。そのため、検証の際にはaudienceの属性値が必要になります。ALB側でaudience値が一致していないとALBは認証エラー401をレスポンスとして返します。
スクリーンショット 2025-11-25 093422
auth0側でALBがトークン検証するためのAPIを発行しておきます。識別子情報を控えておきましょう。

ここで、APIアクセスボタンを押下したときの挙動を確認しておきます。
auth0から受け取ったトークンを読み込みます。audienceには、API識別子を指定しており、JWTのレスポンスペイロードとしてアクセストークンとともに、受け取れるようになっています。あとはこのリクエストヘッダにaudienceを入れてALBにアクセスするという流れです。ALB側でJWTの属性値を確認し、一致しているかどうかを確認することができます。

ALB側の設定を済ませておきましょう。JWTエンドポイントにアクセスするには、auth0から公開鍵を取得して復号する必要があります。また、このアクセスはhttpsで行うので今回は自己証明書を発行してALBに付与しておく必要があります。
この部分は割愛しますが、過去のブログでも検証しているのでご参照ください。
https://dev.classmethod.jp/articles/auth0-alb-openid-connect/

リスナールールの設定を行います。
スクリーンショット 2025-11-25 094422

アップデートで追加された「トークンを検証」を押下します。
JWTSのエンドポイントは以下を指定します。

https://<auth0のテナント>.<リージョン>.auth0.com/.well-known/jwks.json

発行者はトークンを発行したテナントのURLを指定します。

https://<auth0のテナント>.<リージョン>.auth0.com/

最後に/を入れておかないと、401エラーが起こったので、忘れないようにしておきます。JWTのIssuerクレームにも末尾に/が含まれており、完全一致させる必要があるためです。

ユーザクレームの設定を入れておきます。デフォルトで、audは検証属性値に含まれていないので追加しておきます。
スクリーンショット 2025-11-25 094736
名前は「aud」とし、文字列配列を指定します。auth0からのJWTペイロードを見ればわかるのですが、ユーザ情報を取得するためのuserinfoエンドポイントと、先ほど作成したAPIの識別子URLが含まれています。そのため下記の2つを選択します。

https://<APIの識別子URL>.com
https://<auth0のテナント>.<リージョン>.auth0.com/userinfo

ここまで設定できたら完了です。

動作検証

デバッグモードで、アプリのUIを起動してみます。
スクリーンショット 2025-11-25 095709
ログインを押下すると、ローカルサーバが起動します。
State、Code Verifier、ハッシュ化したCode Challengeが正常に生成されているかも確認することができます。
スクリーンショット 2025-11-25 095853

スクリーンショット 2025-11-25 100005
auth0の認証画面にリダイレクトされるので、メールアドレス・パスワード情報を入力してログインします。

認証が成功すると、成功の画面がブラウザに返されました。
スクリーンショット 2025-11-25 100023

フォームの方も確認してみます。
スクリーンショット 2025-11-25 100338

トークンの有効期限が表示され、トークン取得済みのステータスになっていますね。
テキストに表示されているJWTのペイロードも正常に取得できました。audの属性値も付与されていますね。

token_type: Bearer
access_token (full): xxxx
id_token: xxxx
expires_in: 86400
expires_at (UTC): 2025-xx-xx xx:xx:xx

Access token payload (pretty):
{
  "iss": "https://xxxx.us.auth0.com/",
  "sub": "google-oauth2|xxxx",
  "aud": [
"https://<APIの識別子>.com",
"https://xxxx.us.auth0.com/userinfo"
  ],
  "iat": xxxx,
  "exp": xxxx,
  "scope": "openid profile email",
  "azp": "xxxx"
}

APIアクセスボタンを押下すると、ステータスが200でLambdaのレスポンスボディが返却されました。
スクリーンショット 2025-11-25 101112

これで成功ではあるのですが、念のため、Curlして名前解決してみます。

curl -v -H "Authorization:Bearer "<アクセストークン>" https://<albのドメイン名>/

Authorizationヘッダーに、アクセストークンを付与してアクセスしてみました。
スクリーンショット 2025-11-25 101433
問題なく成功していますね。

さいごに

今回新しくアップデートされたALBでのJWT検証を試してみました。今まで、トークン検証ロジックをバックエンド側で実装するなど、アプリからの認証認可のフローの作りこみは手間でしたが楽になったのではないでしょうか。

検証なので、リフレッシュトークンの自動発行を実装して可用面を上げるで合ったり、RFCが提供しているセキュリティ事項を厳格にやるなど改善点はいろいろありますが、ネイティブアプリのHttpクライアントからのトークン受け渡しでALBのJWT検証を実装できました。

皆さんもぜひ試してみてください。

この記事をシェアする

FacebookHatena blogX

関連記事