diff --git a/lib/core/consts.dart b/lib/core/consts.dart index e5d41312c..92c82b5a9 100644 --- a/lib/core/consts.dart +++ b/lib/core/consts.dart @@ -54,6 +54,8 @@ const SUBMIT_BUTTON_KEY_NAME = 'submit-button'; /// Local Preferences keys const PREFS_INGREDIENTS = 'ingredientData'; const PREFS_WORKOUT_UNITS = 'workoutUnits'; + +/// Pre-JWT credential blob. Only still read to delete it on startup. const PREFS_USER = 'userData'; const PREFS_USER_DARK_THEME = 'userDarkMode'; const PREFS_USER_LOCALE = 'userLocale'; @@ -69,15 +71,10 @@ const PREFS_LAST_SERVER = 'lastServer'; const PREFS_USE_DYNAMIC_COLOR = 'useDynamicColor'; const USE_DYNAMIC_COLOR_DEFAULT = false; -/// Headless JWT auth: SharedPreferences keys. -/// -/// Read in parallel with the legacy `PREFS_USER` blob during the migration -/// window; once a user logs in via the headless flow these supersede it. -/// The refresh token is **not** stored here, it lives in secure storage -/// (`SECURE_STORAGE_REFRESH_TOKEN`). +/// Headless JWT auth: SharedPreferences keys. The refresh token is **not** +/// stored here, it lives in secure storage (`SECURE_STORAGE_REFRESH_TOKEN`). const PREFS_ACCESS_TOKEN = 'accessToken'; const PREFS_ACCESS_EXPIRES_AT = 'accessExpiresAt'; -const PREFS_TOKEN_TYPE = 'tokenType'; const PREFS_SERVER_URL = 'serverUrl'; /// JWT `sub` of the user whose data is materialised in the local PowerSync diff --git a/lib/core/network/auth_credential.dart b/lib/core/network/auth_credential.dart index 2da61a338..b667adb4b 100644 --- a/lib/core/network/auth_credential.dart +++ b/lib/core/network/auth_credential.dart @@ -21,54 +21,32 @@ import 'package:wger/core/network/jwt.dart'; part 'auth_credential.freezed.dart'; -/// Sealed credential carried inside `AuthState` for every authenticated -/// caller. Branch via `switch` or `is` to access the variant-specific -/// fields; the two getters below cover everything the HTTP layer needs. +/// Credential carried inside `AuthState` for every authenticated caller: a +/// short-lived access token from `allauth.headless`, sent as +/// `Authorization: Bearer `. The refresh token is not part of it, it +/// lives in secure storage. /// -/// The app supports two flavours during the migration to `allauth.headless`: -/// -/// - [LegacyCredential] — permanent DRF token from `/api/v2/login/`, sent -/// as `Authorization: Token `. This is what existing installs are -/// running until they re-authenticate on a build that ships the JWT flow. -/// - [JwtCredential] — short-lived access token from `allauth.headless`, -/// sent as `Authorization: Bearer `. The refresh token lives in -/// secure storage, not on the credential itself. -/// -/// New logins (login, signup, MFA completion, pasted-refresh exchange) -/// always produce a [JwtCredential]. +/// Every entry point (login, signup, MFA completion, pasted-refresh +/// exchange, auto-login from storage) produces one of these. @freezed -sealed class AuthCredential with _$AuthCredential { - const factory AuthCredential.legacy(String token) = LegacyCredential; - - const factory AuthCredential.jwt({ +abstract class JwtCredential with _$JwtCredential { + const factory JwtCredential({ required String accessToken, DateTime? expiresAt, - }) = JwtCredential; + }) = _JwtCredential; - const AuthCredential._(); + const JwtCredential._(); /// `Authorization` header value for outgoing authenticated requests. - String get authHeaderValue => switch (this) { - LegacyCredential(:final token) => 'Token $token', - JwtCredential(:final accessToken) => 'Bearer $accessToken', - }; + String get authHeaderValue => 'Bearer $accessToken'; - /// True when this credential is a JWT whose expiry falls within [leeway] - /// of now (or is already past). Always false for [LegacyCredential]: - /// permanent DRF tokens have no expiry and are never refreshed. - bool needsRefresh(Duration leeway) => switch (this) { - LegacyCredential() => false, - JwtCredential(:final expiresAt) => - expiresAt != null && expiresAt.isBefore(DateTime.now().toUtc().add(leeway)), - }; + /// True when the expiry falls within [leeway] of now, or is already past. + /// False when the token carries no expiry at all. + bool needsRefresh(Duration leeway) => + expiresAt != null && expiresAt!.isBefore(DateTime.now().toUtc().add(leeway)); - /// User identifier carried by the credential. For [JwtCredential] this is - /// the JWT `sub` claim (decoded on every call, so callers should not - /// hammer it in tight loops). Null for [LegacyCredential]: permanent DRF - /// tokens don't expose the user-id and the app discovers it lazily via - /// the user-profile endpoint instead. - String? get userId => switch (this) { - LegacyCredential() => null, - JwtCredential(:final accessToken) => decodeJwtPayload(accessToken)?['sub']?.toString(), - }; + /// User identifier carried by the token: its `sub` claim. Decoded on + /// every call, so callers should not hammer it in tight loops. Null when + /// the token isn't decodable or carries no `sub`. + String? get userId => decodeJwtPayload(accessToken)?['sub']?.toString(); } diff --git a/lib/core/network/auth_credential.freezed.dart b/lib/core/network/auth_credential.freezed.dart index 38306d738..507d8e21a 100644 --- a/lib/core/network/auth_credential.freezed.dart +++ b/lib/core/network/auth_credential.freezed.dart @@ -12,37 +12,69 @@ part of 'auth_credential.dart'; // dart format off T _$identity(T value) => value; /// @nodoc -mixin _$AuthCredential { - +mixin _$JwtCredential { + String get accessToken; DateTime? get expiresAt; +/// Create a copy of JwtCredential +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$JwtCredentialCopyWith get copyWith => _$JwtCredentialCopyWithImpl(this as JwtCredential, _$identity); @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is AuthCredential); + return identical(this, other) || (other.runtimeType == runtimeType&&other is JwtCredential&&(identical(other.accessToken, accessToken) || other.accessToken == accessToken)&&(identical(other.expiresAt, expiresAt) || other.expiresAt == expiresAt)); } @override -int get hashCode => runtimeType.hashCode; +int get hashCode => Object.hash(runtimeType,accessToken,expiresAt); @override String toString() { - return 'AuthCredential()'; + return 'JwtCredential(accessToken: $accessToken, expiresAt: $expiresAt)'; } } /// @nodoc -class $AuthCredentialCopyWith<$Res> { -$AuthCredentialCopyWith(AuthCredential _, $Res Function(AuthCredential) __); +abstract mixin class $JwtCredentialCopyWith<$Res> { + factory $JwtCredentialCopyWith(JwtCredential value, $Res Function(JwtCredential) _then) = _$JwtCredentialCopyWithImpl; +@useResult +$Res call({ + String accessToken, DateTime? expiresAt +}); + + + + } +/// @nodoc +class _$JwtCredentialCopyWithImpl<$Res> + implements $JwtCredentialCopyWith<$Res> { + _$JwtCredentialCopyWithImpl(this._self, this._then); + final JwtCredential _self; + final $Res Function(JwtCredential) _then; -/// Adds pattern-matching-related methods to [AuthCredential]. -extension AuthCredentialPatterns on AuthCredential { +/// Create a copy of JwtCredential +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? accessToken = null,Object? expiresAt = freezed,}) { + return _then(_self.copyWith( +accessToken: null == accessToken ? _self.accessToken : accessToken // ignore: cast_nullable_to_non_nullable +as String,expiresAt: freezed == expiresAt ? _self.expiresAt : expiresAt // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [JwtCredential]. +extension JwtCredentialPatterns on JwtCredential { /// A variant of `map` that fallback to returning `orElse`. /// /// It is equivalent to doing: @@ -55,12 +87,11 @@ extension AuthCredentialPatterns on AuthCredential { /// } /// ``` -@optionalTypeArgs TResult maybeMap({TResult Function( LegacyCredential value)? legacy,TResult Function( JwtCredential value)? jwt,required TResult orElse(),}){ +@optionalTypeArgs TResult maybeMap(TResult Function( _JwtCredential value)? $default,{required TResult orElse(),}){ final _that = this; switch (_that) { -case LegacyCredential() when legacy != null: -return legacy(_that);case JwtCredential() when jwt != null: -return jwt(_that);case _: +case _JwtCredential() when $default != null: +return $default(_that);case _: return orElse(); } @@ -78,12 +109,14 @@ return jwt(_that);case _: /// } /// ``` -@optionalTypeArgs TResult map({required TResult Function( LegacyCredential value) legacy,required TResult Function( JwtCredential value) jwt,}){ +@optionalTypeArgs TResult map(TResult Function( _JwtCredential value) $default,){ final _that = this; switch (_that) { -case LegacyCredential(): -return legacy(_that);case JwtCredential(): -return jwt(_that);} +case _JwtCredential(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} } /// A variant of `map` that fallback to returning `null`. /// @@ -97,12 +130,11 @@ return jwt(_that);} /// } /// ``` -@optionalTypeArgs TResult? mapOrNull({TResult? Function( LegacyCredential value)? legacy,TResult? Function( JwtCredential value)? jwt,}){ +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _JwtCredential value)? $default,){ final _that = this; switch (_that) { -case LegacyCredential() when legacy != null: -return legacy(_that);case JwtCredential() when jwt != null: -return jwt(_that);case _: +case _JwtCredential() when $default != null: +return $default(_that);case _: return null; } @@ -119,11 +151,10 @@ return jwt(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen({TResult Function( String token)? legacy,TResult Function( String accessToken, DateTime? expiresAt)? jwt,required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( String accessToken, DateTime? expiresAt)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { -case LegacyCredential() when legacy != null: -return legacy(_that.token);case JwtCredential() when jwt != null: -return jwt(_that.accessToken,_that.expiresAt);case _: +case _JwtCredential() when $default != null: +return $default(_that.accessToken,_that.expiresAt);case _: return orElse(); } @@ -141,11 +172,13 @@ return jwt(_that.accessToken,_that.expiresAt);case _: /// } /// ``` -@optionalTypeArgs TResult when({required TResult Function( String token) legacy,required TResult Function( String accessToken, DateTime? expiresAt) jwt,}) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( String accessToken, DateTime? expiresAt) $default,) {final _that = this; switch (_that) { -case LegacyCredential(): -return legacy(_that.token);case JwtCredential(): -return jwt(_that.accessToken,_that.expiresAt);} +case _JwtCredential(): +return $default(_that.accessToken,_that.expiresAt);case _: + throw StateError('Unexpected subclass'); + +} } /// A variant of `when` that fallback to returning `null` /// @@ -159,11 +192,10 @@ return jwt(_that.accessToken,_that.expiresAt);} /// } /// ``` -@optionalTypeArgs TResult? whenOrNull({TResult? Function( String token)? legacy,TResult? Function( String accessToken, DateTime? expiresAt)? jwt,}) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String accessToken, DateTime? expiresAt)? $default,) {final _that = this; switch (_that) { -case LegacyCredential() when legacy != null: -return legacy(_that.token);case JwtCredential() when jwt != null: -return jwt(_that.accessToken,_that.expiresAt);case _: +case _JwtCredential() when $default != null: +return $default(_that.accessToken,_that.expiresAt);case _: return null; } @@ -174,90 +206,24 @@ return jwt(_that.accessToken,_that.expiresAt);case _: /// @nodoc -class LegacyCredential extends AuthCredential { - const LegacyCredential(this.token): super._(); +class _JwtCredential extends JwtCredential { + const _JwtCredential({required this.accessToken, this.expiresAt}): super._(); - final String token; +@override final String accessToken; +@override final DateTime? expiresAt; -/// Create a copy of AuthCredential +/// Create a copy of JwtCredential /// with the given fields replaced by the non-null parameter values. -@JsonKey(includeFromJson: false, includeToJson: false) +@override @JsonKey(includeFromJson: false, includeToJson: false) @pragma('vm:prefer-inline') -$LegacyCredentialCopyWith get copyWith => _$LegacyCredentialCopyWithImpl(this, _$identity); +_$JwtCredentialCopyWith<_JwtCredential> get copyWith => __$JwtCredentialCopyWithImpl<_JwtCredential>(this, _$identity); @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is LegacyCredential&&(identical(other.token, token) || other.token == token)); -} - - -@override -int get hashCode => Object.hash(runtimeType,token); - -@override -String toString() { - return 'AuthCredential.legacy(token: $token)'; -} - - -} - -/// @nodoc -abstract mixin class $LegacyCredentialCopyWith<$Res> implements $AuthCredentialCopyWith<$Res> { - factory $LegacyCredentialCopyWith(LegacyCredential value, $Res Function(LegacyCredential) _then) = _$LegacyCredentialCopyWithImpl; -@useResult -$Res call({ - String token -}); - - - - -} -/// @nodoc -class _$LegacyCredentialCopyWithImpl<$Res> - implements $LegacyCredentialCopyWith<$Res> { - _$LegacyCredentialCopyWithImpl(this._self, this._then); - - final LegacyCredential _self; - final $Res Function(LegacyCredential) _then; - -/// Create a copy of AuthCredential -/// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') $Res call({Object? token = null,}) { - return _then(LegacyCredential( -null == token ? _self.token : token // ignore: cast_nullable_to_non_nullable -as String, - )); -} - - -} - -/// @nodoc - - -class JwtCredential extends AuthCredential { - const JwtCredential({required this.accessToken, this.expiresAt}): super._(); - - - final String accessToken; - final DateTime? expiresAt; - -/// Create a copy of AuthCredential -/// with the given fields replaced by the non-null parameter values. -@JsonKey(includeFromJson: false, includeToJson: false) -@pragma('vm:prefer-inline') -$JwtCredentialCopyWith get copyWith => _$JwtCredentialCopyWithImpl(this, _$identity); - - - -@override -bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is JwtCredential&&(identical(other.accessToken, accessToken) || other.accessToken == accessToken)&&(identical(other.expiresAt, expiresAt) || other.expiresAt == expiresAt)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _JwtCredential&&(identical(other.accessToken, accessToken) || other.accessToken == accessToken)&&(identical(other.expiresAt, expiresAt) || other.expiresAt == expiresAt)); } @@ -266,16 +232,16 @@ int get hashCode => Object.hash(runtimeType,accessToken,expiresAt); @override String toString() { - return 'AuthCredential.jwt(accessToken: $accessToken, expiresAt: $expiresAt)'; + return 'JwtCredential(accessToken: $accessToken, expiresAt: $expiresAt)'; } } /// @nodoc -abstract mixin class $JwtCredentialCopyWith<$Res> implements $AuthCredentialCopyWith<$Res> { - factory $JwtCredentialCopyWith(JwtCredential value, $Res Function(JwtCredential) _then) = _$JwtCredentialCopyWithImpl; -@useResult +abstract mixin class _$JwtCredentialCopyWith<$Res> implements $JwtCredentialCopyWith<$Res> { + factory _$JwtCredentialCopyWith(_JwtCredential value, $Res Function(_JwtCredential) _then) = __$JwtCredentialCopyWithImpl; +@override @useResult $Res call({ String accessToken, DateTime? expiresAt }); @@ -285,17 +251,17 @@ $Res call({ } /// @nodoc -class _$JwtCredentialCopyWithImpl<$Res> - implements $JwtCredentialCopyWith<$Res> { - _$JwtCredentialCopyWithImpl(this._self, this._then); +class __$JwtCredentialCopyWithImpl<$Res> + implements _$JwtCredentialCopyWith<$Res> { + __$JwtCredentialCopyWithImpl(this._self, this._then); - final JwtCredential _self; - final $Res Function(JwtCredential) _then; + final _JwtCredential _self; + final $Res Function(_JwtCredential) _then; -/// Create a copy of AuthCredential +/// Create a copy of JwtCredential /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') $Res call({Object? accessToken = null,Object? expiresAt = freezed,}) { - return _then(JwtCredential( +@override @pragma('vm:prefer-inline') $Res call({Object? accessToken = null,Object? expiresAt = freezed,}) { + return _then(_JwtCredential( accessToken: null == accessToken ? _self.accessToken : accessToken // ignore: cast_nullable_to_non_nullable as String,expiresAt: freezed == expiresAt ? _self.expiresAt : expiresAt // ignore: cast_nullable_to_non_nullable as DateTime?, diff --git a/lib/core/network/auth_credentials_storage.dart b/lib/core/network/auth_credentials_storage.dart index 36307b497..12752fefe 100644 --- a/lib/core/network/auth_credentials_storage.dart +++ b/lib/core/network/auth_credentials_storage.dart @@ -27,19 +27,19 @@ import 'package:wger/core/network/secure_token_storage.dart'; import 'package:wger/core/shared_preferences.dart'; /// Credential + server URL pair restored from on-disk storage. The refresh -/// token (for the JWT path) is intentionally absent: it lives in secure -/// storage and is only read when a refresh actually runs. +/// token is intentionally absent: it lives in secure storage and is only +/// read when a refresh actually runs. class StoredAuth { - final AuthCredential credential; + final JwtCredential credential; final String serverUrl; const StoredAuth({required this.credential, required this.serverUrl}); } /// All persistence for the auth flow in one place. Holds the JWT-keyed -/// shared-preference bundle, the legacy `PREFS_USER` blob, and the -/// secure-storage refresh token. Lifts the storage layout details out of -/// the notifier so callers don't have to know which keys back which fact. +/// shared-preference bundle and the secure-storage refresh token. Lifts the +/// storage layout details out of the notifier so callers don't have to know +/// which keys back which fact. class AuthCredentialsStorage { final SecureTokenStorage _secureStorage; final _logger = Logger('AuthCredentialsStorage'); @@ -48,23 +48,10 @@ class AuthCredentialsStorage { SharedPreferencesAsync get _prefs => PreferenceHelper.asyncPref; - /// Reads the persisted credential bundle. The headless-JWT keys take - /// priority over the legacy `PREFS_USER` blob, so a partial migration - /// state still resolves to the JWT path. Returns null when neither - /// shape is fully present. + /// Reads the persisted credential bundle. Returns null when the access + /// token or the server URL is missing, so a half-written bundle resolves + /// to logged-out rather than to a session that cannot make requests. Future load() async { - final jwt = await _readJwt(); - if (jwt != null) { - return jwt; - } - return _readLegacy(); - } - - Future _readJwt() async { - final tokenType = await _prefs.getString(PREFS_TOKEN_TYPE); - if (tokenType != AuthTokenType.headlessJwt.name) { - return null; - } final accessToken = await _prefs.getString(PREFS_ACCESS_TOKEN); final serverUrl = await _prefs.getString(PREFS_SERVER_URL); if (accessToken == null || accessToken.isEmpty || serverUrl == null || serverUrl.isEmpty) { @@ -80,34 +67,10 @@ class AuthCredentialsStorage { ); } - Future _readLegacy() async { - if (!(await _prefs.containsKey(PREFS_USER))) { - return null; - } - final raw = await _prefs.getString(PREFS_USER); - if (raw == null) { - return null; - } - final Map blob; - try { - blob = json.decode(raw) as Map; - } catch (e, s) { - _logger.warning('Could not decode PREFS_USER blob', e, s); - return null; - } - final token = blob['token'] as String?; - final serverUrl = blob['serverUrl'] as String?; - if (token == null || serverUrl == null) { - return null; - } - return StoredAuth(credential: LegacyCredential(token), serverUrl: serverUrl); - } - /// Persists a fresh JWT bundle. As a side effect this records the - /// server URL as the "last server" for the next login screen and wipes - /// the legacy `PREFS_USER` blob (legacy users transition to JWT on first - /// login through this path). The DB-owner marker is intentionally NOT - /// written here; the login flow sets it after any required DB wipe. + /// server URL as the "last server" for the next login screen. The + /// DB-owner marker is intentionally NOT written here; the login flow sets + /// it after any required DB wipe. Future saveJwt({ required JwtCredential credential, required String serverUrl, @@ -120,7 +83,6 @@ class AuthCredentialsStorage { } else { await _prefs.remove(PREFS_ACCESS_EXPIRES_AT); } - await _prefs.setString(PREFS_TOKEN_TYPE, AuthTokenType.headlessJwt.name); await _prefs.setString(PREFS_SERVER_URL, serverUrl); if (refreshToken != null) { @@ -133,13 +95,11 @@ class AuthCredentialsStorage { _logger.warning('Could not persist refresh token, auto-login disabled', e, s); } } - - await clearLegacy(); } /// Updates the persisted JWT bundle in place after a successful refresh. - /// Identical to [saveJwt] minus the legacy-cleanup and last-server side - /// effects (the original login already wrote those). + /// Identical to [saveJwt] minus the last-server side effect (the original + /// login already wrote it). Future updateJwt({ required JwtCredential credential, String? refreshToken, @@ -162,15 +122,18 @@ class AuthCredentialsStorage { } } - /// Wipes the headless-JWT preference bundle and the secure-storage - /// refresh token. Used both for involuntary session clears and as part - /// of a full [clearAll]. The DB-owner marker is deliberately left intact: - /// it tracks who owns the on-disk data, which clearing credentials does - /// not change. It is reset only when the DB is actually wiped. - Future clearJwt() async { + /// Wipes the credential bundle and the secure-storage refresh token, but + /// keeps the "has ever synced" flag so the next login takes the + /// offline-friendly restored-session path. Used for involuntary session + /// loss (refresh token expired, 401 retries exhausted) where the local + /// PowerSync DB is preserved, and as part of a full [clearAll]. + /// + /// The DB-owner marker is deliberately left intact: it tracks who owns + /// the on-disk data, which clearing credentials does not change. It is + /// reset only when the DB is actually wiped. + Future clearCredentials() async { await _prefs.remove(PREFS_ACCESS_TOKEN); await _prefs.remove(PREFS_ACCESS_EXPIRES_AT); - await _prefs.remove(PREFS_TOKEN_TYPE); await _prefs.remove(PREFS_SERVER_URL); try { await _secureStorage.deleteRefreshToken(); @@ -181,21 +144,10 @@ class AuthCredentialsStorage { } } - /// Wipes only the legacy `PREFS_USER` blob. Used by the JWT-migration - /// path on a 401 from the exchange endpoint (DRF token revoked) and as - /// a side effect of [saveJwt]. - Future clearLegacy() async { - await _prefs.remove(PREFS_USER); - } - - /// Wipes both credential shapes but keeps the "has ever synced" flag, - /// so the next login takes the offline-friendly restored-session path. - /// Used for involuntary session loss (refresh token expired, 401 - /// retries exhausted) where the local PowerSync DB is preserved. - Future clearCredentials() async { - await clearLegacy(); - await clearJwt(); - } + /// Removes the pre-JWT credential blob left behind by installs that + /// upgraded from a build using permanent DRF tokens. Best-effort, runs + /// once per app start; can be dropped a couple of releases from now. + Future clearLegacyDrfToken() => _prefs.remove(PREFS_USER); /// Manual-logout wipe: clears credentials plus the "has ever synced" /// flag, so the next login takes the full first-run gating path diff --git a/lib/core/network/auth_http_client.dart b/lib/core/network/auth_http_client.dart index 2ff13fa24..65f6047df 100644 --- a/lib/core/network/auth_http_client.dart +++ b/lib/core/network/auth_http_client.dart @@ -34,21 +34,19 @@ const refreshLeeway = Duration(seconds: 30); /// authenticated request to the wger backend. /// /// Responsibilities: -/// - Inject the right `Authorization` value for the current credential -/// ([AuthCredential.authHeaderValue] does the dispatch). -/// - For [JwtCredential], pre-emptively refresh when the stored expiry is -/// within [refreshLeeway] of now. -/// - On a 401 reply for a *replayable* [http.Request] body that was sent -/// with a JWT, refresh once and retry. If the retry also returns 401 -/// the session is treated as genuinely revoked: `onSessionExpired` -/// runs (clear credentials + surface a snackbar) and a synthetic 401 -/// is returned to the caller. Non-replayable bodies (multipart / -/// streamed) are not retried; the pre-emptive refresh in the happy -/// path is the primary safeguard. +/// - Inject the `Authorization` value for the current credential. +/// - Pre-emptively refresh when the stored expiry is within +/// [refreshLeeway] of now. +/// - On a 401 reply for a *replayable* [http.Request] body, refresh once +/// and retry. If the retry also returns 401 the session is treated as +/// genuinely revoked: `onSessionExpired` runs (clear credentials + +/// surface a snackbar) and a synthetic 401 is returned to the caller. +/// Non-replayable bodies (multipart / streamed) are not retried; the +/// pre-emptive refresh in the happy path is the primary safeguard. /// /// Wrapped behind [authenticatedHttpClientProvider] so consumers /// (`WgerBaseProvider`, PowerSync's connector) get the auth handling for -/// free without needing to know about the migration state. +/// free. class AuthHttpClient extends http.BaseClient { final http.Client _inner; final AuthState? Function() _readAuth; @@ -79,17 +77,16 @@ class AuthHttpClient extends http.BaseClient { _applyAuthHeader(request, credential); final response = await _inner.send(request); - final canRetry = - response.statusCode == 401 && credential is JwtCredential && request is http.Request; + final canRetry = response.statusCode == 401 && credential != null && request is http.Request; if (!canRetry) { return response; } - _logger.fine('401 on JWT request, refreshing once and retrying'); + _logger.fine('401 on authenticated request, refreshing once and retrying'); await response.stream.drain(); await _refresh(); final fresh = _readAuth()?.credential; - if (fresh is! JwtCredential) { + if (fresh == null) { return _syntheticUnauthorized(); } @@ -110,14 +107,14 @@ class AuthHttpClient extends http.BaseClient { @override void close() => _inner.close(); - void _applyAuthHeader(http.BaseRequest req, AuthCredential? credential) { + void _applyAuthHeader(http.BaseRequest req, JwtCredential? credential) { if (credential == null) { return; } req.headers[HttpHeaders.authorizationHeader] = credential.authHeaderValue; } - http.Request _cloneRequest(http.Request orig, AuthCredential credential) { + http.Request _cloneRequest(http.Request orig, JwtCredential credential) { final retry = http.Request(orig.method, orig.url) ..bodyBytes = orig.bodyBytes ..encoding = orig.encoding diff --git a/lib/core/network/auth_notifier.dart b/lib/core/network/auth_notifier.dart index 0908a91b3..e7fcd08b9 100644 --- a/lib/core/network/auth_notifier.dart +++ b/lib/core/network/auth_notifier.dart @@ -30,7 +30,6 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:wger/core/consts.dart'; import 'package:wger/core/error_dialogs.dart'; -import 'package:wger/core/errors.dart'; import 'package:wger/core/exceptions/http_exception.dart'; import 'package:wger/core/exceptions/mfa_required_exception.dart'; import 'package:wger/core/helpers.dart'; @@ -59,11 +58,6 @@ const HEADLESS_AUTH_LOGIN_PATH = 'auth/login'; const HEADLESS_AUTH_SIGNUP_PATH = 'auth/signup'; const HEADLESS_AUTH_MFA_AUTHENTICATE_PATH = 'auth/2fa/authenticate'; -/// `/api/v2/` endpoint that mints a headless-JWT refresh token for the -/// authenticated user. Used by the one-shot legacy-DRF → JWT migration on -/// app start. -const ISSUE_REFRESH_TOKEN_PATH = 'issue-refresh-token'; - /// Header that carries the short-lived `session_token` returned by /// `auth/login` when a follow-up step (currently only 2FA) is still pending. const HEADLESS_SESSION_TOKEN_HEADER = 'X-Session-Token'; @@ -417,12 +411,7 @@ class AuthNotifier extends _$AuthNotifier { } Future _tryAutoLogin() async { - // One-shot migration: if a legacy DRF token is still on disk, swap it - // for a JWT bundle now so the rest of the auto-login can take the - // JWT happy path. On any failure the legacy blob is left alone and the - // user falls back to the still-supported legacy code path; the next - // start will try again. - await _maybeMigrateLegacyToJwt(); + await _storage.clearLegacyDrfToken(); final stored = await _storage.load(); if (stored == null) { _logger.info('autologin failed, no saved session'); @@ -432,111 +421,9 @@ class AuthNotifier extends _$AuthNotifier { return _resolveStoredSession(stored, appVersion); } - /// Exchanges a legacy DRF API token for a headless-JWT bundle and - /// persists the result. No-op when no legacy blob is present. - /// - /// Sequence: - /// 1. POST to [ISSUE_REFRESH_TOKEN_PATH] authenticated with the legacy - /// `Token ` header. The server mints a long-lived refresh token - /// backed by a fresh Django session. - /// 2. Exchange that refresh token at the standard headless - /// `tokens/refresh` endpoint for the full access bundle (reuses - /// [_exchangePastedRefreshToken]). - /// 3. Persist the bundle. [AuthCredentialsStorage.saveJwt] wipes the - /// legacy `PREFS_USER` blob as a side effect, so the next load sees - /// the JWT path. - /// - /// Failure handling — all branches log and return without touching - /// state, so a re-attempt happens on the next app start: - /// - Network error: keep the DRF token, the user continues working - /// against the legacy code path until connectivity returns. - /// - 401 / 403: the DRF token has been revoked server-side. Wipe the - /// legacy blob so the user is routed to login (they have no usable - /// credential left). - /// - 5xx / malformed body / refresh exchange failure: keep the legacy - /// blob and retry on the next start. The server-side session row - /// minted in step 1 stays orphaned but is harmless. - Future _maybeMigrateLegacyToJwt() async { - final stored = await _storage.load(); - if (stored == null || stored.credential is! LegacyCredential) { - return; - } - final legacyCred = stored.credential as LegacyCredential; - final serverUrl = stored.serverUrl; - final appVersion = await PackageInfo.fromPlatform(); - - _logger.info('Legacy DRF token present, attempting JWT migration'); - - final http.Response response; - try { - response = await _client.post( - makeUri(serverUrl, ISSUE_REFRESH_TOKEN_PATH, trailingSlash: false), - headers: jsonApiHeaders(appVersion, { - HttpHeaders.authorizationHeader: legacyCred.authHeaderValue, - }), - ); - } on Exception catch (e, s) { - if (isNetworkError(e)) { - _logger.info('Legacy migration: server unreachable, keeping DRF token'); - return; - } - _logger.warning('Legacy migration: exchange POST threw', e, s); - return; - } - - if (_isAuthRejection(response.statusCode)) { - _logger.warning( - 'Legacy migration: DRF token rejected (${response.statusCode}), wiping legacy blob', - ); - await _storage.clearLegacy(); - return; - } - if (response.statusCode != 200) { - _logger.warning( - 'Legacy migration: unexpected status ${response.statusCode}, will retry next start', - ); - return; - } - - final String refreshToken; - try { - final body = json.decode(response.body) as Map; - refreshToken = body['refresh_token'] as String; - } catch (e, s) { - _logger.warning('Legacy migration: malformed response body', e, s); - return; - } - if (refreshToken.isEmpty) { - _logger.warning('Legacy migration: empty refresh_token in response'); - return; - } - - final _FreshCredentials freshCreds; - try { - freshCreds = await _exchangePastedRefreshToken(refreshToken, serverUrl, appVersion); - } on Exception catch (e, s) { - _logger.warning('Legacy migration: refresh token exchange failed', e, s); - return; - } - - await _storage.saveJwt( - credential: freshCreds.credential, - refreshToken: freshCreds.refreshToken, - serverUrl: serverUrl, - ); - // Claim DB ownership for the migrated user: this path logs in without - // going through _completeLogin, so otherwise the marker would stay null - // and a later different-user login wouldn't wipe. - final migratedUserId = freshCreds.credential.userId; - if (migratedUserId != null) { - await _storage.setDbOwnerUserId(migratedUserId); - } - _logger.info('Legacy migration: successful, DRF token replaced with JWT'); - } - - /// Common path for both the headless-JWT and the legacy auto-login flows: - /// probe the server, then run the full gating chain. Wipes the matching - /// stored credentials on a definitive 4xx so the user is routed to login. + /// Auto-login path for a session that has never synced: probe the server, + /// then run the full gating chain. Wipes the stored credentials on a + /// definitive 4xx so the user is routed to login. Future _autoLoginWith(StoredAuth stored, PackageInfo appVersion) async { final response = await _gating.probe( credential: stored.credential, @@ -550,12 +437,12 @@ class AuthNotifier extends _$AuthNotifier { return _restoredSessionState(stored, appVersion); } - // The server actively rejected our token: wipe the matching credential - // bundle and route to login. Only 401/403 count, a transient 5xx must not - // log the user out. + // The server actively rejected our token: wipe the stored credentials and + // route to login. Only 401/403 count, a transient 5xx must not log the + // user out. if (_isAuthRejection(response.statusCode)) { _logger.info('autologin failed, token rejected: ${response.statusCode}'); - await _clearStoredCredential(stored.credential); + await _storage.clearCredentials(); return AuthState(applicationVersion: appVersion); } @@ -602,8 +489,7 @@ class AuthNotifier extends _$AuthNotifier { return _restoredSessionState(stored, appVersion); } - /// Builds a logged-in [AuthState] for a stored session. Polymorphism in - /// [AuthCredential] keeps this branch-free across credential variants. + /// Builds a logged-in [AuthState] for a stored session. AuthState _restoredSessionState(StoredAuth stored, PackageInfo appVersion) { return AuthState( status: AuthStatus.loggedIn, @@ -617,15 +503,6 @@ class AuthNotifier extends _$AuthNotifier { /// opposed to a transient error that must not invalidate the session. bool _isAuthRejection(int statusCode) => statusCode == 401 || statusCode == 403; - /// Clears only the storage rows that back the given credential. Used on a - /// definitive auth-rejection by the server so the next start routes the - /// user to the login screen without touching the *other* credential - /// shape (which a parallel user on the same device might still rely on). - Future _clearStoredCredential(AuthCredential credential) => switch (credential) { - LegacyCredential() => _storage.clearLegacy(), - JwtCredential() => _storage.clearJwt(), - }; - /// Schedules a non-blocking revalidation of the restored session. /// /// The first run is deferred to a fresh event-loop task so [build] has @@ -677,9 +554,7 @@ class AuthNotifier extends _$AuthNotifier { // If the access token has expired (typical after a longer offline // period) refresh first, so a still-valid refresh token isn't wasted // by a 401 on the probe below. The refresh's own failure paths will - // clear the session if the refresh token is also dead. Legacy - // credentials are no-op here ([AuthCredential.needsRefresh] returns - // false for them). + // clear the session if the refresh token is also dead. if (current.credential?.needsRefresh(refreshLeeway) ?? false) { _logger.fine('revalidation: access token within leeway, refreshing first'); await refreshAccessToken(); diff --git a/lib/core/network/auth_notifier.g.dart b/lib/core/network/auth_notifier.g.dart index 86ce6eeff..711eedd75 100644 --- a/lib/core/network/auth_notifier.g.dart +++ b/lib/core/network/auth_notifier.g.dart @@ -32,7 +32,7 @@ final class AuthNotifierProvider extends $AsyncNotifierProvider AuthNotifier(); } -String _$authNotifierHash() => r'19bf6776a00c5a7374ddc918f55709ee3a193b3f'; +String _$authNotifierHash() => r'5e6bc3feb9ef7961f046936197dee77dd63a542c'; abstract class _$AuthNotifier extends $AsyncNotifier { FutureOr build(); diff --git a/lib/core/network/auth_state.dart b/lib/core/network/auth_state.dart index 6cc5b3d72..56170b5b8 100644 --- a/lib/core/network/auth_state.dart +++ b/lib/core/network/auth_state.dart @@ -45,24 +45,11 @@ enum LoginActions { proceed, } -/// Storage-layer discriminator for the persisted credential bundle. Lives in -/// `PREFS_TOKEN_TYPE` so auto-login can tell which set of preference keys to -/// read on startup. Runtime code should branch on the [AuthCredential] -/// subtype (`LegacyCredential` / `JwtCredential`) instead. -enum AuthTokenType { - /// Permanent DRF token persisted under `PREFS_USER`. - legacyApiToken, - - /// Headless JWT bundle persisted under the `PREFS_ACCESS_TOKEN` family of - /// keys; refresh token lives in secure storage. - headlessJwt, -} - @freezed sealed class AuthState with _$AuthState { const factory AuthState({ @Default(AuthStatus.loggedOut) AuthStatus status, - AuthCredential? credential, + JwtCredential? credential, String? serverUrl, String? serverVersion, PackageInfo? applicationVersion, diff --git a/lib/core/network/auth_state.freezed.dart b/lib/core/network/auth_state.freezed.dart index 56028625b..f2b2cb40d 100644 --- a/lib/core/network/auth_state.freezed.dart +++ b/lib/core/network/auth_state.freezed.dart @@ -14,7 +14,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$AuthState { - AuthStatus get status; AuthCredential? get credential; String? get serverUrl; String? get serverVersion; PackageInfo? get applicationVersion; bool get serverConfigWarning;/// True when the previous session ended involuntarily (expired or + AuthStatus get status; JwtCredential? get credential; String? get serverUrl; String? get serverVersion; PackageInfo? get applicationVersion; bool get serverConfigWarning;/// True when the previous session ended involuntarily (expired or /// revoked tokens). The login screen shows a hint so the user knows /// why they have to log in again. Reset by the next login, which /// builds a fresh state. @@ -49,11 +49,11 @@ abstract mixin class $AuthStateCopyWith<$Res> { factory $AuthStateCopyWith(AuthState value, $Res Function(AuthState) _then) = _$AuthStateCopyWithImpl; @useResult $Res call({ - AuthStatus status, AuthCredential? credential, String? serverUrl, String? serverVersion, PackageInfo? applicationVersion, bool serverConfigWarning, bool sessionExpired + AuthStatus status, JwtCredential? credential, String? serverUrl, String? serverVersion, PackageInfo? applicationVersion, bool serverConfigWarning, bool sessionExpired }); -$AuthCredentialCopyWith<$Res>? get credential; +$JwtCredentialCopyWith<$Res>? get credential; } /// @nodoc @@ -70,7 +70,7 @@ class _$AuthStateCopyWithImpl<$Res> return _then(_self.copyWith( status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable as AuthStatus,credential: freezed == credential ? _self.credential : credential // ignore: cast_nullable_to_non_nullable -as AuthCredential?,serverUrl: freezed == serverUrl ? _self.serverUrl : serverUrl // ignore: cast_nullable_to_non_nullable +as JwtCredential?,serverUrl: freezed == serverUrl ? _self.serverUrl : serverUrl // ignore: cast_nullable_to_non_nullable as String?,serverVersion: freezed == serverVersion ? _self.serverVersion : serverVersion // ignore: cast_nullable_to_non_nullable as String?,applicationVersion: freezed == applicationVersion ? _self.applicationVersion : applicationVersion // ignore: cast_nullable_to_non_nullable as PackageInfo?,serverConfigWarning: null == serverConfigWarning ? _self.serverConfigWarning : serverConfigWarning // ignore: cast_nullable_to_non_nullable @@ -82,12 +82,12 @@ as bool, /// with the given fields replaced by the non-null parameter values. @override @pragma('vm:prefer-inline') -$AuthCredentialCopyWith<$Res>? get credential { +$JwtCredentialCopyWith<$Res>? get credential { if (_self.credential == null) { return null; } - return $AuthCredentialCopyWith<$Res>(_self.credential!, (value) { + return $JwtCredentialCopyWith<$Res>(_self.credential!, (value) { return _then(_self.copyWith(credential: value)); }); } @@ -169,7 +169,7 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( AuthStatus status, AuthCredential? credential, String? serverUrl, String? serverVersion, PackageInfo? applicationVersion, bool serverConfigWarning, bool sessionExpired)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( AuthStatus status, JwtCredential? credential, String? serverUrl, String? serverVersion, PackageInfo? applicationVersion, bool serverConfigWarning, bool sessionExpired)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _AuthState() when $default != null: return $default(_that.status,_that.credential,_that.serverUrl,_that.serverVersion,_that.applicationVersion,_that.serverConfigWarning,_that.sessionExpired);case _: @@ -190,7 +190,7 @@ return $default(_that.status,_that.credential,_that.serverUrl,_that.serverVersio /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( AuthStatus status, AuthCredential? credential, String? serverUrl, String? serverVersion, PackageInfo? applicationVersion, bool serverConfigWarning, bool sessionExpired) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( AuthStatus status, JwtCredential? credential, String? serverUrl, String? serverVersion, PackageInfo? applicationVersion, bool serverConfigWarning, bool sessionExpired) $default,) {final _that = this; switch (_that) { case _AuthState(): return $default(_that.status,_that.credential,_that.serverUrl,_that.serverVersion,_that.applicationVersion,_that.serverConfigWarning,_that.sessionExpired);} @@ -207,7 +207,7 @@ return $default(_that.status,_that.credential,_that.serverUrl,_that.serverVersio /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( AuthStatus status, AuthCredential? credential, String? serverUrl, String? serverVersion, PackageInfo? applicationVersion, bool serverConfigWarning, bool sessionExpired)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( AuthStatus status, JwtCredential? credential, String? serverUrl, String? serverVersion, PackageInfo? applicationVersion, bool serverConfigWarning, bool sessionExpired)? $default,) {final _that = this; switch (_that) { case _AuthState() when $default != null: return $default(_that.status,_that.credential,_that.serverUrl,_that.serverVersion,_that.applicationVersion,_that.serverConfigWarning,_that.sessionExpired);case _: @@ -226,7 +226,7 @@ class _AuthState extends AuthState { @override@JsonKey() final AuthStatus status; -@override final AuthCredential? credential; +@override final JwtCredential? credential; @override final String? serverUrl; @override final String? serverVersion; @override final PackageInfo? applicationVersion; @@ -267,11 +267,11 @@ abstract mixin class _$AuthStateCopyWith<$Res> implements $AuthStateCopyWith<$Re factory _$AuthStateCopyWith(_AuthState value, $Res Function(_AuthState) _then) = __$AuthStateCopyWithImpl; @override @useResult $Res call({ - AuthStatus status, AuthCredential? credential, String? serverUrl, String? serverVersion, PackageInfo? applicationVersion, bool serverConfigWarning, bool sessionExpired + AuthStatus status, JwtCredential? credential, String? serverUrl, String? serverVersion, PackageInfo? applicationVersion, bool serverConfigWarning, bool sessionExpired }); -@override $AuthCredentialCopyWith<$Res>? get credential; +@override $JwtCredentialCopyWith<$Res>? get credential; } /// @nodoc @@ -288,7 +288,7 @@ class __$AuthStateCopyWithImpl<$Res> return _then(_AuthState( status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable as AuthStatus,credential: freezed == credential ? _self.credential : credential // ignore: cast_nullable_to_non_nullable -as AuthCredential?,serverUrl: freezed == serverUrl ? _self.serverUrl : serverUrl // ignore: cast_nullable_to_non_nullable +as JwtCredential?,serverUrl: freezed == serverUrl ? _self.serverUrl : serverUrl // ignore: cast_nullable_to_non_nullable as String?,serverVersion: freezed == serverVersion ? _self.serverVersion : serverVersion // ignore: cast_nullable_to_non_nullable as String?,applicationVersion: freezed == applicationVersion ? _self.applicationVersion : applicationVersion // ignore: cast_nullable_to_non_nullable as PackageInfo?,serverConfigWarning: null == serverConfigWarning ? _self.serverConfigWarning : serverConfigWarning // ignore: cast_nullable_to_non_nullable @@ -301,12 +301,12 @@ as bool, /// with the given fields replaced by the non-null parameter values. @override @pragma('vm:prefer-inline') -$AuthCredentialCopyWith<$Res>? get credential { +$JwtCredentialCopyWith<$Res>? get credential { if (_self.credential == null) { return null; } - return $AuthCredentialCopyWith<$Res>(_self.credential!, (value) { + return $JwtCredentialCopyWith<$Res>(_self.credential!, (value) { return _then(_self.copyWith(credential: value)); }); } diff --git a/lib/core/network/network_provider.g.dart b/lib/core/network/network_provider.g.dart index e84356690..c67d1f49d 100644 --- a/lib/core/network/network_provider.g.dart +++ b/lib/core/network/network_provider.g.dart @@ -40,7 +40,7 @@ final class NetworkStatusProvider extends $NotifierProvider } } -String _$networkStatusHash() => r'2abc19df2e0e679222609fec1d637ae131ef4ac1'; +String _$networkStatusHash() => r'091ee41de990a1e6ff0ff3915656d41ea2d1a6e5'; abstract class _$NetworkStatus extends $Notifier { bool build(); diff --git a/lib/core/network/server_gating.dart b/lib/core/network/server_gating.dart index 188d6be83..474c13a75 100644 --- a/lib/core/network/server_gating.dart +++ b/lib/core/network/server_gating.dart @@ -49,7 +49,7 @@ class ServerGating { /// separately via [serverVersionGate], so callers check the version once and /// pass it into the auth state themselves. Future resolve({ - required AuthCredential credential, + required JwtCredential credential, required String serverUrl, required PackageInfo appVersion, }) async { @@ -77,7 +77,7 @@ class ServerGating { /// can distinguish "we couldn't reach the server" from "the server said /// no". Only the latter is grounds for logging the user out. Future probe({ - required AuthCredential credential, + required JwtCredential credential, required String serverUrl, required PackageInfo appVersion, }) async { @@ -106,7 +106,7 @@ class ServerGating { /// completed conclusively, so the caller defaults to permissive). Future serverConfigSane({ required String serverUrl, - required AuthCredential credential, + required JwtCredential credential, }) async { try { final baseUri = Uri.parse(serverUrl); @@ -143,7 +143,7 @@ class ServerGating { /// success the flag is set so future starts skip the probe. Future isPowerSyncReachable({ required String serverUrl, - required AuthCredential credential, + required JwtCredential credential, }) async { if (await _storage.hasEverSynced()) { return true; diff --git a/lib/database/powersync/powersync.g.dart b/lib/database/powersync/powersync.g.dart index 974da10bb..b3cc82b21 100644 --- a/lib/database/powersync/powersync.g.dart +++ b/lib/database/powersync/powersync.g.dart @@ -46,4 +46,4 @@ final class PowerSyncInstanceProvider } } -String _$powerSyncInstanceHash() => r'3cc3ce4ee4d65ab26f3709b7526822b0366f0eb6'; +String _$powerSyncInstanceHash() => r'd10c970f05a7c71b45420cd1f90953c2431eadc9'; diff --git a/lib/features/nutrition/providers/nutrition_notifier.g.dart b/lib/features/nutrition/providers/nutrition_notifier.g.dart index 7d78e5995..15815203f 100644 --- a/lib/features/nutrition/providers/nutrition_notifier.g.dart +++ b/lib/features/nutrition/providers/nutrition_notifier.g.dart @@ -33,7 +33,7 @@ final class NutritionNotifierProvider NutritionNotifier create() => NutritionNotifier(); } -String _$nutritionNotifierHash() => r'ecc463d68d5eae2df4c5e73e43e5cf214f6e1c8a'; +String _$nutritionNotifierHash() => r'd0db2f8f3853bd38ae28913cbb3256753e783f6c'; abstract class _$NutritionNotifier extends $StreamNotifier { Stream build(); diff --git a/lib/features/routines/providers/gym_log_notifier.g.dart b/lib/features/routines/providers/gym_log_notifier.g.dart index aa12a3505..0b99b311d 100644 --- a/lib/features/routines/providers/gym_log_notifier.g.dart +++ b/lib/features/routines/providers/gym_log_notifier.g.dart @@ -40,7 +40,7 @@ final class GymLogNotifierProvider extends $NotifierProvider r'2a9eb1f27bcc5d72a893843ddfaa077a32f8ed26'; +String _$gymLogNotifierHash() => r'f19f65118fc2746149178debd2f5fcb1cdfcab3c'; abstract class _$GymLogNotifier extends $Notifier { Log? build(); diff --git a/test/core/network/auth_credentials_storage_test.dart b/test/core/network/auth_credentials_storage_test.dart index 3186c737c..1aecad6b3 100644 --- a/test/core/network/auth_credentials_storage_test.dart +++ b/test/core/network/auth_credentials_storage_test.dart @@ -94,11 +94,11 @@ void main() { expect(loggedKeyringWarning(), isTrue); }); - test('clearJwt clears the prefs, logs, and does not rethrow', () async { + test('clearCredentials clears the prefs, logs, and does not rethrow', () async { when(secureStorage.deleteRefreshToken()).thenThrow(keyringLocked); await PreferenceHelper.asyncPref.setString(PREFS_ACCESS_TOKEN, 'stale'); - await expectLater(storage.clearJwt(), completes); + await expectLater(storage.clearCredentials(), completes); verify(secureStorage.deleteRefreshToken()).called(1); expect(await PreferenceHelper.asyncPref.getString(PREFS_ACCESS_TOKEN), isNull); diff --git a/test/core/network/auth_http_client_test.dart b/test/core/network/auth_http_client_test.dart index cca1b9264..4eadf8c29 100644 --- a/test/core/network/auth_http_client_test.dart +++ b/test/core/network/auth_http_client_test.dart @@ -91,17 +91,6 @@ void main() { expect(refreshCalls, 0); }); - test('legacy credential → Authorization: Token ', () async { - auth = const AuthState(credential: LegacyCredential('legacy-key')); - - final headers = await sendAndCapture( - http.Request('GET', Uri.parse('https://wger.example/api/v2/routine/')), - ); - - expect(headers[HttpHeaders.authorizationHeader], 'Token legacy-key'); - expect(refreshCalls, 0); - }); - test('no auth state → no Authorization header set', () async { auth = null; final headers = await sendAndCapture( @@ -159,16 +148,6 @@ void main() { expect(refreshCalls, 0); }); - test('does not fire for the legacy permanent token', () async { - auth = const AuthState(credential: LegacyCredential('legacy-key')); - - await sendAndCapture( - http.Request('GET', Uri.parse('https://wger.example/api/v2/routine/')), - ); - - expect(refreshCalls, 0); - }); - test('does not fire when accessExpiresAt is null', () async { auth = const AuthState(credential: JwtCredential(accessToken: 'opaque-jwt')); @@ -285,8 +264,8 @@ void main() { verify(inner.send(any)).called(1); // No retry attempted. }); - test('legacy 401 → no retry, original 401 surfaces', () async { - auth = const AuthState(credential: LegacyCredential('legacy-key')); + test('401 without a credential → no retry, original 401 surfaces', () async { + auth = const AuthState(); when(inner.send(any)).thenAnswer( (_) async => http.StreamedResponse(Stream.value([]), 401), ); diff --git a/test/core/network/auth_notifier_login_test.dart b/test/core/network/auth_notifier_login_test.dart index e8ceef763..684ce79ca 100644 --- a/test/core/network/auth_notifier_login_test.dart +++ b/test/core/network/auth_notifier_login_test.dart @@ -186,7 +186,7 @@ void main() { }); group('login: headless happy path', () { - test('200 → stores headless JWT bundle, wipes legacy PREFS_USER, state has tokens', () async { + test('200 → stores headless JWT bundle, state has tokens', () async { final accessJwt = makeJwt({'sub': '7', 'exp': 1900000000}); when( mockClient.post(tHeadlessLogin, headers: anyNamed('headers'), body: anyNamed('body')), @@ -202,17 +202,9 @@ void main() { ); final container = makeContainer(); - // Let auto-login settle as logged-out (no PREFS_USER yet). + // Let auto-login settle as logged-out (nothing stored yet). await container.read(authProvider.future); - // Seed a stale legacy blob *after* auto-login, so we can assert - // login() wipes it on success without auto-login itself trying to - // probe with it. - await PreferenceHelper.asyncPref.setString( - PREFS_USER, - jsonEncode({'token': 'stale-legacy', 'serverUrl': serverUrl}), - ); - final result = await container .read(authProvider.notifier) .login(username, password, serverUrl, null); @@ -230,13 +222,9 @@ void main() { // logged-in user so the next login can detect a user-switch. final prefs = PreferenceHelper.asyncPref; expect(await prefs.getString(PREFS_ACCESS_TOKEN), accessJwt); - expect(await prefs.getString(PREFS_TOKEN_TYPE), AuthTokenType.headlessJwt.name); expect(await prefs.getString(PREFS_SERVER_URL), serverUrl); expect(await prefs.getString(PREFS_DB_OWNER_USER_ID), '7'); - // Stale legacy blob wiped. - expect(await prefs.containsKey(PREFS_USER), false); - // No prior session, so the user-switch wipe path must not have fired. expect(container.read(authProvider.notifier).userSwitchWipeCount, 0); }); diff --git a/test/core/network/auth_notifier_powersync_test.dart b/test/core/network/auth_notifier_powersync_test.dart index 959c52b7c..da86354fa 100644 --- a/test/core/network/auth_notifier_powersync_test.dart +++ b/test/core/network/auth_notifier_powersync_test.dart @@ -68,19 +68,17 @@ void main() { const pathProviderChannel = MethodChannel('plugins.flutter.io/path_provider'); const serverUrl = 'https://wger.example'; - const token = 'token-12345'; + const accessToken = 'access-token-12345'; const powerSyncUrl = 'https://ps.example/'; - // makeUri() defaults to a trailing slash; powersync-token, - // issue-refresh-token are the endpoints registered without one on the - // Django side. + // makeUri() defaults to a trailing slash; powersync-token is registered + // without one on the Django side. final tProbe = Uri.parse('$serverUrl/api/v2/routine/'); final tVersion = Uri.parse('$serverUrl/api/v2/version/'); final tMinAppVersion = Uri.parse('$serverUrl/api/v2/min-app-version/'); final tPowerSyncToken = Uri.parse('$serverUrl/api/v2/powersync-token'); final tLiveness = Uri.parse('${powerSyncUrl}probes/liveness'); final tFallbackLiveness = Uri.parse('$serverUrl/ps/probes/liveness'); - final tIssueRefresh = Uri.parse('$serverUrl/api/v2/issue-refresh-token'); final tHeadlessRefresh = Uri.parse('$serverUrl/allauth/app/v1/tokens/refresh'); /// Builds a fresh ProviderContainer with the mock HTTP client wired into @@ -135,11 +133,15 @@ void main() { final prefs = PreferenceHelper.asyncPref; await prefs.clear(); - // Persist a logged-in user so auto-login actually runs. - await prefs.setString( - PREFS_USER, - json.encode({'token': token, 'serverUrl': serverUrl}), + // Persist a logged-in user so auto-login actually runs. The expiry sits + // far enough in the future that no test trips the pre-emptive refresh + // unless it re-seeds the bundle itself. + await prefs.setString(PREFS_ACCESS_TOKEN, accessToken); + await prefs.setInt( + PREFS_ACCESS_EXPIRES_AT, + DateTime.now().add(const Duration(hours: 1)).millisecondsSinceEpoch, ); + await prefs.setString(PREFS_SERVER_URL, serverUrl); // Default happy-path mocks. Individual tests override what they need. when( @@ -163,13 +165,6 @@ void main() { // /ps/. A 404 keeps the "unreachable" scenarios unreachable; // fallback-specific tests override this. when(mockClient.get(tFallbackLiveness)).thenAnswer((_) async => Response('not found', 404)); - - // Default: the legacy-DRF → JWT migration POST silently fails as - // "offline" so existing tests that seed PREFS_USER fall through to the - // legacy code path unchanged. Migration-specific tests override this. - when( - mockClient.post(tIssueRefresh, headers: anyNamed('headers')), - ).thenThrow(http.ClientException('SocketException: stub default')); }); group('restored session (ever-synced)', () { @@ -184,7 +179,7 @@ void main() { final state = await container.read(authProvider.future); expect(state.status, AuthStatus.loggedIn); - expect((state.credential as LegacyCredential).token, token); + expect(state.credential!.accessToken, accessToken); expect(state.serverUrl, serverUrl); // The startup path must not touch the network at all; every probe @@ -233,7 +228,7 @@ void main() { await container.read(authProvider.notifier).revalidationDone; expect(container.read(authProvider).value?.status, AuthStatus.loggedIn); - expect(await PreferenceHelper.asyncPref.containsKey(PREFS_USER), true); + expect(await PreferenceHelper.asyncPref.containsKey(PREFS_ACCESS_TOKEN), true); }); test('token rejected (401) → session cleared but local DB kept', () async { @@ -252,7 +247,7 @@ void main() { await container.read(authProvider.notifier).revalidationDone; expect(container.read(authProvider).value?.status, AuthStatus.loggedOut); - expect(await PreferenceHelper.asyncPref.containsKey(PREFS_USER), false); + expect(await PreferenceHelper.asyncPref.containsKey(PREFS_ACCESS_TOKEN), false); // PREFS_HAS_EVER_SYNCED stays so the next auto-login takes the offline // path and the cached DB stays usable. expect(await PreferenceHelper.asyncPref.getBool(PREFS_HAS_EVER_SYNCED), true); @@ -270,7 +265,7 @@ void main() { // Regression (bug #2): a transient 5xx must not invalidate the session. expect(container.read(authProvider).value?.status, AuthStatus.loggedIn); - expect(await PreferenceHelper.asyncPref.containsKey(PREFS_USER), true); + expect(await PreferenceHelper.asyncPref.containsKey(PREFS_ACCESS_TOKEN), true); }); test('probe returns 502 → stays logged in', () async { @@ -284,7 +279,7 @@ void main() { await container.read(authProvider.notifier).revalidationDone; expect(container.read(authProvider).value?.status, AuthStatus.loggedIn); - expect(await PreferenceHelper.asyncPref.containsKey(PREFS_USER), true); + expect(await PreferenceHelper.asyncPref.containsKey(PREFS_ACCESS_TOKEN), true); }); test('network error → stays logged in', () async { @@ -298,7 +293,7 @@ void main() { await container.read(authProvider.notifier).revalidationDone; expect(container.read(authProvider).value?.status, AuthStatus.loggedIn); - expect(await PreferenceHelper.asyncPref.containsKey(PREFS_USER), true); + expect(await PreferenceHelper.asyncPref.containsKey(PREFS_ACCESS_TOKEN), true); }); test('server version too old → state moves to serverUpdateRequired', () async { @@ -411,7 +406,7 @@ void main() { expect(state.status, AuthStatus.powerSyncUnreachable); // Saved credentials must be preserved so the recovery screen's // "Try again" button can re-run the flow without a re-login. - expect((state.credential as LegacyCredential).token, token); + expect(state.credential!.accessToken, accessToken); expect(state.serverUrl, serverUrl); }); @@ -496,7 +491,7 @@ void main() { // A saved session must carry the user straight into the app instead of // stalling on a recovery screen. expect(state.status, AuthStatus.loggedIn); - expect((state.credential as LegacyCredential).token, token); + expect(state.credential!.accessToken, accessToken); expect(state.serverUrl, serverUrl); // No further server calls when Django itself is unreachable. verifyNever(mockClient.get(tPowerSyncToken, headers: anyNamed('headers'))); @@ -511,7 +506,7 @@ void main() { final state = await container.read(authProvider.future); expect(state.status, AuthStatus.loggedOut); - expect(await PreferenceHelper.asyncPref.containsKey(PREFS_USER), false); + expect(await PreferenceHelper.asyncPref.containsKey(PREFS_ACCESS_TOKEN), false); }); test('Django HEAD returns 500 → stays logged in, session kept', () async { @@ -524,8 +519,8 @@ void main() { // Regression (bug #2): a transient 5xx must not log the user out. expect(state.status, AuthStatus.loggedIn); - expect((state.credential as LegacyCredential).token, token); - expect(await PreferenceHelper.asyncPref.containsKey(PREFS_USER), true); + expect(state.credential!.accessToken, accessToken); + expect(await PreferenceHelper.asyncPref.containsKey(PREFS_ACCESS_TOKEN), true); // A broken server means the rest of the gating chain is skipped. verifyNever(mockClient.get(tPowerSyncToken, headers: anyNamed('headers'))); }); @@ -539,7 +534,7 @@ void main() { final state = await container.read(authProvider.future); expect(state.status, AuthStatus.loggedIn); - expect(await PreferenceHelper.asyncPref.containsKey(PREFS_USER), true); + expect(await PreferenceHelper.asyncPref.containsKey(PREFS_ACCESS_TOKEN), true); }); }); @@ -589,7 +584,7 @@ void main() { await container.read(authProvider.notifier).logout(); expect(await PreferenceHelper.asyncPref.containsKey(PREFS_HAS_EVER_SYNCED), false); - expect(await PreferenceHelper.asyncPref.containsKey(PREFS_USER), false); + expect(await PreferenceHelper.asyncPref.containsKey(PREFS_ACCESS_TOKEN), false); }, ); @@ -598,7 +593,6 @@ void main() { final prefs = PreferenceHelper.asyncPref; await prefs.setString(PREFS_ACCESS_TOKEN, 'jwt-access'); await prefs.setInt(PREFS_ACCESS_EXPIRES_AT, 1700000000); - await prefs.setString(PREFS_TOKEN_TYPE, AuthTokenType.headlessJwt.name); await prefs.setString(PREFS_SERVER_URL, serverUrl); final container = makeContainer(); @@ -608,7 +602,6 @@ void main() { expect(await prefs.containsKey(PREFS_ACCESS_TOKEN), false); expect(await prefs.containsKey(PREFS_ACCESS_EXPIRES_AT), false); - expect(await prefs.containsKey(PREFS_TOKEN_TYPE), false); expect(await prefs.containsKey(PREFS_SERVER_URL), false); verify(mockSecureStorage.deleteRefreshToken()).called(1); }); @@ -666,7 +659,7 @@ void main() { // Credentials are still cleared (the user logged out), but the marker // survives because the data was not actually removed. expect(container.read(authProvider).value?.status, AuthStatus.loggedOut); - expect(await prefs.containsKey(PREFS_USER), false); + expect(await prefs.containsKey(PREFS_ACCESS_TOKEN), false); expect(await prefs.getString(PREFS_DB_OWNER_USER_ID), '7'); }); }); @@ -686,7 +679,7 @@ void main() { // ever-synced flag) survives so the same user resumes incrementally. expect(await prefs.getString(PREFS_DB_OWNER_USER_ID), '7'); expect(await prefs.getBool(PREFS_HAS_EVER_SYNCED), true); - expect(await prefs.containsKey(PREFS_USER), false); + expect(await prefs.containsKey(PREFS_ACCESS_TOKEN), false); }); test('off wipes the DB owner marker and the ever-synced flag', () async { @@ -705,46 +698,23 @@ void main() { }); }); - group('_tryAutoLogin: headless-JWT migration', () { - /// Replaces the legacy seed from setUp with the headless-JWT bundle. - Future seedHeadlessBundle({String accessToken = 'jwt-access'}) async { - final prefs = PreferenceHelper.asyncPref; - await prefs.remove(PREFS_USER); - await prefs.setString(PREFS_ACCESS_TOKEN, accessToken); - // Far in the future so we don't trip on expiry, refresh isn't wired yet. - await prefs.setInt( - PREFS_ACCESS_EXPIRES_AT, - DateTime.now().add(const Duration(hours: 1)).millisecondsSinceEpoch, - ); - await prefs.setString(PREFS_TOKEN_TYPE, AuthTokenType.headlessJwt.name); - await prefs.setString(PREFS_SERVER_URL, serverUrl); - } - - test('prefers the headless bundle over PREFS_USER and probes with Bearer', () async { - // Both formats present, headless must win. - await seedHeadlessBundle(accessToken: 'jwt-access'); - await PreferenceHelper.asyncPref.setString( - PREFS_USER, - json.encode({'token': 'legacy-token', 'serverUrl': serverUrl}), - ); - + group('_tryAutoLogin: stored credentials', () { + test('probes with the stored access token as a Bearer header', () async { final container = makeContainer(); final state = await container.read(authProvider.future); expect(state.status, AuthStatus.loggedIn); - expect(state.credential, isA()); - expect((state.credential as JwtCredential).accessToken, 'jwt-access'); + expect(state.credential!.accessToken, accessToken); final captured = verify( mockClient.head(tProbe, headers: captureAnyNamed('headers')), ).captured.single as Map; - expect(captured[HttpHeaders.authorizationHeader], 'Bearer jwt-access'); + expect(captured[HttpHeaders.authorizationHeader], 'Bearer $accessToken'); }); - test('headless 401 wipes the new prefs keys and the secure-storage refresh token', () async { - await seedHeadlessBundle(); + test('401 wipes the prefs bundle and the secure-storage refresh token', () async { when( mockClient.head(tProbe, headers: anyNamed('headers')), ).thenAnswer((_) async => Response('Unauthorized', 401)); @@ -756,221 +726,32 @@ void main() { final prefs = PreferenceHelper.asyncPref; expect(await prefs.containsKey(PREFS_ACCESS_TOKEN), false); expect(await prefs.containsKey(PREFS_ACCESS_EXPIRES_AT), false); - expect(await prefs.containsKey(PREFS_TOKEN_TYPE), false); expect(await prefs.containsKey(PREFS_SERVER_URL), false); verify(mockSecureStorage.deleteRefreshToken()).called(1); }); - test('missing required headless keys fall through to the legacy PREFS_USER path', () async { - // PREFS_TOKEN_TYPE set, but PREFS_ACCESS_TOKEN missing → fall through. - final prefs = PreferenceHelper.asyncPref; - await prefs.setString(PREFS_TOKEN_TYPE, AuthTokenType.headlessJwt.name); - // PREFS_USER seeded by setUp. - - final container = makeContainer(); - final state = await container.read(authProvider.future); - - expect(state.status, AuthStatus.loggedIn); - expect(state.credential, isA()); - expect((state.credential as LegacyCredential).token, token); - - final captured = - verify( - mockClient.head(tProbe, headers: captureAnyNamed('headers')), - ).captured.single - as Map; - expect(captured[HttpHeaders.authorizationHeader], 'Token $token'); - }); - }); - - group('_tryAutoLogin: legacy-to-JWT migration', () { - String makeJwt(Map payload) { - String enc(Map m) => - base64Url.encode(utf8.encode(jsonEncode(m))).replaceAll('=', ''); - return '${enc({'alg': 'HS256', 'typ': 'JWT'})}.${enc(payload)}.signature'; - } - - /// Stubs the two-step migration: issue-refresh-token returns - /// [mintedRefresh], tokens/refresh returns [accessJwt] / [rotatedRefresh]. - void stubMigrationSuccess({ - required String mintedRefresh, - required String accessJwt, - String rotatedRefresh = 'rotated-refresh', - }) { - when( - mockClient.post(tIssueRefresh, headers: anyNamed('headers')), - ).thenAnswer( - (_) async => Response(jsonEncode({'refresh_token': mintedRefresh}), 200), - ); - when( - mockClient.post( - tHeadlessRefresh, - headers: anyNamed('headers'), - body: anyNamed('body'), - ), - ).thenAnswer( - (_) async => Response( - jsonEncode({ - 'status': 200, - 'data': {'access_token': accessJwt, 'refresh_token': rotatedRefresh}, - 'meta': {'is_authenticated': true}, - }), - 200, - ), - ); - } - - test('happy path: DRF token swapped for JWT bundle, PREFS_USER wiped', () async { - // Legacy blob is already seeded in the outer setUp. The migration - // round-trip must replace it with the headless-JWT bundle and the - // refresh token must land in secure storage. - final accessJwt = makeJwt({'sub': '42', 'exp': 1900000000}); - stubMigrationSuccess( - mintedRefresh: 'minted-refresh', - accessJwt: accessJwt, - rotatedRefresh: 'rotated-refresh', - ); - - final container = makeContainer(); - await container.read(authProvider.future); - - final state = container.read(authProvider).value!; - expect(state.status, AuthStatus.loggedIn); - expect(state.credential, isA()); - expect((state.credential as JwtCredential).accessToken, accessJwt); - - final prefs = PreferenceHelper.asyncPref; - expect(await prefs.containsKey(PREFS_USER), false); - expect(await prefs.getString(PREFS_ACCESS_TOKEN), accessJwt); - // The migrated user claims DB ownership so a later different-user login - // still triggers a wipe. - expect(await prefs.getString(PREFS_DB_OWNER_USER_ID), '42'); - verify(mockSecureStorage.writeRefreshToken('rotated-refresh')).called(1); - - // The migration POST must have been authenticated with the legacy - // header — otherwise the backend can't identify the user. - final captured = - verify( - mockClient.post(tIssueRefresh, headers: captureAnyNamed('headers')), - ).captured.single - as Map; - expect(captured[HttpHeaders.authorizationHeader], 'Token $token'); - }); - - test('network error keeps the legacy DRF token in place for the next start', () async { - // The outer setUp's default stub already throws ClientException. - // The user must end up logged in via the legacy code path so the - // app stays usable offline; PREFS_USER stays so the next start - // retries the migration. - final container = makeContainer(); - await container.read(authProvider.future); - - final state = container.read(authProvider).value!; - expect(state.status, AuthStatus.loggedIn); - expect(state.credential, isA()); - expect(await PreferenceHelper.asyncPref.containsKey(PREFS_USER), true); - // No headless prefs must have been written. - verifyNever(mockSecureStorage.writeRefreshToken(any)); - }); - - test('401 on the exchange endpoint wipes the legacy blob (token revoked)', () async { - // A 401 here is the unambiguous "this DRF token is no longer valid" - // signal: server-side revoked or user deleted. We wipe the blob so - // the next start drops to the login screen instead of looping. - when( - mockClient.post(tIssueRefresh, headers: anyNamed('headers')), - ).thenAnswer((_) async => Response('Unauthorized', 401)); + test('a half-written bundle (no access token) resolves to logged out', () async { + await PreferenceHelper.asyncPref.remove(PREFS_ACCESS_TOKEN); final container = makeContainer(); final state = await container.read(authProvider.future); expect(state.status, AuthStatus.loggedOut); - expect(await PreferenceHelper.asyncPref.containsKey(PREFS_USER), false); - // tokens/refresh must not have been called: we never got a refresh - // token to exchange. - verifyNever( - mockClient.post( - tHeadlessRefresh, - headers: anyNamed('headers'), - body: anyNamed('body'), - ), - ); - }); - - test('5xx on the exchange endpoint keeps the legacy blob for retry', () async { - // Transient server issue: must not invalidate the local session. - // The user keeps working with the DRF token; the next start retries. - when( - mockClient.post(tIssueRefresh, headers: anyNamed('headers')), - ).thenAnswer((_) async => Response('Service Unavailable', 503)); - - final container = makeContainer(); - final state = await container.read(authProvider.future); - - expect(state.status, AuthStatus.loggedIn); - expect(state.credential, isA()); - expect(await PreferenceHelper.asyncPref.containsKey(PREFS_USER), true); - }); - - test('malformed exchange response keeps the legacy blob for retry', () async { - // 200 but no refresh_token in the body. Treated like a transient - // failure: keep using the DRF token, retry next start. - when( - mockClient.post(tIssueRefresh, headers: anyNamed('headers')), - ).thenAnswer((_) async => Response('{}', 200)); - - final container = makeContainer(); - final state = await container.read(authProvider.future); - - expect(state.status, AuthStatus.loggedIn); - expect(state.credential, isA()); - expect(await PreferenceHelper.asyncPref.containsKey(PREFS_USER), true); - }); - - test('refresh-exchange failure leaves the legacy blob intact', () async { - // issue-refresh-token succeeds, but the follow-up call to the - // headless tokens/refresh endpoint returns 5xx. We don't commit a - // half-migrated state: PREFS_USER stays, the user continues with - // DRF, the next start retries from step 1. - when( - mockClient.post(tIssueRefresh, headers: anyNamed('headers')), - ).thenAnswer( - (_) async => Response(jsonEncode({'refresh_token': 'minted'}), 200), - ); - when( - mockClient.post( - tHeadlessRefresh, - headers: anyNamed('headers'), - body: anyNamed('body'), - ), - ).thenAnswer((_) async => Response('upstream error', 502)); - - final container = makeContainer(); - final state = await container.read(authProvider.future); - - expect(state.credential, isA()); - expect(await PreferenceHelper.asyncPref.containsKey(PREFS_USER), true); - verifyNever(mockSecureStorage.writeRefreshToken(any)); + expect(state.credential, isNull); + verifyNever(mockClient.head(tProbe, headers: anyNamed('headers'))); }); - test('no legacy blob → migration is a no-op', () async { - // Existing headless-JWT user (or fresh install): the helper must - // not touch the network at all. + test('a leftover pre-JWT credential blob is deleted on startup', () async { final prefs = PreferenceHelper.asyncPref; - await prefs.remove(PREFS_USER); - await prefs.setString(PREFS_ACCESS_TOKEN, 'existing-jwt'); - await prefs.setInt( - PREFS_ACCESS_EXPIRES_AT, - DateTime.now().add(const Duration(hours: 1)).millisecondsSinceEpoch, + await prefs.setString( + PREFS_USER, + json.encode({'token': 'stale-drf-token', 'serverUrl': serverUrl}), ); - await prefs.setString(PREFS_TOKEN_TYPE, AuthTokenType.headlessJwt.name); - await prefs.setString(PREFS_SERVER_URL, serverUrl); - await prefs.setBool(PREFS_HAS_EVER_SYNCED, true); final container = makeContainer(); await container.read(authProvider.future); - verifyNever(mockClient.post(tIssueRefresh, headers: anyNamed('headers'))); + expect(await prefs.containsKey(PREFS_USER), false); }); }); @@ -993,13 +774,11 @@ void main() { // online, and the connectivity-triggered revalidation must refresh // first instead of wasting the still-valid refresh token on a 401. final prefs = PreferenceHelper.asyncPref; - await prefs.remove(PREFS_USER); await prefs.setString(PREFS_ACCESS_TOKEN, 'expired-access'); await prefs.setInt( PREFS_ACCESS_EXPIRES_AT, DateTime.now().subtract(const Duration(hours: 1)).millisecondsSinceEpoch, ); - await prefs.setString(PREFS_TOKEN_TYPE, AuthTokenType.headlessJwt.name); await prefs.setString(PREFS_SERVER_URL, serverUrl); await prefs.setBool(PREFS_HAS_EVER_SYNCED, true); @@ -1053,13 +832,11 @@ void main() { Future seedHeadlessBundle() async { final prefs = PreferenceHelper.asyncPref; - await prefs.remove(PREFS_USER); await prefs.setString(PREFS_ACCESS_TOKEN, 'old-access'); await prefs.setInt( PREFS_ACCESS_EXPIRES_AT, DateTime.now().add(const Duration(hours: 1)).millisecondsSinceEpoch, ); - await prefs.setString(PREFS_TOKEN_TYPE, AuthTokenType.headlessJwt.name); await prefs.setString(PREFS_SERVER_URL, serverUrl); } @@ -1320,13 +1097,11 @@ void main() { /// session whose access token expires at [expiresAt]. Future seedJwtSession({required DateTime expiresAt}) async { final prefs = PreferenceHelper.asyncPref; - await prefs.remove(PREFS_USER); await prefs.setString( PREFS_ACCESS_TOKEN, makeJwt({'sub': '42', 'exp': expiresAt.millisecondsSinceEpoch ~/ 1000}), ); await prefs.setInt(PREFS_ACCESS_EXPIRES_AT, expiresAt.millisecondsSinceEpoch); - await prefs.setString(PREFS_TOKEN_TYPE, AuthTokenType.headlessJwt.name); await prefs.setString(PREFS_SERVER_URL, serverUrl); } diff --git a/test/core/network/server_gating_test.dart b/test/core/network/server_gating_test.dart index 7c2e8688a..0df82f0d9 100644 --- a/test/core/network/server_gating_test.dart +++ b/test/core/network/server_gating_test.dart @@ -140,7 +140,7 @@ void main() { group('isPowerSyncReachable', () { const serverUrl = 'https://wger.example.com'; - const credential = AuthCredential.legacy('abc123'); + const credential = JwtCredential(accessToken: 'abc123'); /// Gating whose token endpoint answers [tokenBody] and whose liveness /// probes answer 200 only for [liveUrl]. Every requested URL is diff --git a/test/core/recovery_screens_test.dart b/test/core/recovery_screens_test.dart index 9285196f4..5e74f937f 100644 --- a/test/core/recovery_screens_test.dart +++ b/test/core/recovery_screens_test.dart @@ -50,7 +50,7 @@ void main() { late MockSecureTokenStorage mockSecureStorage; const serverUrl = 'https://wger.example'; - const token = 'token-12345'; + const accessToken = 'access-token-12345'; const powerSyncUrl = 'https://ps.example/'; final tProbe = Uri.parse('$serverUrl/api/v2/routine/'); @@ -58,7 +58,6 @@ void main() { final tMinAppVersion = Uri.parse('$serverUrl/api/v2/min-app-version/'); final tPowerSyncToken = Uri.parse('$serverUrl/api/v2/powersync-token'); final tLiveness = Uri.parse('${powerSyncUrl}probes/liveness'); - final tIssueRefresh = Uri.parse('$serverUrl/api/v2/issue-refresh-token'); Widget wrap(Widget child) { return ProviderScope( @@ -113,10 +112,12 @@ void main() { await prefs.clear(); // Saved login → autoLogin runs the full probe path. - await prefs.setString( - PREFS_USER, - json.encode({'token': token, 'serverUrl': serverUrl}), + await prefs.setString(PREFS_ACCESS_TOKEN, accessToken); + await prefs.setInt( + PREFS_ACCESS_EXPIRES_AT, + DateTime.now().add(const Duration(hours: 1)).millisecondsSinceEpoch, ); + await prefs.setString(PREFS_SERVER_URL, serverUrl); // Default happy-path mocks. Test groups override one of these to // steer the auth notifier into the targeted recovery state. @@ -134,14 +135,6 @@ void main() { ), ); when(mockClient.get(tLiveness)).thenAnswer((_) async => Response('OK', 200)); - - // Legacy → JWT auto-migration runs on every auto-login. These tests - // intentionally drive the *legacy* recovery flow, so stub the - // migration to a clean "offline" failure: the helper short-circuits - // and the rest of the auto-login proceeds against the DRF token. - when( - mockClient.post(tIssueRefresh, headers: anyNamed('headers')), - ).thenThrow(http.ClientException('SocketException: stub default')); }); group('PowerSyncUnreachableScreen', () { @@ -178,14 +171,14 @@ void main() { verify(mockClient.get(tLiveness)).called(1); }); - testWidgets('"Log out" wipes saved user and navigates to "/"', (tester) async { + testWidgets('"Log out" wipes saved credentials and navigates to "/"', (tester) async { await tester.pumpWidget(wrap(const PowerSyncUnreachableScreen())); await tester.pumpAndSettle(); await tester.tap(find.text('Log out')); await tester.pumpAndSettle(); - expect(await PreferenceHelper.asyncPref.containsKey(PREFS_USER), false); + expect(await PreferenceHelper.asyncPref.containsKey(PREFS_ACCESS_TOKEN), false); expect(find.text('AUTH_SCREEN_STUB'), findsOneWidget); }); }); diff --git a/test/core/validators_test.mocks.dart b/test/core/validators_test.mocks.dart index 2df3fa82d..c5de1eb65 100644 --- a/test/core/validators_test.mocks.dart +++ b/test/core/validators_test.mocks.dart @@ -503,6 +503,39 @@ class MockAppLocalizations extends _i1.Mock implements _i2.AppLocalizations { ) as String); + @override + String get allowSelfSignedCertsTitle => + (super.noSuchMethod( + Invocation.getter(#allowSelfSignedCertsTitle), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#allowSelfSignedCertsTitle), + ), + ) + as String); + + @override + String get allowSelfSignedCertsDetail => + (super.noSuchMethod( + Invocation.getter(#allowSelfSignedCertsDetail), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#allowSelfSignedCertsDetail), + ), + ) + as String); + + @override + String get certsNotVerifiedTitle => + (super.noSuchMethod( + Invocation.getter(#certsNotVerifiedTitle), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#certsNotVerifiedTitle), + ), + ) + as String); + @override String get authOptionPasswordTitle => (super.noSuchMethod( @@ -1662,6 +1695,14 @@ class MockAppLocalizations extends _i1.Mock implements _i2.AppLocalizations { ) as String); + @override + String get meal => + (super.noSuchMethod( + Invocation.getter(#meal), + returnValue: _i3.dummyValue(this, Invocation.getter(#meal)), + ) + as String); + @override String get mealLogged => (super.noSuchMethod( @@ -4250,6 +4291,17 @@ class MockAppLocalizations extends _i1.Mock implements _i2.AppLocalizations { ) as String); + @override + String get useDynamicColor => + (super.noSuchMethod( + Invocation.getter(#useDynamicColor), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#useDynamicColor), + ), + ) + as String); + @override String get youAreOffline => (super.noSuchMethod( @@ -4492,6 +4544,39 @@ class MockAppLocalizations extends _i1.Mock implements _i2.AppLocalizations { ) as String); + @override + String get syncStatusStalledHint => + (super.noSuchMethod( + Invocation.getter(#syncStatusStalledHint), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#syncStatusStalledHint), + ), + ) + as String); + + @override + String get syncStatusReconnect => + (super.noSuchMethod( + Invocation.getter(#syncStatusReconnect), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#syncStatusReconnect), + ), + ) + as String); + + @override + String get syncStatusNeverSynced => + (super.noSuchMethod( + Invocation.getter(#syncStatusNeverSynced), + returnValue: _i3.dummyValue( + this, + Invocation.getter(#syncStatusNeverSynced), + ), + ) + as String); + @override String get filterNutriscore => (super.noSuchMethod( @@ -4602,6 +4687,17 @@ class MockAppLocalizations extends _i1.Mock implements _i2.AppLocalizations { ) as String); + @override + String certsNotVerifiedDetail(String? host) => + (super.noSuchMethod( + Invocation.method(#certsNotVerifiedDetail, [host]), + returnValue: _i3.dummyValue( + this, + Invocation.method(#certsNotVerifiedDetail, [host]), + ), + ) + as String); + @override String exerciseNr(String? nr) => (super.noSuchMethod( @@ -4954,6 +5050,17 @@ class MockAppLocalizations extends _i1.Mock implements _i2.AppLocalizations { ) as String); + @override + String syncStatusPendingUploads(int? count) => + (super.noSuchMethod( + Invocation.method(#syncStatusPendingUploads, [count]), + returnValue: _i3.dummyValue( + this, + Invocation.method(#syncStatusPendingUploads, [count]), + ), + ) + as String); + @override String filterNutriscoreOrBetter(String? grade) => (super.noSuchMethod( diff --git a/test/features/routines/providers/routines_repository_test.mocks.dart b/test/features/routines/providers/routines_repository_test.mocks.dart index 65a35a37c..641d3fc16 100644 --- a/test/features/routines/providers/routines_repository_test.mocks.dart +++ b/test/features/routines/providers/routines_repository_test.mocks.dart @@ -1429,6 +1429,29 @@ class MockDriftPowersyncDatabase extends _i1.Mock implements _i4.DriftPowersyncD ) as _i3.GenerationContext); + @override + _i3.GenerationContext $writeUpdateInsertable( + _i3.TableInfo<_i3.Table, dynamic>? table, + _i3.Insertable? insertable, { + int? startIndex, + }) => + (super.noSuchMethod( + Invocation.method( + #$writeUpdateInsertable, + [table, insertable], + {#startIndex: startIndex}, + ), + returnValue: _FakeGenerationContext_52( + this, + Invocation.method( + #$writeUpdateInsertable, + [table, insertable], + {#startIndex: startIndex}, + ), + ), + ) + as _i3.GenerationContext); + @override String $expandVar(int? start, int? amount) => (super.noSuchMethod( diff --git a/test/screenshots/screenshots_01_dashboard.dart b/test/screenshots/screenshots_01_dashboard.dart index ed7e45792..4dd6c2008 100644 --- a/test/screenshots/screenshots_01_dashboard.dart +++ b/test/screenshots/screenshots_01_dashboard.dart @@ -115,7 +115,7 @@ Widget createDashboardScreen({Locale? locale}) { const loggedInAuth = AuthState( status: AuthStatus.loggedIn, - credential: LegacyCredential('test-token'), + credential: JwtCredential(accessToken: 'test-token'), serverUrl: 'http://localhost', ); final container = ProviderContainer.test(