WinUI 3×HttpListenerでOAuthリダイレクトのcodeが取れない原因と解決策(response_mode=fragment→query)

WinUI 3 などのデスクトップアプリで Microsoft の OAuth 認可を行い、localhost へリダイレクトされた認可コード(code)を HttpListener で受け取ろうとすると、ブラウザーには「#code=…」が見えるのにアプリ側では空…という落とし穴があります。本記事では原因と最短の直し方、実装例、よくある罠までまとめます。

目次

現象:ブラウザーでは「#code=…」が見えるのに HttpListener で取得できない

WinUI アプリからシステムブラウザーを開き、Microsoft の認可画面でサインイン→同意まで進めると、redirect_uri(例:localhost) に戻ってきます。ここで「認可コード(authorization code)」を受け取り、続けてトークン交換(token endpoint)を行うのが典型的な OAuth(Authorization Code Flow)です。

ところが、次のような状態にハマることがあります。

  • ブラウザーのアドレスバーには http://localhost:xxxx/#code=… のように見える
  • しかし HttpListener 側で受け取った context.Request.Url.AbsoluteUri を見ると、リダイレクト URI の本体だけ(code が付いていない)
  • HttpUtility.ParseQueryString() で解析しても code が取れない
どこで見えるURL見えているもの結果
ブラウザーのアドレスバーhttp://localhost:5000/#code=AAA...code が「あるように見える」
HttpListener の受信URLhttp://localhost:5000/(または /callback/)code が「届いていない」

これは実装ミスというより、URLの仕様と OAuth のレスポンスモードの選び方に原因があります。

