公開日 2026-07-25

Flutterでログイン状態を保持する(JWT + secure storage 最小構成)

Flutterで JWT を secure storage に保存し、ログイン API 呼び出し、起動時復元、ログアウト時の破棄までを最小構成で確認できるようにする。

目次

  1. 1. ゴールと非対象
  2. 対象読者
  3. この記事で到達する状態
  4. 非対象
  5. 2. 先にログイン状態保持の流れを掴む
  6. 3. プロジェクトを作成し、依存を追加する
  7. 3-1. Flutter の環境構築がまだなら先に済ませる
  8. 3-2. Flutter プロジェクトを作成する
  9. 3-3. エミュレーターを起動する
  10. 3-4. パッケージを追加する
  11. 3-5. Android では cleartext HTTP を許可する
  12. 4. モック認証 API を tool/mock_auth_api.dart に作る
  13. コードのポイント
  14. 5. lib/main.dart にログイン保持の最小構成をまとめる
  15. コードのポイント
  16. 6. モック API とアプリを起動して確認する
  17. 7. まとめ

Flutterでgo_routerの認証ガードを実装する(redirect最小構成) の次に詰まりやすいのが、ログイン状態の保存先です。認証ガードの記事では in-memory でログイン状態を持ちましたが、アプリを再起動すると未ログインへ戻ります。この記事では http でログイン API を呼び、受け取った JWT を flutter_secure_storage へ保存し、起動時復元とログアウト時の破棄までを go_router と合わせて最小構成で確認します。

1. ゴールと非対象

対象読者

  • Flutter の環境構築、REST API 通信、go_router の認証ガード、設定保存の入口までは終わっている人
  • JWT を受け取ったあと、どこへ保存してどう復元すればよいかがまだ曖昧な人
  • リフレッシュトークンへ進む前に、まずはログイン状態保持の最小形を手元で動かしたい人

この記事で到達する状態

  • POST /login で受け取った JWT を secure storage へ保存できる
  • アプリ起動時に保存済みトークンを読み、ログイン済み画面へ復元できる
  • GoRouterrefreshListenable を使って、ログイン前後とログアウト後の遷移をまとめられる
  • ログアウト時にトークンを削除し、/login へ戻せる

非対象

  • リフレッシュトークンの再発行
  • JWT の署名検証やクレーム解析
  • Riverpod や Bloc を使った本格的な構成
  • 本番バックエンドや HTTPS 証明書の設定

今回は「JWT を受け取って保存し、起動時に復元する」ところに絞ります。トークン期限切れの扱いは別の段階です。先に、secure storage をどこで読み書きするかを固めます。

2. 先にログイン状態保持の流れを掴む

今回の流れは次の通りです。

flowchart TD
  A[アプリ起動] --> B[SessionController.restoreSession]
  B --> C{secure storage に token があるか}
  C -->|ある| D[ログイン済みとして router を再評価]
  C -->|ない| E["/login を表示"]
  E --> F[メールとパスワードを入力]
  F --> G[POST /login]
  G --> H{200 OK か}
  H -->|はい| I[token を secure storage へ保存]
  I --> D
  H -->|いいえ| J[エラー表示]
  D --> K["/orders を表示"]
  K --> L[ログアウト]
  L --> M[token を削除]
  M --> E

見る場所は 4 つです。

  • ログイン API が token を返すか
  • token を secure storage に保存できたか
  • 起動時に token を読み、ログイン済みへ戻せるか
  • ログアウトで token を削除できたか

Android Emulator からホスト PC のローカル API を叩くときは http://10.0.2.2:8080 を使います。Windows デスクトップ実行や Web なら http://127.0.0.1:8080 を使います。この記事のコードもこの差を baseUrl で吸収する構成です。

ここで保存する JWT は、あくまで「サーバーが返した access token を次回起動まで保持する」ためのものです。期限切れ時にどう更新するかは次の段階で考えます。

3. プロジェクトを作成し、依存を追加する

3-1. Flutter の環境構築がまだなら先に済ませる

環境構築がまだの場合は Windows 11で始めるFlutter開発環境 を先に参照してください。

3-2. Flutter プロジェクトを作成する

次のコマンドでプロジェクトを作成します。

flutter create my_login_state_app
cd my_login_state_app

3-3. エミュレーターを起動する

利用可能なエミュレーター一覧を確認します。

flutter emulators

