モバイルに本物のログインを足す連載の続き。前回はトークンを「毎回読む」継ぎ目を作った。今回はその上に本物のログインを載せる——前に、方式を1個ひっくり返した記録です。
ここで言う「web」は、綴ったものを web でも見られるように開発中のアプリ(紡Book)のことです。
綴Photo は、モバイルアプリと、それを見せる web、つなぐ backend でできています。前回トークンを差し込む継ぎ目を1点(AuthSession)に絞ったので、今回はそこに「本物のログイン」を差すだけ——のはずだった。実際に差す段で「ログイン画面ってどこに出すの?」を詰めたら、当初の方式をやめることにした。
「アプリが Google に直接ログインする」のをやめた
取り方は2つありました。
web は既にログインできています(Google でログインする画面はもう動いています)。なので「アプリも同じ web ログインを開いて、結果だけもらえばいい」に倒した。決め手は、アプリが Google を一切持たなくて済むこと。
代償は、web 側に「アプリにトークンを返す中継」を新しく足す必要があること(これはまだ別セッションの宿題)。それでも、アプリから Google 設定を全部追い出せて、しかも作ってある web ログインを使い回せるので、こっちを選んだ。
ちなみに最初は「デスクトップだと変になるから web 経由にするしかない」と思い込んでいた。整理したら、それは別方式(端末ネイティブの SDK)が Windows 非対応という話で、ブラウザを開く方式はどっちもデスクトップで普通に動きます。方式選びは「動く/動かない」じゃなく設計の好みで決めていい、と分かったのは収穫だった。
ブラウザで自分のログイン画面を開いて、アプリに戻す
問題は「web からアプリにどう戻すか」で、ブラウザとアプリは別プロセスだから、アプリが受け取れる住所を用意して、そこへリダイレクトさせます。デスクトップでは、その住所をアプリ自身が一瞬だけ立てます。
// 127.0.0.1(自分自身)の空きポートに、一瞬だけ小さなサーバを立てる ← これが loopback
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
// ブラウザはログイン後この URL に戻ってくる
String get redirectUri => 'http://127.0.0.1:${server.port}/cb';トークンそのものじゃなく「引換券」を返す
戻ってくるとき、URL にトークンを直接載せれば一番簡単です。でもそれはやめた。戻り道(リダイレクトの URL)は漏れる前提だから——ブラウザの履歴、loopback のログ、モバイルだと別アプリによる横取り。一番長生きするトークン(7日有効)をそんな所に載せたくない。
なので web が返すのは「使い捨ての引換券(ワンタイム code)」だけにして、アプリがそれをブラウザを通さないアプリ ↔ サーバの直通の通信で本物のトークンに交換する形にした(こちらは URL として履歴にもログにも残らない)。交換のとき、アプリだけが知る秘密を一緒に出します。
券も同じ道を通る以上、漏れうるのは変わりません。違うのは価値の寿命で、券は短時間で失効し、一度使ったら消える契約にした。7日生きるトークンが履歴に残るのと、すぐ腐る券が残るのとでは、漏れたときの意味がまるで違います。(この失効と単回使用を実際に担保するのはサーバ側で、web の中継を作る次回の話になります。)
final cb = await capture.awaitRedirect(); // ブラウザがアプリに戻ってきた(結果をアプリに渡す唯一の道)
if (cb.queryParameters['state'] != state) { // 自分が出した state か(すり替え防止)
throw const WebLoginException('state が一致しない');
}
final code = cb.queryParameters['code']; // 戻ってきたのは「引換券」だけ
return await api.exchangeSessionCode(code!, pkce.codeVerifier); // 裏口で本物に交換(verifier 同送=PKCE)URL に直接トークンを載せれば交換の直通は消せるけど、危ないので分けた。
継ぎ目のおかげで、差し替わったのは「取得方法」だけ
前回「窓口を1点に絞れば、差し込むのはその1点で済む」と書いた。今回それが効いた。方式をまるごと変えたのに、トークンの保存・切れたときの取り直し(サーバが 401=「通行証が切れた」と返したら再取得する部分)・同期は1行も触っていません。変わったのは「トークンをどう手に入れるか」だけ。
// 旧: Google から取った access_token を backend に渡す(Google 直行)
Future<void> loginWithGoogleAccessToken(String accessToken) { ... }
// 新: ブラウザで web ログイン → 引換券を交換して token を得る(web 経由)
Future<void> loginWithWebHandoff() { ... }入手の入口が2行違うだけで、その先(受け取ったトークンを金庫に入れて、切れたら取り直して、同期に乗せる)は全部そのまま動きます。継ぎ目をどこに置くかは、足す前に決まる——を改めて実感した。
ログインボタンが二度と押せなくなる罠
組み終わったコードを丸ごと多段レビューに掛けたら、2件出てきた。どちらも「正常系は動くが、ユーザーが途中でやめたとき」の話だった。
1つ目が厄介だった。ログインの戻りを待つ部分に締め切りがありません。ユーザーがブラウザを閉じてログインをやめると、アプリは「戻り」を永久に待ち続けます。待っている間、画面の状態は「ログイン中」で固まり、ボタンは押せません(処理中なので無効化されています)。つまりアプリを再起動するまでログインボタンが二度と押せません。確実に踏める行き止まりだった。
2つ目は、立てた loopback サーバが「最初に来たリクエスト」を何でも戻りとして食べてしまう件。ブラウザが favicon を取りに来たり、別プロセスがポートを覗いたりして、本物の /cb より先に来ると、それを戻りと勘違いして偽の失敗になります。
どちらも嫌だったので直すことにした。締め切り(タイムアウト)を入れて、戻りが来なければ「完了できなかった」として状態を戻します。受け取りも「/cb が来るまで待つ」ループにして、それ以外は受け流します。
// /cb 以外(favicon 等)が先に来てもワンショットを潰さない=/cb が来るまで待つ
if (req.requestedUri.path != '/cb') {
req.response.statusCode = 404;
await req.response.close();
return;
}
// 戻ってこないユーザーのための締め切り。これが無いと状態が「ログイン中」で固着する
Timer(timeout, () => completer.completeError(TimeoutException('タイムアウト', timeout)));正常系のテストだけ書いていると、この手の「来なかったとき」はすり抜けます。レビューが「放棄したらどうなる?」を突いてくれたおかげで拾えた。
学びポイント
- 「ログインを足す」を「アプリが Google を持つ」にしません。 認証を自分の web に丸投げできるなら、アプリは結果だけ受け取ればいいです。プラットフォーム別の Google 設定を全部 web 側に寄せられて、アプリは認証方式を知らないままでいられます。
- 戻り道(リダイレクト URL)は漏れる前提で設計します。 本物のトークンじゃなく使い捨ての引換券を載せ、verifier(PKCE)付きで裏口交換します。一番長生きするトークンほど、危ない道に載せません。
- 継ぎ目を1点に絞った効果は、方式を丸ごと変えたときに出ます。 トークンの「入手経路」を差し替えただけで、保存も取り直しも同期も無傷でした。継ぎ目の値打ちは、変更が来て初めて分かります。
- ローカルにサーバを立てて待つ系は「来なかったとき」を必ず考えます。 締め切りが無いと、ユーザーがブラウザを閉じただけでボタンが永久に死にます。正常系のテストでは見えません。
おわりに
今回組んだのは土台まで。web 側の中継(アプリに引換券を返すページと、それを本物に換える裏口)はまだこれからで、それが出来て初めて実機で一周する。ただアプリ側は「ブラウザを開いて、戻ってきた引換券を交換する」だけになったので、残りは web の宿題。次回、実際に web をつないで動かしてみる。
この記事は2026年6月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。