Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 4 additions & 7 deletions lib/core/consts.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down
60 changes: 19 additions & 41 deletions lib/core/network/auth_credential.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 <jwt>`. 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 <key>`. 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 <jwt>`. 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();
}
Loading