表示された ID を指定して起動します。

flutter emulators --launch <emulator_id>

3-4. パッケージを追加する

プロジェクト直下で次のコマンドを実行します。

flutter pub add http
flutter pub add flutter_secure_storage
flutter pub add go_router

役割は次の通りです。

  • http: ログイン API を呼ぶ
  • flutter_secure_storage: JWT を通常設定と分けて保存する
  • go_router: ログイン状態に応じて /login/orders を切り替える

3-5. Android では cleartext HTTP を許可する

今回のモック API の URL は http://10.0.2.2:8080 です。Android 9 以降では cleartext HTTP が既定で拒否されるため、android/app/src/main/AndroidManifest.xml<application>android:usesCleartextTraffic="true" を追加します。

<application
  android:label="my_login_state_app"
  android:name="${applicationName}"
  android:icon="@mipmap/ic_launcher"
  android:usesCleartextTraffic="true">

この設定を入れないままログインボタンを押すと、NetworkSecurityException で止まります。

4. モック認証 API を tool/mock_auth_api.dart に作る

まず、JWT を返す最小 API をローカルで用意します。今回は Flutter に含まれる Dart SDK だけで動くよう、tool ディレクトリを作成して tool/mock_auth_api.dart を追加します。

tool/mock_auth_api.dart は次の内容で作成します。

このファイルは、ログイン API の最小往復だけをローカルで再現するモックサーバーです。注目点は、POST /login にだけ応答し、成功時は token と利用者情報、失敗時は 401 を返すことです。

import 'dart:convert';
import 'dart:io';

const String allowedEmail = 'worker@example.com';
const String allowedPassword = 'pass1234';

Future<void> main() async {
  final HttpServer server = await HttpServer.bind(
    InternetAddress.loopbackIPv4,
    8080,
  );

  stdout.writeln('Mock auth API listening on http://127.0.0.1:8080');
  await for (final HttpRequest request in server) {
    request.response.headers.contentType = ContentType.json;
    request.response.headers.add('Access-Control-Allow-Origin', '*');
    request.response.headers.add('Access-Control-Allow-Headers', 'Content-Type');
    request.response.headers.add('Access-Control-Allow-Methods', 'POST, OPTIONS');

    if (request.method == 'OPTIONS') {
      request.response.statusCode = HttpStatus.noContent;
      await request.response.close();
      continue;
    }

    if (request.method == 'POST' && request.uri.path == '/login') {
      await _handleLogin(request);
      continue;
    }

    request.response.statusCode = HttpStatus.notFound;
    request.response.write(
      jsonEncode(<String, dynamic>{
        'message': 'Not Found',
      }),
    );
    await request.response.close();
  }
}

Future<void> _handleLogin(HttpRequest request) async {
  final String body = await utf8.decoder.bind(request).join();
  final Map<String, dynamic> json = jsonDecode(body) as Map<String, dynamic>;
  final String email = json['email']?.toString() ?? '';
  final String password = json['password']?.toString() ?? '';

  if (email != allowedEmail || password != allowedPassword) {
    request.response.statusCode = HttpStatus.unauthorized;
    request.response.write(
      jsonEncode(<String, dynamic>{
        'message': 'メールアドレスまたはパスワードが違います。',
      }),
    );
    await request.response.close();
    return;
  }

  request.response.statusCode = HttpStatus.ok;
  request.response.write(
    jsonEncode(<String, dynamic>{
      'access_token': 'header.payload.signature-demo-token',
      'user': <String, dynamic>{
        'name': '倉庫担当A',
        'email': allowedEmail,
      },
    }),
  );
  await request.response.close();
}

コードのポイント

