Skip to main content

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

StateTriggerDescription
LOGGED_OUTSDK initializationInitial state when AFCore is first initialized
LOGGED_OUTlogout()User explicitly signs out
LOGGED_OUTreconfigure()SDK state is reset during reconfiguration
LOGGED_INsignInOrCreateMember()Successful external user ID authentication
SESSION_EXPIREDToken refresh failureThe 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()
}
}

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:

  1. Show a re-login prompt -- Do not silently redirect. Inform the user that their session has expired.
  2. Clear sensitive local state -- Remove any cached user data that should not persist without a valid session.
  3. Redirect to login -- Present the login screen.
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()
}
}
}

Why Sessions Expire

A SESSION_EXPIRED state is emitted when the DefaultTokenManager cannot recover the session:

  1. The access token expires (normal -- happens regularly).
  2. The Auth plugin triggers a refresh.
  3. The refresh token is also expired or revoked.
  4. For credential-based auth: The manager cannot re-authenticate without the user's password, so it clears tokens and emits SESSION_EXPIRED.
  5. 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_EXPIRED is 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:

APITypeUse Case
isLoggedIn()Point-in-time checkQuick guard before making an API call; initial routing logic
sessionStateReactive streamUI 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():

val memberId = AFCore.memberId()
if (memberId != null) {
// Use the member ID for analytics, logging, etc.
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 null if no user is logged in.
  • Is cleared on logout() and reconfigure().

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.