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

モバイルに本物のログインを足す① — 既存の同期を1行も変えず、トークンを『毎回読む』継ぎ目にする

設計認証FlutterRiverpod綴Photo

モバイルからサーバへ綴りを同期する管を作っている連載の続き。前回「次はログインを本物にする」と書いた、その土台(client core)を組んだ記録です。

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

綴Photo は、モバイルアプリと、それを見せる web、間をつなぐ backend でできています。同期の管はもう通っていて、選んだ写真だけサーバに上がる所まで動きます。ただログインがまだ開発用で、--dart-define(アプリを組み立てる=ビルドする時に値を埋め込む仕組み)で手発行したトークンを、組み立て時に一度きり焼き込んでいました。これだとストアに出すアプリには使えません。本物のログインに置き換えたい。

問題は、同期もアップロードももう動いていること。そこを壊さずにログインだけ足したい。今回はそのための土台を組んだ。

トークンを「焼き込む」のをやめて、毎回読みにいく

旧実装は、固定のトークンを通信クライアントに一度だけ渡していました。

dart
// 旧: ビルド時に決まったトークンを1回渡すだけ。ログインしても変わらない
ApiClientImpl(baseUrl: ..., token: AppConfig.jwtToken);

これだと、ログインしてトークンが変わっても、通信クライアントは古いトークンを握ったままになります。本物ログインは「ログイン前は未認証、ログイン後は認証済み、しばらくすると切れて取り直す」とトークンが動く前提なので、固定で持つのと相性が悪いです。

なので「リクエストを送るたびに、今のトークンを読みにいく」形にした。

固定は組み立て時のバイナリ。access と refresh は Keychain / Keystore。SharedPreferences には置かない。組み立て時のバイナリ固定トークンdart-define で焼く実行中は保存しない端末の金庫access 15分 / refresh 7日Keychain / Keystore閉じても残る。毎回ここを読むSharedPreferences には置かない
dart
// 毎回呼ばれる。ログイン済みなら今の access、未ログインなら従来の固定トークンに落ちる
Future<String> _resolveToken() async {
  final s = _authSession;
  if (s != null) {
    final live = await s.currentAccessToken();
    if (live != null && live.isNotEmpty) return live; // ログイン中はこっち
  }
  return _staticToken; // 未ログインの間は従来の dart-define トークン(移行期の保険)
}

ログインで中身が変わっても、次のリクエストから自然に新しいトークンになります。

既存の同期を1行も変えずに済ませる継ぎ目

ここが今回の本体。同期もアップロードも ApiClient という窓口を受け取って使っています。その裏に、認証のことだけを知っている小さな契約 AuthSession を1個差し込む。

同期とアップロードは窓口だけを見る。裏で金庫の access を毎回読む。無ければバイナリの固定。同期使う側は変えないアップロード使う側は変えないApiClient の窓口型は同じ。渡さなければ固定トークンのままAuthSession 交差。毎回読む。層ではないKeychain の access。閉じても残るバイナリの固定。保存しない

差し込む契約はこれだけにした。

dart
// 通信層が認証について知る、たった一つの窓口
abstract class AuthSession {
  Future<String?> currentAccessToken(); // 今のトークン(無ければ null)
  Future<bool> tryRefresh();            // 401 のとき取り直す。成功で true
  Future<void> logout();                // 破棄して未認証へ
}

通信クライアントは、この契約を任意で受け取る形にした。

dart
ApiClientImpl({
  required String baseUrl,
  String token = '',        // 旧来の固定トークン(後方互換で残す)
  AuthSession? authSession, // ← これが居れば毎回トークンを読みにいく
  http.Client? client,
});

authSession を渡さなければ、今までどおり固定トークンで動きます。渡せば毎回読みます。既存の同期・アップロードのコードは1行も変えていません。実際、これらの既存テストは1文字も書き換えずに緑のままでした。

「ログインを足す」と聞くと、トークンを使う全部の箇所に手を入れる絵を想像しますけど、抽象を1点に絞っておくと、差し込むのはその1点で済みます。どこを継ぎ目にするか、をここで決めた。

401 が返ってきたら、一回だけ自動で取り直す

access トークンは短命(15分)で、切れるとサーバは 401 を返します。切れるたびにログイン画面へ飛ばすのは雑なので、「401 が来たら裏で取り直して、同じリクエストをもう一度だけ送る」を通信クライアントに入れた。

dart
Future<http.Response> _executeWithAuth(
  Future<http.Response> Function(String token) send,
) async {
  var token = await _resolveToken();
  _assertTokenTransportSecure(token);
  var res = await send(token);
 
  // 401 かつログイン中なら、取り直して一度だけ再送する
  if (res.statusCode == 401 && _authSession != null) {
    final refreshed = await _authSession.tryRefresh();
    if (refreshed) {
      token = await _resolveToken();      // 取り直した新しいトークンで
      _assertTokenTransportSecure(token);
      res = await send(token);            // 再送は最大1回(でないと無限ループ)
    }
  }
  return res;
}

肝は「再送は1回まで」。再送した結果また 401 でも、もう一度…とやると、サーバが 401 を返し続ける限り無限に回ります。取り直して駄目なら諦めて 401 をそのまま返す、で止めます。ここはテストで「再送後も 401 なら通信は2回で止まる」を固定した。

リクエストを送る closure を受け取る形にしてあるので、GET も POST も画像アップロードも、全部この同じ取り直しロジックを通ります。

同時に何本も 401 が来ても、取り直しは1回にまとめる

ここが地味だけど踏みやすい穴だった。

