モバイルからサーバへ綴りを同期する管を作っている連載の続き。前回「次はログインを本物にする」と書いた、その土台(client core)を組んだ記録です。
ここで言う「web」は、綴ったものを web でも見られるように開発中のアプリ(紡Book)のことです。
綴Photo は、モバイルアプリと、それを見せる web、間をつなぐ backend でできています。同期の管はもう通っていて、選んだ写真だけサーバに上がる所まで動きます。ただログインがまだ開発用で、--dart-define(アプリを組み立てる=ビルドする時に値を埋め込む仕組み)で手発行したトークンを、組み立て時に一度きり焼き込んでいました。これだとストアに出すアプリには使えません。本物のログインに置き換えたい。
問題は、同期もアップロードももう動いていること。そこを壊さずにログインだけ足したい。今回はそのための土台を組んだ。
トークンを「焼き込む」のをやめて、毎回読みにいく
旧実装は、固定のトークンを通信クライアントに一度だけ渡していました。
// 旧: ビルド時に決まったトークンを1回渡すだけ。ログインしても変わらない
ApiClientImpl(baseUrl: ..., token: AppConfig.jwtToken);これだと、ログインしてトークンが変わっても、通信クライアントは古いトークンを握ったままになります。本物ログインは「ログイン前は未認証、ログイン後は認証済み、しばらくすると切れて取り直す」とトークンが動く前提なので、固定で持つのと相性が悪いです。
なので「リクエストを送るたびに、今のトークンを読みにいく」形にした。
// 毎回呼ばれる。ログイン済みなら今の 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個差し込む。
差し込む契約はこれだけにした。
// 通信層が認証について知る、たった一つの窓口
abstract class AuthSession {
Future<String?> currentAccessToken(); // 今のトークン(無ければ null)
Future<bool> tryRefresh(); // 401 のとき取り直す。成功で true
Future<void> logout(); // 破棄して未認証へ
}通信クライアントは、この契約を任意で受け取る形にした。
ApiClientImpl({
required String baseUrl,
String token = '', // 旧来の固定トークン(後方互換で残す)
AuthSession? authSession, // ← これが居れば毎回トークンを読みにいく
http.Client? client,
});authSession を渡さなければ、今までどおり固定トークンで動きます。渡せば毎回読みます。既存の同期・アップロードのコードは1行も変えていません。実際、これらの既存テストは1文字も書き換えずに緑のままでした。
「ログインを足す」と聞くと、トークンを使う全部の箇所に手を入れる絵を想像しますけど、抽象を1点に絞っておくと、差し込むのはその1点で済みます。どこを継ぎ目にするか、をここで決めた。
401 が返ってきたら、一回だけ自動で取り直す
access トークンは短命(15分)で、切れるとサーバは 401 を返します。切れるたびにログイン画面へ飛ばすのは雑なので、「401 が来たら裏で取り直して、同じリクエストをもう一度だけ送る」を通信クライアントに入れた。
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回にまとめる」を入れた。誰かが取り直し中なら、後から来た人はその進行中の取り直しに相乗りして、同じ結果を待ちます。
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 は野ざらし、という逆転だ。
後回しにもできた(実害が出るのは URL を平文 http の外部に誤設定したときだけだし、その前提は別途置いてある)。でも「一番長生きするトークンが一番無防備」というのがどうしても嫌で、今塞ぐことにした。
// 同期側・認証側の両方が、送る前にこれを通す
void assertTransportSecure(String baseUrl, {required bool hasSecret}) {
if (!hasSecret) return; // 守る秘密が無いなら素通し
// https / localhost は許可。平文http × 外部 は StateError で止める(送らない)
}// 認証の通信にも、同じ門を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月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。