綴Photo の勉強ノートに戻る
開発記録2026.06.25

モバイルに本物のログインを足す④ — 動いてはいた。でもアプリは自分がログイン済みだと知らなかった

設計認証状態管理Flutter綴Photo

モバイルに本物のログインを足す連載の続き。前回はブラウザで自分の web ログインを開いてトークンを受け取る土台を作った。今回は web をつないで実際に一周し、ログインを「動く」から「使える」にする回——アプリが自分のログイン状態を持って、見せる。

ここで言う「web」は、綴ったものを web でも見られるように開発中のアプリ(紡Book)のことです。

綴Photo はデータをスマホのローカルに溜めます。ネットがなくても全部使えます。ログインが要るのは、その記録をクラウドに同期したいときだけです。前回ブラウザ経由でトークンを取れるようにして、実機で一度ログインを通した。確かに動いた。でも設定画面を開いても、自分がログイン中かどうかはどこにも出ません。

ログインは起動の壁にせず、設定の中に静かに置くと決めていた(その判断は別記事に書いた)。壁にしないぶん、ログイン状態は「設定を見れば分かる」程度に静かに見せればいい——はずだった。ところがその「見せる」ができていなかった。

動いてはいたが、アプリは状態を持っていなかった

設定に出すために、まず「アプリは今ログイン済みか」をどう持っているか確かめた。コードを読んだら、復元する処理(restore)はちゃんと書いてある。トークンを金庫から読んで、あれば「ログイン済み」にします。

dart
// token を金庫から読んで、あれば「ログイン済み」にするだけ。ネットは呼ばない
Future<void> restore() async {
  final access = await _store.readAccess();
  state = AuthState(
    status: access == null ? AuthStatus.unauthenticated : AuthStatus.authenticated,
  );
}

問題は、この restore がどこからも呼ばれていなかったことだ。起動のどこにも繋がっていません。だからアプリは起動するたびに「ログイン状態は不明」のまま立ち上がっていた。前回ログインが「動いた」のは、ログインした直後の一回だけ状態が立っていたからで、再起動したら忘れます。

面白かったのは、それでも同期自体は再起動後も動くことだった。

同期は金庫を読む。画面はメモリを見る。名前はサーバにあり、端末には保存しない。金庫 TokenStoreaccess / refreshKeychain / Keystore閉じても残る名前は置かない同期はここを直接読む画面は見ないメモリ AuthStateログイン済みか名前(あれば)起動で消える保存しない画面はここを見る繋がる前は不明のままサーバ DBaccounts_userusernameGET /api/accounts/me/端末には保存しない名前の正本はここ閉じても残る置き場所は3つ。画面が見るのはメモリだけ。

直し方はシンプルで、起動時に一度だけ restore を走らせる配線を足してもらった(ここでの「配線」=起動の流れに処理を繋いで、実際に走るようにすること。電源はあるのにスイッチから線が伸びていない状態を、繋ぎ直すイメージです)。ひとつ気をつけたのは、この復元でネットを呼ばないこと。金庫を読むだけにして、起動が一瞬で済むようにした。ローカル中心のアプリなら、オフラインでも一瞬で立ち上がってほしい。

dart
// これが無かった=起動時にどこからも restore() が呼ばれていなかった
// FutureProvider=アプリ起動時に一度だけ中の処理を自動で走らせる仕掛け
final authRestoreProvider = FutureProvider<void>(
  (ref) => ref.read(authControllerProvider.notifier).restore(),
);
 
// この一行で「起動時に restore を走らせる」配線が初めて繋がる(金庫を読むだけ=ネット非依存)
ref.watch(authRestoreProvider);

名前を出すには、裏で一回聞きに行くしかなかった

状態が持てたら、次は「tsuzu_taro でログイン中」みたいに名前を出したい。ところが、トークンを交換したときに web が返すのはトークンのペアだけで、名前が入っていません。

名前を知るには、別のエンドポイント(me/)に聞きに行くしかなかった。

これをいつ叩くかを決めた。起動時に毎回叩くと、ネットが要る処理を起動に混ぜることになります(さっきオフライン起動を守ったばかりなのに)。なので設定画面を開いたときだけ聞きに行く形にした。名前はあくまで「見せられたら見せる」扱いで、取れなければ名前なしの「ログイン中」のままでいいです。