なので「取り直しは、同時に何本要求が来ても1回にまとめる」を入れた。誰かが取り直し中なら、後から来た人はその進行中の取り直しに相乗りして、同じ結果を待ちます。

dart
Future<bool> tryRefresh() {
  final inFlight = _refreshInFlight;
  if (inFlight != null) return inFlight; // 既に走ってる取り直しに相乗りする
  final future = _doRefresh().whenComplete(() => _refreshInFlight = null);
  _refreshInFlight = future;             // 走り始めたら覚えておく
  return future;
}

肝は、_refreshInFlight を埋めるまでの間に await を1つも挟んでいないこと。ここで一度でも中断すると、その隙に入ってきた2本目が「まだ誰も走っていない」を見て2本目の取り直しを始めてしまいます。代入まで一息で走り切るから、後の2本は「もう走ってる」を見て同じ future(後で出来上がる結果の受け取り口。注文番号の札のようなもの)を返せます。結果、サーバへの取り直し要求は1回だけになります。同時に3回呼んで refresh は1回、をテストで確認した。

async を付けていないのも同じ狙いだが、こちらは念のためです。Dart は async を付けた関数でも最初の await までは同期に走るので、効いているのは async の有無ではなく「ここに await を置かない」ほう。外してあるのは、その約束をコードの形で残すため。

一番大事なトークンだけ、門を通っていなかった

ここはレビューが拾った。組み終わったコードを丸ごと AI のレビューに掛けたら、セキュリティの観点で1件出てきた。

同期の通信クライアントには前から「平文の http で、しかもローカル(localhost / 127.0.0.1 / Android エミュレータ用の 10.0.2.2 など)以外へトークンを送ろうとしたら、送る前に止める」門が付いています。設定ミスでトークンを平文で漏らさないための保険です。

ところが、ログイン・取り直し・ログアウトの通信は、この通信クライアントを通らず素の HTTP で別に叩いていました(循環を避けるためにそう分けてありました)。つまり、その門を通っていません。結果、一番機密な refresh トークン(7日有効)と Google のトークンが、設定ミス時には一番無防備に出ていく、という非対称ができていた。短命な同期トークンは守られているのに、長命な refresh は野ざらし、という逆転だ。

refresh は Keychain に置く。門は送る前の判定で、置き場所ではない。同期・アップロードaccess 15分ApiClient門を通っていた門サーバログイン・取り直しログアウトrefresh 7日素の HTTP循環を避けるため別系統門なしサーバ門を1個の関数に寄せて、両方通す置き場所は金庫のまま。進行中の取り直しだけメモリで、保存しない

後回しにもできた(実害が出るのは URL を平文 http の外部に誤設定したときだけだし、その前提は別途置いてある)。でも「一番長生きするトークンが一番無防備」というのがどうしても嫌で、今塞ぐことにした。

dart
// 同期側・認証側の両方が、送る前にこれを通す
void assertTransportSecure(String baseUrl, {required bool hasSecret}) {
  if (!hasSecret) return; // 守る秘密が無いなら素通し
  // https / localhost は許可。平文http × 外部 は StateError で止める(送らない)
}
dart
// 認証の通信にも、同じ門を1行足した
Future<AuthTokens> loginWithGoogle(String accessToken) async {
  assertTransportSecure(_baseUrl, hasSecret: true); // ← ここが抜けていた
  // ... サーバへ POST
}

同じ防御を2箇所で別々に書いていたら、片方にだけ入れて片方を忘れる——というのは起こるべくして起きた。

学びポイント

  • 「ログインを足す」を「トークンを使う全箇所に手を入れる」にしません。 通信が ApiClient という抽象を1点で受けているなら、その裏に小さな契約(AuthSession)を差し込むだけで済みます。既存の同期・アップロードは無改修・無改変のテストで緑のまま渡れました。継ぎ目を1点に絞れるかどうかは、足す前の設計で決まります。
  • トークンは「持つ」より「毎回読む」。 内部に抱えると、ログインや取り直しで変わったときに古いのを掴み続けます。送る直前に読みにいく形なら、変化に自然と追従します。前回の同期回でも「状態は持つより導く」と書いたが、同じ匂いがする。
  • ローテーションする refresh トークンは、同時の取り直しが事故になります。 一斉リクエストでそれぞれが 401 → それぞれが取り直し、だと refresh を二重に使って片方が即死します。取り直しは1回に集約する(単一フライト)必要があります。
  • 同じ防御を2箇所に手書きすると、片方を忘れます。 一番機密なトークンを運ぶ経路ほど、別系統で実装されていて防御から漏れやすいです。判定は1個の関数に寄せて、通す場所を増やす形にします。

おわりに

今回組んだのは土台だけで、ログインボタンはまだ開発用の偽トークンを流すだけの段階だ。本物の Google ログイン(ブラウザでトークンを取る部分)と、その登録作業はこれから。ただ継ぎ目を AuthSession の1点に寄せてあるので、本物にするときに差し替えるのはボタンの中身で、同期や取り直しのロジックには触らずに済む——はずだ。そこは次回、実際に差し替えてみて確かめる。

振り返ると、今回の2つ——トークンの継ぎ目(AuthSession)と、平文送信を止める門(assertTransportSecure)——は、やっていた動きが同じだった。「判断する場所を1個に寄せて、全部そこを通す」。だからトークンは「入れ替え」ではなく「どこを見るかを1個足す」で済んだし、後からレビューが門の抜けを拾ったときも、その1個に認証の経路を通すだけで塞げた。別々の作業に見えて、手は一つだったと後から気づいた。


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