① 受け付ける導線を POST /login に絞っている

  await for (final HttpRequest request in server) {
    request.response.headers.contentType = ContentType.json;
    request.response.headers.add('Access-Control-Allow-Origin', '*');
    request.response.headers.add('Access-Control-Allow-Headers', 'Content-Type');
    request.response.headers.add('Access-Control-Allow-Methods', 'POST, OPTIONS');

    if (request.method == 'OPTIONS') {
      request.response.statusCode = HttpStatus.noContent;
      await request.response.close();
      continue;
    }

    if (request.method == 'POST' && request.uri.path == '/login') {
      await _handleLogin(request);
      continue;
    }

この記事で確認したいのはログイン往復だけなので、受け口を POST /login に限定しています。ルーティングを増やさないことで、Flutter 側の保存ロジックと API 側の責務を切り分けやすくしています。

② 認証失敗時は 401 とメッセージを返す

  if (email != allowedEmail || password != allowedPassword) {
    request.response.statusCode = HttpStatus.unauthorized;
    request.response.write(
      jsonEncode(<String, dynamic>{
        'message': 'メールアドレスまたはパスワードが違います。',
      }),
    );
    await request.response.close();
    return;
  }

失敗時に 401 とメッセージを返しているため、Flutter 側では資格情報エラーを通信失敗と分けて扱えます。ログイン API として最低限ほしい分岐だけを先に固定している形です。

③ 認証成功時だけ token と user を返す

  request.response.statusCode = HttpStatus.ok;
  request.response.write(
    jsonEncode(<String, dynamic>{
      'access_token': 'header.payload.signature-demo-token',
      'user': <String, dynamic>{
        'name': '倉庫担当A',
        'email': allowedEmail,
      },
    }),

成功時に返すのは疑似的な JWT と利用者情報だけで、署名検証や有効期限判定は扱っていません。この記事では「サーバーが token を返す」「Flutter が保存する」という往復だけを再現し、失敗時は 401 とメッセージで切り分ける構成です。

5. lib/main.dart にログイン保持の最小構成をまとめる

続いて、Flutter 側で token を保存し、起動時に復元する処理を lib/main.dart にまとめます。

flutter create で生成された lib/main.dart は、いったん次の内容に書き換えます。

このファイルは、ログイン状態の復元、認証 API 呼び出し、secure storage への保存、go_router による画面切り替えを 1 本で確認するサンプルです。注目点は、SessionController を中心に router と保存処理を連動させていることです。

import 'dart:convert';

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:go_router/go_router.dart';
import 'package:http/http.dart' as http;

void main() {
  runApp(MyApp(sessionController: SessionController()));
}

class MyApp extends StatefulWidget {
  const MyApp({super.key, required this.sessionController});

  final SessionController sessionController;

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  late final GoRouter _router = GoRouter(
    initialLocation: '/boot',
    refreshListenable: widget.sessionController,
    redirect: (BuildContext context, GoRouterState state) {
      final SessionStatus status = widget.sessionController.status;
      final bool isBootRoute = state.matchedLocation == '/boot';
      final bool isLoginRoute = state.matchedLocation == '/login';

      if (status == SessionStatus.unknown) {
        return isBootRoute ? null : '/boot';
      }

      if (status != SessionStatus.unknown && isBootRoute) {
        return status == SessionStatus.authenticated ? '/orders' : '/login';
      }

      if (status == SessionStatus.unauthenticated && !isLoginRoute) {
        return Uri(
          path: '/login',
          queryParameters: <String, String>{
            'from': state.uri.toString(),
          },
        ).toString();
      }

      if (status == SessionStatus.authenticated && isLoginRoute) {
        return _safeFrom(state.uri.queryParameters['from']);
      }

      return null;
    },
    routes: <RouteBase>[
      GoRoute(
        path: '/boot',
        builder: (BuildContext context, GoRouterState state) {
          return const BootPage();
        },
      ),
      GoRoute(
        path: '/login',
        builder: (BuildContext context, GoRouterState state) {
          final String from = _safeFrom(state.uri.queryParameters['from']);
          return LoginPage(
            sessionController: widget.sessionController,
            from: from,
          );
        },
      ),
      GoRoute(
        path: '/orders',
        builder: (BuildContext context, GoRouterState state) {
          return OrdersPage(sessionController: widget.sessionController);
        },
      ),
    ],
  );

  @override
  void initState() {
    super.initState();
    widget.sessionController.restoreSession();
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'JWT login state demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
      ),
      routerConfig: _router,
    );
  }
}

String _safeFrom(String? from) {
  if (from == null || from.isEmpty || from == '/login') {
    return '/orders';
  }
  return from;
}

enum SessionStatus {
  unknown,
  unauthenticated,
  authenticated,
}

class LoginResult {
  const LoginResult({
    required this.accessToken,
    required this.userName,
    required this.email,
  });

  final String accessToken;
  final String userName;
  final String email;

  factory LoginResult.fromJson(Map<String, dynamic> json) {
    final Map<String, dynamic> user = json['user'] as Map<String, dynamic>;

    return LoginResult(
      accessToken: json['access_token'].toString(),
      userName: user['name'].toString(),
      email: user['email'].toString(),
    );
  }
}