起動は金庫だけ。名前は設定を開いたときサーバからメモリへ。保存しない。起動時設定を開いたとき読む: 金庫 TokenStore書く: メモリ AuthStateログイン済み。名前は空ネットは呼ばない読む: GET /api/accounts/me/書く: メモリの名前accounts_user の username取れなければ名前なしのまま

切れていたら黙って取り直す、でもオフラインでは何もしない

me/ を叩く処理で一番考えたのが、失敗したときの振り分けだ。失敗には2種類あります。「トークンが切れている(401)」のと「そもそも通信できない(オフライン等)」です。この二つを混ぜると事故ります。

401 だけ取り直す。通信失敗はメモリに触らない。me/ が 401(失効)me/ が通信失敗金庫の refresh で access を更新Keychain / Keystore に書き戻す新しい access で me/ 再試行名前をメモリへ。保存しないメモリ AuthState は触らないログイン済みのまま金庫のトークンも消さないオフラインで弾かない取り直し失敗だけ未ログインへ。通信失敗は状態を壊さない。

ネットが繋がっていないだけなのに「ログアウト扱い」にしてしまうと、地下鉄で設定を開いただけで弾き出されます。

dart
final access = await _store.readAccess();
if (access == null) { /* 未ログイン扱い */ return; }
try {
  final username = await _api.fetchMe(access);   // me/ に「今ログインしてるのは誰?」と聞く
  state = AuthState(status: AuthStatus.authenticated, username: username);
} on AuthApiException catch (e) {
  if (e.statusCode != 401) return;               // 401(失効)以外は状態をいじらない
  final ok = await tryRefresh();                 // 失効してたら裏で取り直す
  if (!ok) return;                               // 取り直しも失敗=本当に未ログインへ
  final access2 = await _store.readAccess();
  state = AuthState(
    status: AuthStatus.authenticated,
    username: await _api.fetchMe(access2!),       // 新しいトークンでもう一回
  );
} catch (_) {
  // 通信失敗など=状態を壊さない(オフラインで誤ログアウトしない)
}

実機で、1時間前に切れたトークンが勝手に蘇った

全部組んでテストを通して、実機で設定を開いたら「tsuzu_taro でログイン中」と出た(テスト用のアカウント)。名前まで出ている。裏でサーバのログを見たら、地味にうれしい順番になっていた。

GET /me/   401   ← まず弾かれた(前のログインから1時間、入館証は切れていた)
GET /me/   200   ← 取り直してもう一回 → tsuzu_taro

1時間前にログインの一周通しをやったとき取った入館証(15分有効)が、当然切れていた。設定を開いた瞬間 me/ が 401 で弾かれ、アプリが裏で引換券を出して入館証を取り直し、もう一度 me/ を叩いて名前を取って表示していた。テストで一番ややこしく書いた「401 のときだけ取り直す」分岐が、本番の経路でそのまま動いていたのを、サーバのログで確かめられた。ログアウトを押せば「未ログイン」に戻って同期タイルが消え、もう一度ログインすれば名前ごと戻る、も確認できた。

学びポイント

  • 「動く」と「アプリが状態を持っている」は別。 トークンがあって API が通っても、起動時に状態を復元していないと、画面は自分がログイン済みだと知りません。復元の配線は、書いてあっても繋ぎ忘れます。
  • 表示のための情報は、起動の邪魔をしない所で best-effort に取ります。 名前は起動時じゃなく設定を開いたときに聞きに行き、取れなければ名前なしで通します。起動はネット非依存に保ちます。
  • 失効(401)と通信失敗(例外)を分けます。 前者は裏で取り直し、後者は状態に触りません。混ぜると、オフラインなだけで誤ログアウトします。

おわりに

これでログインは「動く」から「使える」になった。土台と状態の見せ方はここまで。この時点で手つかずだったのが、iOS / Android でブラウザからアプリに戻ってくる「戻り口」と、web の中継を本番に出すことだった。

その戻り口をどの方式にするかは、同じ日に別記事 アプリのログイン、結局どういう形? — 『誰が認証するか』と『どう戻すか』を分けたら、ほどけた で決めている。アプリ独自の合図(custom scheme)は別アプリに横取り登録されうるので足さず、OS がドメインの持ち主を確認する App Links に倒した。③で「custom scheme を足す日が本当の急所」と後回しにした穴は、足さない選択で回収している。


この記事は2026年6月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。