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 の受信URL | http://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=fragment | http://localhost:5000/#code=... | できない(# 以降が届かない) |
response_mode=query | http://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 | アプリID | xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx |
| response_type | 返してほしいもの | code |
| redirect_uri | 戻り先 | http://localhost:5000/ |
| response_mode | 返し方 | query |
| scope | 権限 | openid profile offline_access User.Read |
| state | CSRF対策 | ランダム文字列 |
| code_challenge / method | PKCE | S256 |
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=fragment | response_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 URL | response_mode=query |
| response_type は code か | authorize URL | response_type=code |
| redirect_uri が完全一致か | アプリ登録 + authorize URL | 1文字も違わない(末尾スラッシュ含む) |
| 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 やトークン取得まで段階的に固めていくのがおすすめです。

コメント