class AuthApiClient {
  AuthApiClient({http.Client? client}) : _client = client ?? http.Client();

  final http.Client _client;

  String get baseUrl {
    if (kIsWeb) {
      return 'http://127.0.0.1:8080';
    }
    if (defaultTargetPlatform == TargetPlatform.android) {
      return 'http://10.0.2.2:8080';
    }
    return 'http://127.0.0.1:8080';
  }

  Future<LoginResult> login({
    required String email,
    required String password,
  }) async {
    final http.Response response = await _client
        .post(
          Uri.parse('$baseUrl/login'),
          headers: <String, String>{
            'Content-Type': 'application/json',
          },
          body: jsonEncode(<String, String>{
            'email': email,
            'password': password,
          }),
        )
        .timeout(const Duration(seconds: 5));

    final Map<String, dynamic> json =
        jsonDecode(response.body) as Map<String, dynamic>;

    if (response.statusCode != 200) {
      throw AuthException(json['message']?.toString() ?? 'ログインに失敗しました。');
    }

    return LoginResult.fromJson(json);
  }
}

class AuthException implements Exception {
  const AuthException(this.message);

  final String message;

  @override
  String toString() => message;
}

class TokenStore {
  TokenStore({FlutterSecureStorage? storage})
      : _storage = storage ?? const FlutterSecureStorage();

  static const String accessTokenKey = 'auth.accessToken';
  static const String userNameKey = 'auth.userName';
  static const String emailKey = 'auth.email';

  final FlutterSecureStorage _storage;

  Future<void> save(LoginResult result) async {
    await _storage.write(key: accessTokenKey, value: result.accessToken);
    await _storage.write(key: userNameKey, value: result.userName);
    await _storage.write(key: emailKey, value: result.email);
  }

  Future<SavedSession?> load() async {
    final String? accessToken = await _storage.read(key: accessTokenKey);
    if (accessToken == null || accessToken.isEmpty) {
      return null;
    }

    final String userName = await _storage.read(key: userNameKey) ?? '担当者';
    final String email = await _storage.read(key: emailKey) ?? '';

    return SavedSession(
      accessToken: accessToken,
      userName: userName,
      email: email,
    );
  }

  Future<void> clear() async {
    await _storage.delete(key: accessTokenKey);
    await _storage.delete(key: userNameKey);
    await _storage.delete(key: emailKey);
  }
}

class SavedSession {
  const SavedSession({
    required this.accessToken,
    required this.userName,
    required this.email,
  });

  final String accessToken;
  final String userName;
  final String email;
}

class SessionController extends ChangeNotifier {
  SessionController({AuthApiClient? apiClient, TokenStore? tokenStore})
      : _apiClient = apiClient ?? AuthApiClient(),
        _tokenStore = tokenStore ?? TokenStore();

  final AuthApiClient _apiClient;
  final TokenStore _tokenStore;

  SessionStatus _status = SessionStatus.unknown;
  SavedSession? _session;
  bool _isBusy = false;

  SessionStatus get status => _status;
  SavedSession? get session => _session;
  bool get isBusy => _isBusy;

  Future<void> restoreSession() async {
    final SavedSession? savedSession = await _tokenStore.load();
    _session = savedSession;
    _status = savedSession == null
        ? SessionStatus.unauthenticated
        : SessionStatus.authenticated;
    notifyListeners();
  }

  Future<void> login({
    required String email,
    required String password,
  }) async {
    _isBusy = true;
    notifyListeners();

    try {
      final LoginResult result = await _apiClient.login(
        email: email,
        password: password,
      );
      await _tokenStore.save(result);
      _session = SavedSession(
        accessToken: result.accessToken,
        userName: result.userName,
        email: result.email,
      );
      _status = SessionStatus.authenticated;
    } finally {
      _isBusy = false;
      notifyListeners();
    }
  }

  Future<void> logout() async {
    _isBusy = true;
    notifyListeners();

    try {
      await _tokenStore.clear();
      _session = null;
      _status = SessionStatus.unauthenticated;
    } finally {
      _isBusy = false;
      notifyListeners();
    }
  }
}

class BootPage extends StatelessWidget {
  const BootPage({super.key});

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            CircularProgressIndicator(),
            SizedBox(height: 16),
            Text('保存済みセッションを確認しています...'),
          ],
        ),
      ),
    );
  }
}