結論:原因は response_mode=fragment(# 以降はサーバーへ送られない)

最も多い原因は、認可リクエストに response_mode=fragment を付けていることです。

fragment(URLの # 以降)は、ブラウザーがクライアント側で扱うための情報であり、HTTP リクエストとしてサーバーへ送信されません。つまり、HttpListener(= サーバー側)には fragment が届かないのが仕様です。

要素例サーバー(HttpListener)に届く?用途のイメージ
クエリ(query)?code=AAA&state=BBB届くサーバーが受け取って処理する
フラグメント(fragment)#code=AAA&state=BBB届かないブラウザー/JS がクライアント側で処理する

そのため、ブラウザーの URL に #code=... が表示されていても、HttpListener の context.Request.Url には fragment が存在しません。結果として ParseQueryString() でも拾えない、という流れになります。

なぜ fragment は送られないのか:HTTP リクエストの構造で理解する

ブラウザーがサーバーへ送るのは、ざっくり言うと以下のような HTTP リクエストです。

GET /callback/?code=AAA&state=BBB HTTP/1.1
Host: localhost:5000
...

ここに #... が入ることはありません。fragment はHTTP 仕様上「リクエスト対象(Request-URI)」の一部ではないため、サーバー側に渡せないのです。だからこそ、クライアント側(ブラウザーや JavaScript)でのみ意味を持ちます。

つまり今回の状況は、次のように言い換えられます。

  • ブラウザー:「#code=… は見えている(クライアント側の情報)」
  • サーバー(HttpListener):「そんなものは受け取っていない(仕様どおり)」

最短の解決策:response_mode=query に変更し、?code=… で返させる

対処はシンプルです。認可リクエストの response_mode を query に変更し、code をクエリ文字列(?code=...)で返してもらいます。

  • response_mode=fragment → response_mode=query に変更
  • 受信側は context.Request.Url.Query を解析する
  • (必要なら)リダイレクト受信のURLテンプレートや判定ロジックも「クエリ前提」に寄せる
設定ブラウザーの最終URL例HttpListener で code を取得できる?
response_mode=fragmenthttp://localhost:5000/#code=...できない(# 以降が届かない)
response_mode=queryhttp://localhost:5000/?code=...できる

実装例:WinUI 3 × HttpListener で OAuth リダイレクトから code を受け取る

ここでは「localhost のループバックで待ち受けて code を受け取る」最小構成を、WinUI(デスクトップ)で使いやすい形にまとめます。ポイントは次のとおりです。

  • HttpListener の Prefix は 末尾スラッシュが必須(例:http://localhost:5000/)
  • 受信時に見るのは Url.Query(query に code が載る前提)
  • CSRF 対策として state を発行して検証
  • 可能なら PKCE を使う(public client の基本)

認可URLの例(response_mode=query)

Microsoft の認可エンドポイント(v2.0)に投げる URL には、最低でも以下が入ります。

パラメータ意味例
client_idアプリIDxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
response_type返してほしいものcode
redirect_uri戻り先http://localhost:5000/
response_mode返し方query
scope権限openid profile offline_access User.Read
stateCSRF対策ランダム文字列
code_challenge / methodPKCES256

C# サンプル:ブラウザー起動→HttpListenerで待受→code抽出

using System;
using System.Diagnostics;
using System.Net;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Web;

public static class OAuthLoopbackReceiver
{
public static async Task ReceiveAuthorizationCodeAsync(
string authorizeEndpoint,
string clientId,
string redirectUri,
string scope,
string state,
CancellationToken cancellationToken)
{
// 1) HttpListener を起動(末尾スラッシュ必須)
using var listener = new HttpListener();
listener.Prefixes.Add(redirectUri.EndsWith("/") ? redirectUri : redirectUri + "/");
listener.Start();


    // 2) 認可URLを組み立て(response_mode=query)
    var authorizeUrl =
        authorizeEndpoint +
        "?client_id=" + Uri.EscapeDataString(clientId) +
        "&response_type=code" +
        "&redirect_uri=" + Uri.EscapeDataString(redirectUri) +
        "&response_mode=query" +
        "&scope=" + Uri.EscapeDataString(scope) +
        "&state=" + Uri.EscapeDataString(state);

    // 3) 既定ブラウザーを開く
    Process.Start(new ProcessStartInfo
    {
        FileName = authorizeUrl,
        UseShellExecute = true
    });

    // 4) リダイレクトを1回だけ待つ
    var contextTask = listener.GetContextAsync();
    using (cancellationToken.Register(() => listener.Stop()))
    {
        HttpListenerContext context;
        try
        {
            context = await contextTask.ConfigureAwait(false);
        }
        catch (HttpListenerException)
        {
            throw new OperationCanceledException("認可待機がキャンセルされました。", cancellationToken);
        }

        // 5) query から code / state を取得
        var query = HttpUtility.ParseQueryString(context.Request.Url?.Query ?? "");
        var code = query["code"];
        var returnedState = query["state"];
        var error = query["error"];
        var errorDescription = query["error_description"];

        // 6) ブラウザーに「完了」ページを返す(閉じてもらう)
        var html = @"<!doctype html>


認証完了
認証が完了しました。このウィンドウを閉じてアプリに戻ってください。";
var buffer = Encoding.UTF8.GetBytes(html);
context.Response.ContentType = "text/html; charset=utf-8";
context.Response.ContentLength64 = buffer.Length;
await context.Response.OutputStream.WriteAsync(buffer, 0, buffer.Length, cancellationToken).ConfigureAwait(false);
context.Response.OutputStream.Close();


        // 7) エラー・state 検証
        if (!string.IsNullOrEmpty(error))
        {
            throw new InvalidOperationException($"OAuth エラー: {error} / {errorDescription}");
        }
        if (string.IsNullOrEmpty(code))
        {
            throw new InvalidOperationException("code が取得できませんでした。response_mode=query と redirect_uri を確認してください。");
        }
        if (!string.Equals(state, returnedState, StringComparison.Ordinal))
        {
            throw new InvalidOperationException("state が一致しません。CSRF の可能性があるため中断します。");
        }

        return code;
    }
}


}

上の例は「code を受け取るところ」までです。実運用ではこの後に、token endpoint に grant_type=authorization_code と code、(PKCE を使うなら)code_verifier を送ってアクセストークンを取得します。

つまずきポイント:redirect_uri の一致、末尾スラッシュ、URL エンコード

OAuth は「少しの違い」で失敗します。特に Microsoft のアプリ登録(Microsoft Entra ID / Azure AD)側と、アプリ側の redirect_uri が 完全一致していないと、うまく戻ってこなかったり、戻ってきても意図しない形になったりします。

症状原因になりやすい点対策
認可画面の後にエラーになるredirect_uri が登録と一致しないアプリ登録の Redirect URI と 1文字も違わないように揃える
HttpListener は受信するが code がないresponse_mode=fragmentresponse_mode=query に変更
リスナーが起動できない(Access denied)URL ACL 未設定 / 権限不足ポート変更、URLACL設定、実行権限を見直す
クエリの解析がうまくいかないredirect_uri や scope の URL エンコード漏れUri.EscapeDataString を徹底する
たまに別のタブで想定外挙動state を検証していないstate を必ず発行・照合する

特に末尾スラッシュは見落としがちです。HttpListener.Prefixes に追加する URI は、末尾が / で終わる必要があります。http://localhost:5000 ではなく http://localhost:5000/ に揃える、というイメージです。

追加で知っておくと強い:HttpListener の URL ACL とポート選び

本題の「code が取れない」問題とは別ですが、WinUI アプリで HttpListener を使う場合、環境によっては待受の開始時に例外が出ます。代表例が「アクセスが拒否されました(Access is denied)」です。

この場合、原因は次のどれかに集約されやすいです。

  • その URL(プレフィックス)を listen する権限がない(URL ACL の問題)
  • ポートが既に使用中
  • localhost ではなくホスト名や IP を広く指定している

対策としては、まずは 127.0.0.1 / localhost のみに絞り、空いているポートを選ぶのが安全です。どうしても固定ポートが必要で、権限で詰まる場合は URL ACL の設定が必要になることがあります。

netsh http add urlacl url=http://localhost:5000/ user=DOMAIN\Username

運用環境での権限設定は組織のポリシーや配布形態(MSIX か、管理者権限があるか等)に依存するため、社内基準に合わせて判断してください。開発中は「ランダムポート + localhost + 1回だけ待つ」設計にしておくと事故が減ります。

どうしても response_mode=fragment を使いたい場合の別案

基本方針としては response_mode=query を推奨します。とはいえ「既存仕様で fragment で返る前提になっている」「制約で変更しづらい」というケースもあります。

その場合の回避策として、ブラウザー側の JavaScript で fragment を読み取り、query に詰め替えて再リダイレクトする方法があります。構造としては次のイメージです。

  • 認可後:http://localhost:5000/bridge.html#code=... に戻る
  • bridge.html が location.hash を読み、http://localhost:5000/callback/?code=... に移動
  • /callback/ を HttpListener が受けて code を取る
<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <title>Redirect Bridge</title>
</head>
<body>
  <p>処理中です…</p>
  <script>
    // location.hash: "#code=...&state=..."
    const hash = window.location.hash.startsWith("#") ? window.location.hash.substring(1) : "";
    // "/callback/?" に付け替えて再リダイレクト
    if (hash) {
      window.location.replace("/callback/?" + hash);
    } else {
      document.body.innerHTML = "<p>パラメータが見つかりませんでした。</p>";
    }
  </script>
</body>
</html>

ただしこの方式は、配布や保守(HTML を同梱する/ルーティングを増やす/セキュリティレビューで説明する)が必要になりがちです。可能なら素直に response_mode=query に寄せた方がシンプルで、トラブルシュートも容易です。

自前実装より MSAL を推奨する理由:PKCE、キャッシュ、リダイレクト処理を吸収

OAuth を「自分で組む」こと自体は可能ですが、実務では落とし穴が多い領域です。特に Microsoft の認証(Microsoft identity platform)では、将来的な仕様変更やセキュリティ要件(PKCE、ブローカー連携、トークンキャッシュなど)を考えると、MSAL(Microsoft Authentication Library) の利用が堅実です。

観点HttpListener で自前MSAL 利用
実装コスト低〜中(最初は簡単に見える)中(初期設定は必要)
安全性(PKCE/CSRF 等)自分で正しく実装する必要ライブラリが吸収する範囲が広い
トークンキャッシュ自前実装が必要標準で対応しやすい
仕様差分・更新追従自分で追う公式が追従する前提
トラブル時の検索性実装が独自で情報が少ない事例・ナレッジが多い

「まずは動かして検証したい」段階では HttpListener での最小構成も有効ですが、業務アプリとして配布・運用するなら、最終的に MSAL へ寄せる判断は十分に価値があります。

トラブルシューティング:code が取れないときのチェックリスト

最後に、現場で効く確認項目をまとめます。「どこまで動いているか」を切り分けるだけで復旧が早くなります。

チェック確認する場所期待値
response_mode は query かauthorize URLresponse_mode=query
response_type は code かauthorize URLresponse_type=code
redirect_uri が完全一致かアプリ登録 + authorize URL1文字も違わない(末尾スラッシュ含む)
HttpListener の Prefix が正しいかコードhttp://localhost:PORT/ のように末尾 / がある
取得対象が Query になっているかコードcontext.Request.Url.Query を解析している
state を検証しているかコード一致しなければ中断する
エラー戻りを拾っているかクエリerror, error_description をチェック

まとめ:#code が見えても HttpListener で取れないのは仕様。query で返させるのが王道

WinUI アプリで HttpListener を使って OAuth のリダイレクトを受ける場合、code が URL の fragment(# 以降)で返ってくると、サーバー側では取得できません。これは OAuth 以前に URL/HTTP の仕様です。

  • ブラウザーに #code=... が見えても、HttpListener には届かない
  • response_mode=query に変更し、?code=... で返してもらう
  • 併せて redirect_uri の完全一致、末尾スラッシュ、state 検証を徹底する
  • 長期運用なら MSAL の利用も検討する

「ブラウザーでは見えるのにアプリで取れない」系の混乱は、response_mode を正しく選ぶだけで一気に解消します。まずは query で code を確実に受け取れる状態にしてから、PKCE やトークン取得まで段階的に固めていくのがおすすめです。

この記事を書いた人

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

コメント

コメントする

目次