Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Auto refresh #166

Merged
merged 3 commits into from
Feb 2, 2025
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ written for Android. You can read more on the [Descope Website](https://descope.
Add the following to your `build.gradle` dependencies:

```groovy
implementation 'com.descope:descope-kotlin:0.12.2'
implementation 'com.descope:descope-kotlin:0.12.3'
```

## Quickstart
Expand Down
9 changes: 5 additions & 4 deletions descopesdk/src/main/java/com/descope/sdk/Config.kt
Original file line number Diff line number Diff line change
Expand Up @@ -57,16 +57,17 @@ open class DescopeLogger(private val level: Level = Level.Debug) {
* @param message the message to print
* @param values any associated values. _**IMPORTANT** - sensitive information may be printed here. Enable logs only when debugging._
*/
open fun output(level: Level, message: String, vararg values: Any) {
open fun output(level: Level, message: String, vararg values: Any?) {
var text = "[${DescopeSdk.name}] $message"
if (values.isNotEmpty()) {
text += """ (${values.joinToString(", ") { v -> v.toString() }})"""
val filtered = values.filterNotNull()
if (filtered.isNotEmpty()) {
text += """ (${filtered.joinToString(", ") { v -> v.toString() }})"""
}
println(text);
}

// Called by other code in the Descope SDK to output log messages.
fun log(level: Level, message: String, vararg values: Any) {
fun log(level: Level, message: String, vararg values: Any?) {
if (level <= this.level) {
output(level, message, *values)
}
Expand Down
4 changes: 2 additions & 2 deletions descopesdk/src/main/java/com/descope/sdk/Sdk.kt
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ class DescopeSdk(context: Context, projectId: String, configure: DescopeConfig.(

private fun initDefaultManager(context: Context, config: DescopeConfig): DescopeSessionManager {
val storage = SessionStorage(context.applicationContext, config.projectId, config.logger)
val lifecycle = SessionLifecycle(auth)
val lifecycle = SessionLifecycle(auth, storage, config.logger)
return DescopeSessionManager(storage, lifecycle)
}

Expand All @@ -71,6 +71,6 @@ class DescopeSdk(context: Context, projectId: String, configure: DescopeConfig.(
const val name = "DescopeAndroid"

/** The Descope SDK version */
const val version = "0.12.2"
const val version = "0.12.3"
}
}
70 changes: 55 additions & 15 deletions descopesdk/src/main/java/com/descope/session/Lifecycle.kt
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
package com.descope.session

import android.annotation.SuppressLint
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import com.descope.sdk.DescopeAuth
import com.descope.sdk.DescopeLogger
import com.descope.sdk.DescopeLogger.Level.Debug
import com.descope.sdk.DescopeLogger.Level.Error
import com.descope.sdk.DescopeLogger.Level.Info
import com.descope.types.DescopeException
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
Expand All @@ -19,11 +25,11 @@ const val SECOND = 1000L
* manages its [DescopeSession] while the application is running.
*/
interface DescopeSessionLifecycle {
/** Set by the session manager whenever the current active session changes. */
/** Holds the latest session value for the session manager. */
var session: DescopeSession?

/** Called the session manager to conditionally refresh the active session. */
suspend fun refreshSessionIfNeeded()
/** Called by the session manager to conditionally refresh the active session. */
suspend fun refreshSessionIfNeeded(): Boolean
}

/**
Expand All @@ -36,10 +42,15 @@ interface DescopeSessionLifecycle {
*
* @property auth used to refresh the session when needed
*/
class SessionLifecycle(private val auth: DescopeAuth) : DescopeSessionLifecycle {
class SessionLifecycle(
private val auth: DescopeAuth,
private val storage: DescopeSessionStorage,
private val logger: DescopeLogger?,
) : DescopeSessionLifecycle {

var shouldSaveAfterPeriodicRefresh: Boolean = true
var stalenessAllowedInterval: Long = 60L /* seconds */ * SECOND
var stalenessCheckFrequency: Long = 30L /* seconds */ * SECOND
var periodicCheckFrequency: Long = 30L /* seconds */ * SECOND

init {
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
Expand All @@ -64,15 +75,23 @@ class SessionLifecycle(private val auth: DescopeAuth) : DescopeSessionLifecycle
} else {
startTimer()
}
if (value?.refreshToken?.isExpired == true) {
logger?.log(Info, "Session has an expired refresh token", session?.refreshToken?.expiresAt)
}
}

override suspend fun refreshSessionIfNeeded() {
session?.run {
if (shouldRefresh(this)) {
val response = auth.refreshSession(refreshJwt) // TODO check for refresh failure to not try again and again after expiry
updateTokens(response)
override suspend fun refreshSessionIfNeeded(): Boolean {
val session = this.session ?: return false
return if (shouldRefresh(session)) {
logger?.log(Info, "Refreshing session that is about to expire", session.sessionToken.expiresAt)
val response = auth.refreshSession(session.refreshJwt)
if (this.session?.sessionJwt != session.sessionJwt) {
logger?.log(Info, "Skipping refresh because session has changed in the meantime")
return false
}
}
session.updateTokens(response)
true
} else false
}

// Internal
Expand All @@ -85,10 +104,11 @@ class SessionLifecycle(private val auth: DescopeAuth) : DescopeSessionLifecycle

private var timer: Timer? = null

@SuppressLint("DiscouragedApi")
@OptIn(DelicateCoroutinesApi::class)
private fun startTimer(runImmediately: Boolean = false) {
val weakRef = WeakReference(this)
val delay = if (runImmediately) 0L else stalenessCheckFrequency
val delay = if (runImmediately) 0L else periodicCheckFrequency
timer?.run { cancel(); purge() }
timer = Timer().apply {
scheduleAtFixedRate(timerTask {
Expand All @@ -97,13 +117,33 @@ class SessionLifecycle(private val auth: DescopeAuth) : DescopeSessionLifecycle
stopTimer()
return@timerTask
}
if (session?.refreshToken?.isExpired != false) {
logger?.log(Debug, "Stopping periodic refresh for session with expired refresh token")
stopTimer()
return@timerTask
}
GlobalScope.launch(Dispatchers.Main) {
try {
ref.refreshSessionIfNeeded()
} catch (ignored: Exception) {
val refreshed = ref.refreshSessionIfNeeded()
val session = session
if (refreshed && shouldSaveAfterPeriodicRefresh && session != null) {
logger?.log(Debug, "Saving refresh session after periodic refresh")
storage.saveSession(session)
}
} catch (descopeException: DescopeException) {
// allow retries on network errors
if (descopeException != DescopeException.networkError) {
logger?.log(Error, "Stopping periodic refresh after failure", descopeException)
stopTimer()
} else {
logger?.log(Debug, "Ignoring network error in periodic refresh")
}
} catch (e: Exception) {
logger?.log(Error, "Stopping periodic refresh after unexpected failure", e)
stopTimer()
}
}
}, delay, stalenessCheckFrequency)
}, delay, periodicCheckFrequency)
}
}

Expand Down
42 changes: 23 additions & 19 deletions descopesdk/src/main/java/com/descope/session/Manager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,11 @@ class DescopeSessionManager(
private val lifecycle: DescopeSessionLifecycle,
) {

var session: DescopeSession?
private set
val session: DescopeSession?
get() = lifecycle.session

init {
session = storage.loadSession()
lifecycle.session = session
lifecycle.session = storage.loadSession()
}

/**
Expand All @@ -100,9 +99,8 @@ class DescopeSessionManager(
* @param session the session to manage
*/
fun manageSession(session: DescopeSession) {
this.session = session
lifecycle.session = session
storage.saveSession(session)
saveSession()
}

/**
Expand All @@ -120,11 +118,22 @@ class DescopeSessionManager(
* each other's saved sessions.
*/
fun clearSession() {
session = null
lifecycle.session = null
storage.removeSession()
}

/**
* Saves the active [DescopeSession] to the storage.
*
* - **Important**: There is usually no need to call this method directly.
* The session is automatically saved when it's refreshed or updated,
* unless you're using a session manager with custom `stroage` and
* `lifecycle` objects.
*/
fun saveSession() {
session?.run { storage.saveSession(this) }
}

/**
* Ensures that the session is valid and refreshes it if needed.
*
Expand All @@ -136,9 +145,8 @@ class DescopeSessionManager(
* here depends on the `storage` and `lifecycle` objects.
*/
suspend fun refreshSessionIfNeeded() {
val session = session ?: return
lifecycle.refreshSessionIfNeeded()
storage.saveSession(session)
val refreshed = lifecycle.refreshSessionIfNeeded()
if (refreshed) saveSession()
}

/**
Expand All @@ -159,10 +167,8 @@ class DescopeSessionManager(
* @param refreshResponse the response after calling `Descope.auth.refreshSession`
*/
fun updateTokens(refreshResponse: RefreshResponse) {
session?.run {
updateTokens(refreshResponse)
storage.saveSession(this)
}
session?.updateTokens(refreshResponse)
saveSession()
}

/**
Expand All @@ -181,10 +187,8 @@ class DescopeSessionManager(
* @param user the [DescopeUser] to update.
*/
fun updateUser(user: DescopeUser) {
session?.run {
updateUser(user)
storage.saveSession(this)
}
session?.updateUser(user)
saveSession()
}

}
28 changes: 14 additions & 14 deletions descopesdk/src/main/java/com/descope/session/Storage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -71,36 +71,36 @@ interface DescopeSessionStorage {
class SessionStorage(context: Context, private val projectId: String, logger: DescopeLogger? = null, store: Store? = null) : DescopeSessionStorage {

private val store: Store
private var lastValue: Value? = null
private var lastSaved: EncodedSession? = null

init {
this.store = store ?: createEncryptedStore(context, projectId, logger)
}

override fun saveSession(session: DescopeSession) {
val value = Value(
val encodedSession = EncodedSession(
sessionJwt = session.sessionJwt,
refreshJwt = session.refreshJwt,
user = session.user
)
if (value == lastValue) return
store.saveItem(key = projectId, data = value.serialized)
lastValue = value
if (encodedSession == lastSaved) return
store.saveItem(key = projectId, data = encodedSession.serialized)
lastSaved = encodedSession
}

override fun loadSession(): DescopeSession? =
store.loadItem(projectId)?.run {
val value = tryOrNull { Value.deserialize(this) } ?: return null
lastValue = value
val encodedSession = tryOrNull { EncodedSession.deserialize(this) } ?: return null
lastSaved = encodedSession
DescopeSession(
sessionJwt = value.sessionJwt,
refreshJwt = value.refreshJwt,
user = value.user,
sessionJwt = encodedSession.sessionJwt,
refreshJwt = encodedSession.refreshJwt,
user = encodedSession.user,
)
}

override fun removeSession() {
lastValue = null
lastSaved = null
store.removeItem(projectId)
}

Expand All @@ -122,7 +122,7 @@ class SessionStorage(context: Context, private val projectId: String, logger: De
}
}

private data class Value(
private data class EncodedSession(
val sessionJwt: String,
val refreshJwt: String,
val user: DescopeUser,
Expand All @@ -135,8 +135,8 @@ class SessionStorage(context: Context, private val projectId: String, logger: De
}.toString()

companion object {
fun deserialize(string: String): Value = JSONObject(string).run {
Value(
fun deserialize(string: String): EncodedSession = JSONObject(string).run {
EncodedSession(
sessionJwt = getString("sessionJwt"),
refreshJwt = getString("refreshJwt"),
user = deserializeDescopeUser(getJSONObject("user"))
Expand Down
Loading