import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'models.dart'; /// One signed-in session: the two tokens, when the access token dies, and who /// the customer is. @immutable class Session { const Session({ required this.accessToken, required this.refreshToken, required this.expiresAt, this.customer, }); final String accessToken; final String refreshToken; /// Absolute expiry, computed from the `expiresIn` the server sent. final DateTime expiresAt; final Customer? customer; /// Treated as expired a minute early, so a call started just before the /// boundary does not land just after it. bool get isExpired => DateTime.now().isAfter(expiresAt.subtract(const Duration(minutes: 1))); Session copyWith({ String? accessToken, String? refreshToken, DateTime? expiresAt, Customer? customer, }) => Session( accessToken: accessToken ?? this.accessToken, refreshToken: refreshToken ?? this.refreshToken, expiresAt: expiresAt ?? this.expiresAt, customer: customer ?? this.customer, ); Map toJson() => { 'accessToken': accessToken, 'refreshToken': refreshToken, 'expiresAt': expiresAt.millisecondsSinceEpoch, if (customer != null) 'customer': customer!.toJson(), }; static Session? fromJson(Map json) { final access = json['accessToken'] as String?; final refresh = json['refreshToken'] as String?; final expires = json['expiresAt']; if (access == null || access.isEmpty) return null; if (refresh == null || refresh.isEmpty) return null; return Session( accessToken: access, refreshToken: refresh, expiresAt: expires is int ? DateTime.fromMillisecondsSinceEpoch(expires) : DateTime.now(), customer: json['customer'] is Map ? Customer.fromJson(json['customer'] as Map) : null, ); } } /// Where the session lives between launches. /// /// The refresh token is valid for 60 days and **rotates on every use** — a /// replayed one revokes the whole chain server-side. So the two rules this /// class exists to enforce are: persist the newest token the moment it arrives, /// and never hand out a half-written session. abstract class SessionStore { Future read(); Future write(Session session); Future clear(); } /// Keychain on iOS, EncryptedSharedPreferences on Android. class SecureSessionStore implements SessionStore { SecureSessionStore({FlutterSecureStorage? storage}) // Keychain on iOS by default; Android needs asking, or it falls back to a // plaintext preferences file — which is the one place a 60-day refresh // token must never sit. : _storage = storage ?? const FlutterSecureStorage( aOptions: AndroidOptions(encryptedSharedPreferences: true), ); final FlutterSecureStorage _storage; static const String _key = 'dm_cx_session_v1'; @override Future read() async { try { final raw = await _storage.read(key: _key); if (raw == null || raw.isEmpty) return null; final decoded = jsonDecode(raw); if (decoded is! Map) return null; return Session.fromJson(decoded); } catch (e) { // A session we cannot read is a session we do not have. Signing the // customer out is recoverable; crashing at launch is not. debugPrint('[SESSION] unreadable, treating as signed out: $e'); return null; } } @override Future write(Session session) async { try { await _storage.write(key: _key, value: jsonEncode(session.toJson())); } catch (e) { // Losing persistence costs the customer a re-login next launch. It must // not cost them the sign-in they just completed. debugPrint('[SESSION] could not persist: $e'); } } @override Future clear() async { try { await _storage.delete(key: _key); } catch (e) { debugPrint('[SESSION] could not clear: $e'); } } } /// In-memory store. Tests only — a session that does not survive the process /// is not a session. class MemorySessionStore implements SessionStore { Session? _session; @override Future read() async => _session; @override Future write(Session session) async => _session = session; @override Future clear() async => _session = null; }