class LoginPage extends StatefulWidget {
  const LoginPage({
    super.key,
    required this.sessionController,
    required this.from,
  });

  final SessionController sessionController;
  final String from;

  @override
  State<LoginPage> createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
  final TextEditingController _emailController =
      TextEditingController(text: 'worker@example.com');
  final TextEditingController _passwordController =
      TextEditingController(text: 'pass1234');
  String? _errorMessage;

  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    super.dispose();
  }

  Future<void> _submit() async {
    setState(() {
      _errorMessage = null;
    });

    try {
      await widget.sessionController.login(
        email: _emailController.text.trim(),
        password: _passwordController.text,
      );

      if (!mounted) {
        return;
      }

      context.go(widget.from);
    } on AuthException catch (error) {
      setState(() {
        _errorMessage = error.message;
      });
    } catch (error) {
      setState(() {
        _errorMessage = '接続に失敗しました。詳細: $error';
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    final bool isBusy = widget.sessionController.isBusy;

    return Scaffold(
      appBar: AppBar(title: const Text('ログイン')),
      body: Center(
        child: ConstrainedBox(
          constraints: const BoxConstraints(maxWidth: 420),
          child: ListView(
            padding: const EdgeInsets.all(24),
            children: <Widget>[
              Text(
                'JWT を受け取り、secure storage に保存します。',
                style: Theme.of(context).textTheme.titleMedium,
              ),
              const SizedBox(height: 8),
              const Text(
                'モック API の初期値は worker@example.com / pass1234 です。',
              ),
              const SizedBox(height: 24),
              TextField(
                controller: _emailController,
                keyboardType: TextInputType.emailAddress,
                decoration: const InputDecoration(
                  labelText: 'メールアドレス',
                  border: OutlineInputBorder(),
                ),
              ),
              const SizedBox(height: 16),
              TextField(
                controller: _passwordController,
                obscureText: true,
                decoration: const InputDecoration(
                  labelText: 'パスワード',
                  border: OutlineInputBorder(),
                ),
              ),
              const SizedBox(height: 16),
              if (_errorMessage != null)
                Padding(
                  padding: const EdgeInsets.only(bottom: 12),
                  child: Text(
                    _errorMessage!,
                    style: TextStyle(
                      color: Theme.of(context).colorScheme.error,
                    ),
                  ),
                ),
              FilledButton.icon(
                onPressed: isBusy ? null : _submit,
                icon: isBusy
                    ? const SizedBox(
                        width: 18,
                        height: 18,
                        child: CircularProgressIndicator(strokeWidth: 2),
                      )
                    : const Icon(Icons.login),
                label: const Text('ログインする'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class OrdersPage extends StatelessWidget {
  const OrdersPage({super.key, required this.sessionController});

  final SessionController sessionController;

  @override
  Widget build(BuildContext context) {
    final SavedSession? session = sessionController.session;
    final String token = session?.accessToken ?? '';
    final String tokenSummary =
        token.length <= 18 ? token : '${token.substring(0, 9)} ... ${token.substring(token.length - 6)}';

    return Scaffold(
      appBar: AppBar(
        title: const Text('注文一覧'),
        actions: <Widget>[
          TextButton(
            onPressed: sessionController.isBusy
                ? null
                : () async {
                    await sessionController.logout();
                    if (context.mounted) {
                      context.go('/login');
                    }
                  },
            child: const Text('ログアウト'),
          ),
        ],
      ),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: <Widget>[
          Card(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Text(
                    '保存済みセッション',
                    style: Theme.of(context).textTheme.titleMedium,
                  ),
                  const SizedBox(height: 8),
                  Text('担当者: ${session?.userName ?? '-'}'),
                  Text('メール: ${session?.email ?? '-'}'),
                  Text('token: $tokenSummary'),
                  const SizedBox(height: 8),
                  const Text(
                    'ここに token の全体を出す必要はありません。今回は保存済みであることだけ確認します。',
                  ),
                ],
              ),
            ),
          ),
          const SizedBox(height: 16),
          const _OrderCard(
            orderNo: 'SO-1001',
            customer: '東京商事',
            status: '出荷待ち',
          ),
          const SizedBox(height: 12),
          const _OrderCard(
            orderNo: 'SO-1002',
            customer: '大阪物産',
            status: 'ピッキング中',
          ),
        ],
      ),
    );
  }
}

class _OrderCard extends StatelessWidget {
  const _OrderCard({
    required this.orderNo,
    required this.customer,
    required this.status,
  });

  final String orderNo;
  final String customer;
  final String status;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: ListTile(
        title: Text(orderNo),
        subtitle: Text(customer),
        trailing: Text(status),
      ),
    );
  }
}

コードのポイント

① router の redirect がログイン状態に応じて遷移先を決める

class _MyAppState extends State<MyApp> {
  late final GoRouter _router = GoRouter(
    initialLocation: '/boot',
    refreshListenable: widget.sessionController,
    redirect: (BuildContext context, GoRouterState state) {
      final SessionStatus status = widget.sessionController.status;
      final bool isBootRoute = state.matchedLocation == '/boot';
      final bool isLoginRoute = state.matchedLocation == '/login';

      if (status == SessionStatus.unknown) {
        return isBootRoute ? null : '/boot';
      }

      if (status == SessionStatus.unauthenticated && !isLoginRoute) {
        return Uri(
          path: '/login',
          queryParameters: <String, String>{
            'from': state.uri.toString(),
          },
        ).toString();
      }

refreshListenableSessionController を渡すと、ログイン状態が変わった直後に router が再評価されます。redirect で見るのは /boot/login/orders の 3 状態だけです。画面側に認証分岐を散らさずに済みます。

SessionController が復元、保存、破棄を一元化する

class SessionController extends ChangeNotifier {
  SessionController({AuthApiClient? apiClient, TokenStore? tokenStore})
      : _apiClient = apiClient ?? AuthApiClient(),
        _tokenStore = tokenStore ?? TokenStore();

  final AuthApiClient _apiClient;
  final TokenStore _tokenStore;

  SessionStatus _status = SessionStatus.unknown;
  SavedSession? _session;
  bool _isBusy = false;

  Future<void> restoreSession() async {
    final SavedSession? savedSession = await _tokenStore.load();
    _session = savedSession;
    _status = savedSession == null
        ? SessionStatus.unauthenticated
        : SessionStatus.authenticated;
    notifyListeners();
  }

  Future<void> login({
    required String email,
    required String password,
  }) async {
    _isBusy = true;
    notifyListeners();

    try {
      final LoginResult result = await _apiClient.login(
        email: email,
        password: password,
      );
    await _tokenStore.save(result);

restoreSession() を起動直後に呼べば、secure storage の token 有無から SessionStatus を決められます。login() は保存成功後に authenticated へ切り替え、logout() は token を削除して router が /login へ戻れる状態を作ります。認証状態の更新点を 1 か所に寄せられる構成です。

TokenStore は token だけでなく、画面表示に使う利用者名とメールも一緒に保存しています。厳密には user profile を毎回 API から取り直す設計もあります。ただ、最小構成では「復元後に画面へ出す最低限の要約」を一緒に持つほうが挙動を追いやすくなります。

6. モック API とアプリを起動して確認する

まず 1 つ目のターミナルでモック API を起動します。

dart run tool/mock_auth_api.dart
モック認証 API の起動結果

続いて 2 つ目のターミナルでアプリを起動します。

flutter run

初回起動では /login が表示されます。ここではモック API の初期値である worker@example.com / pass1234 を入力した状態を載せます。

初回起動時のログイン画面

確認手順は次の順です。

  1. 初回起動で /login が表示されることを確認する
  2. worker@example.com / pass1234 でログインし、注文一覧へ進むことを確認する
  3. アプリをいったん終了して再度起動し、ログイン画面ではなく注文一覧へ戻ることを確認する
  4. ログアウトし、/login へ戻ったあと再起動しても未ログインのままであることを確認する

ログインに成功すると、注文一覧の先頭に保存済みセッションカードが表示されます。担当者名、メール、token の要約が見えていれば、保存と復元の流れを画面上でも追えます。

ログイン後の注文一覧画面

再起動前後の注文一覧と、ログアウト後に戻る /login 画面は見た目が同じです。そこでスクリーンショットは代表 2 画面に絞り、再起動の確認は本文で補います。アプリをいったん終了して再起動しても、secure storage に保存した token を起動時に読み直すため、注文一覧へ戻ります。ログアウト後は token を削除しているため、再起動しても注文一覧へは戻らず、未ログイン状態のまま /login が表示されます。

誤った資格情報を入れた場合は 401 が返り、ログイン画面にエラーメッセージが残ります。ここまで確認できれば、保存、復元、破棄の 3 点は揃っています。

7. まとめ

JWT を受け取って secure storage に保存し、起動時に復元してログイン状態を保つ最小構成です。この構成では、秘密値を通常設定と分けて保存し、起動時復元とログアウト時の破棄を SessionController に集約できます。

次は、期限切れ時の自動再試行やリフレッシュトークンへ進む流れです。go_router の route 分岐だけを先に整理したい場合は Flutterでgo_routerの認証ガードを実装する(redirect最小構成) を、秘密値と通常設定の保存先を整理し直したい場合は Flutterで端末設定と利用者設定を保存する(SharedPreferencesとsecure storageの使い分け) を合わせて読むと流れがつながります。

シリーズ 24/38

このシリーズ

Flutter導入と基礎

  1. 1. Windows 11で始めるFlutter開発環境:Android Emulatorで動かすまで
  2. 2. Flutter + FVM で開発環境のバージョンを固定する
  3. 3. Flutterで画像・SVG・アイコンを管理する(flutter_gen最小構成)
  4. 4. Flutterで最初に詰まりやすいDartの書き方:final・const・null safety・async/await を最初に整理する
  5. 5. DartのStream入門(非同期データの流れをつかむ)
  6. 6. FlutterのWidgetライフサイクル入門(initState / dispose で詰まらないために)
  7. 7. FlutterでBuildContextとKeyを理解する
  8. 8. Flutterのレイアウト入門(Column / Row / Stack の使い分け)
  9. 9. Flutterのテーマ設計入門(ThemeData + Theme Extension)
  10. 10. FlutterでMediaQueryとLayoutBuilderを使って画面サイズに対応する(スマホ・タブレット両対応)
  11. 11. FlutterのContainerとSizedBoxを使いこなす(余白・サイズ・装飾の基本)
  12. 12. FlutterのListViewとGridViewで一覧画面を作る(基本パターン)
  13. 13. Flutterのダイアログ・スナックバー・ボトムシートを使う(確認・通知UIの基本)
  14. 14. FlutterのTabBarとBottomNavigationBarで複数画面を切り替える
  15. 15. Flutterでカスタムウィジェットを作る入門(StatelessWidget の分割と再利用)
  16. 16. Flutterのルーティング入門(Navigator と go_router の使い分け)
  17. 17. FlutterからREST APIを呼ぶ最小構成(JSON通信 + エラー処理)
  18. 18. Flutterでjson_serializable + build_runnerを使ってJSONモデルを型安全に扱う
  19. 19. Flutterの状態管理入門(Riverpod最小構成)
  20. 20. Flutterでローディング・空状態・エラー表示を整える
  21. 21. Dart 3のsealed classとパターンマッチングで分岐を安全に書く
  22. 22. Flutterでgo_routerの認証ガードを実装する(redirect最小構成)
  23. 23. Flutterで端末設定と利用者設定を保存する(SharedPreferencesとsecure storageの使い分け)
  24. 24. Flutterでログイン状態を保持する(JWT + secure storage 最小構成) 現在の記事
  25. 25. Flutterアプリを日本語化する(l10n + arb 最小構成)
  26. 26. Flutterで業務用バーコード読み取りアプリを作る(最小構成)
  27. 27. Flutterでスキャン入力を受けて処理する
  28. 28. FlutterでGS1-128バーコードを解析する
  29. 29. Flutterで単一画面の入力フローを作る
  30. 30. Flutterで複数画像の添付UIを作る
  31. 31. Flutterでデータをファイルに書き出す
  32. 32. permission_handler でAndroid権限を実践的に扱う(カメラ・ストレージ・Bluetooth)
  33. 33. Flutterアプリのネイティブ設定を整える(アプリ名 / アイコン / スプラッシュ / 署名)
  34. 34. Flutterの環境切替と配布前チェック(flavor / release build / 権限確認)
  35. 35. FlutterのWidgetテスト入門(画面ロジックを壊さない最小構成)
  36. 36. FlutterのIntegration Test入門(ログインから一覧表示まで確認する)
  37. 37. Flutterアプリを社内配布する(Android APK サイドロード + MDM 概要)
  38. 38. Sentryでクラッシュとエラーを検知する(Flutter最小構成)