Session Management
AFCore provides a reactive session state model that lets your app respond to login, logout, and session expiration events. Token refresh is fully automatic -- your app only needs to observe state changes and handle the SESSION_EXPIRED case.
SessionState Enum
public enum class SessionState {
/** No authenticated session. Initial state and state after logout. */
LOGGED_OUT,
/** A valid session was established via login or SSO. */
LOGGED_IN,
/** The session was invalidated server-side (token refresh failed).
* Consumer should prompt re-authentication. */
SESSION_EXPIRED
}
State Transitions
stateDiagram-v2
[*] --> LOGGED_OUT : SDK initialized
LOGGED_OUT --> LOGGED_IN : signInOrCreateMember() succeeds
LOGGED_IN --> LOGGED_OUT : logout()
LOGGED_IN --> SESSION_EXPIRED : Token refresh fails (unrecoverable)
SESSION_EXPIRED --> LOGGED_IN : User re-authenticates
SESSION_EXPIRED --> LOGGED_OUT : App calls logout() or reconfigure()
What Triggers Each State
| State | Trigger | Description |
|---|---|---|
LOGGED_OUT | SDK initialization | Initial state when AFCore is first initialized |
LOGGED_OUT | logout() | User explicitly signs out |
LOGGED_OUT | reconfigure() | SDK state is reset during reconfiguration |
LOGGED_IN | signInOrCreateMember() | Successful external user ID authentication |
SESSION_EXPIRED | Token refresh failure | The refresh token was rejected (401) and re-auth fallback failed |
Observing Session State
AFCore.sessionState is a StateFlow<SessionState> that emits the current session state and all subsequent changes. It always has a current value (it never starts empty).
Android (Kotlin)
Collect the flow in a ViewModel or lifecycle-aware scope:
// In a ViewModel
class MainViewModel : ViewModel() {
val sessionState = AFCore.sessionState
init {
viewModelScope.launch {
AFCore.sessionState.collect { state ->
when (state) {
SessionState.LOGGED_IN -> {
// User has a valid session
_uiState.value = UiState.Authenticated
}
SessionState.SESSION_EXPIRED -> {
// Session expired -- prompt re-login
_uiState.value = UiState.SessionExpired
}
SessionState.LOGGED_OUT -> {
// No session -- show login screen
_uiState.value = UiState.NotAuthenticated
}
}
}
}
}
}
Or collect in an Activity/Fragment with lifecycle awareness:
// In an Activity or Fragment
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
AFCore.sessionState.collect { state ->
when (state) {
SessionState.LOGGED_IN -> showHome()
SessionState.SESSION_EXPIRED -> showLoginWithMessage("Your session has expired")
SessionState.LOGGED_OUT -> showLogin()
}
}
}
}
iOS (Swift) -- Callback-Based
For UIKit apps or Objective-C interop, use the callback-based subscribeToSessionState() method. It returns a FlowSubscription that delivers updates on the main thread:
class SessionManager {
private var subscription: FlowSubscription?
func startObserving() {
subscription = AFCore.shared.subscribeToSessionState(
onState: { [weak self] state in
// Delivered on the main thread
switch state {
case .loggedIn:
self?.handleLoggedIn()
case .sessionExpired:
self?.handleSessionExpired()
case .loggedOut:
self?.handleLoggedOut()
default:
break
}
},
onError: { error in
print("Session state error: \(error)")
},
onComplete: {
print("Session state flow completed")
}
)
}
func stopObserving() {
subscription?.close()
subscription = nil
}
deinit {
subscription?.close()
}
}
iOS (Swift) -- Recommended Pattern
For SwiftUI apps, use the callback-based subscribeToSessionState() inside your view model:
@MainActor
class SessionViewModel: ObservableObject {
@Published var isLoggedIn = false
@Published var sessionExpired = false
private var subscription: FlowSubscription?
func startObserving() {
subscription = AFCore.shared.subscribeToSessionState(
onState: { [weak self] state in
switch state {
case .loggedIn:
self?.isLoggedIn = true
self?.sessionExpired = false
case .sessionExpired:
self?.isLoggedIn = false
self?.sessionExpired = true
case .loggedOut:
self?.isLoggedIn = false
self?.sessionExpired = false
default:
break
}
},
onError: { _ in },
onComplete: { }
)
}
deinit {
subscription?.close()
}
}
Handling SESSION_EXPIRED
When the session expires, your app should:
- Show a re-login prompt -- Do not silently redirect. Inform the user that their session has expired.
- Clear sensitive local state -- Remove any cached user data that should not persist without a valid session.
- Redirect to login -- Present the login screen.
- Android (Kotlin)
- iOS (Swift)
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
AFCore.sessionState
.filter { it == SessionState.SESSION_EXPIRED }
.collect {
// Clear any locally cached user data
userCache.clear()
// Show re-login prompt
MaterialAlertDialogBuilder(this@MainActivity)
.setTitle("Session Expired")
.setMessage("Your session has expired. Please sign in again.")
.setPositiveButton("Sign In") { _, _ ->
navigateToLogin()
}
.setCancelable(false)
.show()
}
}
}
// In your session management layer
func handleSessionExpired() {
// Clear locally cached user data
userCache.clear()
// Present re-login alert
let alert = UIAlertController(
title: "Session Expired",
message: "Your session has expired. Please sign in again.",
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "Sign In", style: .default) { _ in
self.navigateToLogin()
})
topViewController?.present(alert, animated: true)
}
Why Sessions Expire
A SESSION_EXPIRED state is emitted when the DefaultTokenManager cannot recover the session:
- The access token expires (normal -- happens regularly).
- The Auth plugin triggers a refresh.
- The refresh token is also expired or revoked.
- For credential-based auth: The manager cannot re-authenticate without the user's password, so it clears tokens and emits
SESSION_EXPIRED. - For external user ID auth: The manager attempts re-authentication with the stored external user ID. If this also fails, tokens are cleared and
SESSION_EXPIREDis emitted.
Token Refresh Is Automatic
Consumer apps do not need to manage tokens. The Ktor Auth plugin intercepts every request and ensures a valid token is attached:
- Tokens are refreshed proactively when within 5 minutes of expiry.
- Refresh failures use exponential backoff (1s to 30s) to avoid overwhelming the server.
- Concurrent refresh attempts are serialized via a Mutex -- only one refresh happens at a time.
- If a refresh is already in progress, other requests wait for it to complete rather than triggering their own refresh.
The only action your app needs to take is handling SESSION_EXPIRED when the token cannot be recovered.
isLoggedIn() vs sessionState
AFCore provides two ways to check authentication status:
| API | Type | Use Case |
|---|---|---|
isLoggedIn() | Point-in-time check | Quick guard before making an API call; initial routing logic |
sessionState | Reactive stream | UI that needs to update when session changes; background monitoring |
isLoggedIn()
Returns true if a non-blank access token exists in encrypted storage. This is a local check only -- it does not validate the token against the server.
// Use for initial routing
if (AFCore.isLoggedIn()) {
navigateToHome()
} else {
navigateToLogin()
}
sessionState
Emits the current state and all subsequent changes. This is the recommended approach for UI code that needs to react to session changes.
// Use for reactive UI
AFCore.sessionState.collect { state ->
updateUI(state)
}
Member ID
After successful authentication, the member's unique ID is available via AFCore.memberId():
- Android (Kotlin)
- iOS (Swift)
val memberId = AFCore.memberId()
if (memberId != null) {
// Use the member ID for analytics, logging, etc.
analytics.setUserId(memberId)
}
if let memberId = AFCore.shared.memberId() {
Analytics.setUserId(memberId)
}
The member ID:
- Is set during authentication and persisted in encrypted storage.
- Remains available across app restarts as long as the session is active.
- Returns
nullif no user is logged in. - Is cleared on
logout()andreconfigure().
FlowSubscription Lifecycle
When using the callback-based subscribeToSessionState() on iOS, the returned FlowSubscription manages a coroutine that collects the underlying StateFlow. It is important to close the subscription when you no longer need updates:
// Start observing
let subscription = AFCore.shared.subscribeToSessionState(
onState: { state in /* ... */ },
onError: { _ in },
onComplete: { }
)
// Stop observing (e.g., in deinit or viewDidDisappear)
subscription.close()
Failing to close the subscription will keep the coroutine alive, which may lead to:
- Callbacks delivered to deallocated objects.
- Unnecessary memory retention.
- Continued processing after the view controller is dismissed.
Best practice: Store the subscription as a property and close it in deinit or when the view disappears.