Skip to content

Commit

Permalink
Session lifecycle updates (#83)
Browse files Browse the repository at this point in the history
  • Loading branch information
shilgapira authored Jan 7, 2025
1 parent ec9524f commit 0d71359
Show file tree
Hide file tree
Showing 7 changed files with 109 additions and 43 deletions.
10 changes: 5 additions & 5 deletions src/internal/http/DescopeClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -437,10 +437,10 @@ final class DescopeClient: HTTPClient, @unchecked Sendable {
user?.setCustomAttributes(from: dict)
}
if sessionJwt == nil || sessionJwt == "" {
sessionJwt = try findTokenCookie(named: sessionCookieName, in: cookies)
sessionJwt = findTokenCookie(named: sessionCookieName, in: cookies)
}
if refreshJwt == nil || refreshJwt == "" {
refreshJwt = try findTokenCookie(named: refreshCookieName, in: cookies)
refreshJwt = findTokenCookie(named: refreshCookieName, in: cookies)
}
}
}
Expand Down Expand Up @@ -579,10 +579,10 @@ private extension DeliveryMethod {
}
}

private func findTokenCookie(named name: String, in cookies: [HTTPCookie]) throws(DescopeError) -> String {
private func findTokenCookie(named name: String, in cookies: [HTTPCookie]) -> String? {
// keep only cookies matching the required name
let cookies = cookies.filter { name.caseInsensitiveCompare($0.name) == .orderedSame }
guard !cookies.isEmpty else { throw DescopeError.decodeError.with(message: "Missing value for \(name) cookie") }
guard !cookies.isEmpty else { return nil }

// try to make a deterministic choice between cookies by looking for the best matching token
let tokens = cookies.compactMap { try? Token(jwt: $0.value) }.sorted { a, b in
Expand All @@ -591,7 +591,7 @@ private func findTokenCookie(named name: String, in cookies: [HTTPCookie]) throw
}

// try to find the best match by prioritizing the newest non-expired token
guard let token = tokens.first else { throw DescopeError.decodeError.with(message: "Invalid value for \(name) cookie") }
guard let token = tokens.first else { return nil }

return token.jwt
}
2 changes: 1 addition & 1 deletion src/sdk/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public struct DescopeConfig {
/// This property can be useful to test code that uses the Descope SDK without any
/// network requests actually taking place. In most other cases there shouldn't be
/// any need to use it.
public var networkClient: DescopeNetworkClient? = nil
public var networkClient: DescopeNetworkClient?
}

/// The ``DescopeLogger`` class can be used to customize logging functionality in the Descope SDK.
Expand Down
6 changes: 3 additions & 3 deletions src/sdk/SDK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ public class DescopeSDK {
/// override the ``DescopeSDK`` object's default networking client with one that always
/// fails, using code such as this (see ``DescopeNetworkClient``):
///
/// let descope = DescopeSDK(projectId: "") { config in
/// let descope = DescopeSDK(projectId: "test") { config in
/// config.networkClient = FailingNetworkClient()
/// }
/// testOTPNetworkError(descope)
Expand Down Expand Up @@ -143,15 +143,15 @@ public extension DescopeSDK {
static let name = "DescopeKit"

/// The Descope SDK version
static let version = "0.9.10"
static let version = "0.9.11"
}

// Internal

private extension DescopeSessionManager {
convenience init(sdk: DescopeSDK) {
let storage = SessionStorage(projectId: sdk.config.projectId)
let lifecycle = SessionLifecycle(auth: sdk.auth)
let lifecycle = SessionLifecycle(auth: sdk.auth, storage: storage, logger: sdk.config.logger)
self.init(storage: storage, lifecycle: lifecycle)
}
}
44 changes: 36 additions & 8 deletions src/session/Lifecycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,32 +20,49 @@ public protocol DescopeSessionLifecycle: AnyObject {
/// or if it's already expired.
public class SessionLifecycle: DescopeSessionLifecycle {
public let auth: DescopeAuth
public let storage: DescopeSessionStorage
public let logger: DescopeLogger?

public init(auth: DescopeAuth) {
public init(auth: DescopeAuth, storage: DescopeSessionStorage, logger: DescopeLogger?) {
self.auth = auth
self.storage = storage
self.logger = logger
}

public var stalenessAllowedInterval: TimeInterval = 60 /* seconds */

public var stalenessCheckFrequency: TimeInterval = 30 /* seconds */ {
public var periodicCheckFrequency: TimeInterval = 30 /* seconds */ {
didSet {
if stalenessCheckFrequency != oldValue {
if periodicCheckFrequency != oldValue {
resetTimer()
}
}
}

public var shouldSaveAfterPeriodicRefresh: Bool = true

public var session: DescopeSession? {
didSet {
if session?.refreshJwt != oldValue?.refreshJwt {
resetTimer()
}
if let session, session.refreshToken.isExpired {
logger(.debug, "Session has an expired refresh token", session.refreshToken.expiresAt)
}
}
}

public func refreshSessionIfNeeded() async throws -> Bool {
guard let current = session, shouldRefresh(current) else { return false }

logger(.info, "Refreshing session that is about to expire", current.sessionToken.expiresAt.timeIntervalSinceNow)
let response = try await auth.refreshSession(refreshJwt: current.refreshJwt)

guard session?.sessionJwt == current.sessionJwt else {
logger(.info, "Skipping refresh because session has changed in the meantime")
return false
}

session?.updateTokens(with: response)
return true
}
Expand All @@ -61,7 +78,7 @@ public class SessionLifecycle: DescopeSessionLifecycle {
private var timer: Timer?

private func resetTimer() {
if stalenessCheckFrequency > 0, let refreshToken = session?.refreshToken, !refreshToken.isExpired {
if periodicCheckFrequency > 0, let refreshToken = session?.refreshToken, !refreshToken.isExpired {
startTimer()
} else {
stopTimer()
Expand All @@ -70,9 +87,9 @@ public class SessionLifecycle: DescopeSessionLifecycle {

private func startTimer() {
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: stalenessCheckFrequency, repeats: true) { [weak self] timer in
timer = Timer.scheduledTimer(withTimeInterval: periodicCheckFrequency, repeats: true) { [weak self] timer in
guard let lifecycle = self else { return timer.invalidate() }
Task { @MainActor in
Task {
await lifecycle.periodicRefresh()
}
}
Expand All @@ -84,11 +101,22 @@ public class SessionLifecycle: DescopeSessionLifecycle {
}

private func periodicRefresh() async {
if let refreshToken = session?.refreshToken, refreshToken.isExpired {
logger(.debug, "Stopping periodic refresh for session with expired refresh token")
stopTimer()
return
}

do {
_ = try await refreshSessionIfNeeded()
let refreshed = try await refreshSessionIfNeeded()
if refreshed, shouldSaveAfterPeriodicRefresh, let session {
logger(.debug, "Saving refresh session after periodic refresh")
storage.saveSession(session)
}
} catch DescopeError.networkError {
// allow retries on network errors
logger(.debug, "Ignoring network error in periodic refresh")
} catch {
logger(.error, "Stopping periodic refresh after failure", error)
stopTimer()
}
}
Expand Down
21 changes: 12 additions & 9 deletions src/session/Manager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ public class DescopeSessionManager {
/// each other's saved sessions.
public func manageSession(_ session: DescopeSession) {
lifecycle.session = session
storage.saveSession(session)
saveSession()
}

/// Clears any active ``DescopeSession`` from this manager and removes it
Expand All @@ -122,6 +122,17 @@ public class DescopeSessionManager {
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.
public func saveSession() {
guard let session else { return }
storage.saveSession(session)
}

/// Ensures that the session is valid and refreshes it if needed.
///
/// The session manager checks whether there's an active ``DescopeSession`` and if
Expand Down Expand Up @@ -170,12 +181,4 @@ public class DescopeSessionManager {
lifecycle.session?.updateUser(with: user)
saveSession()
}

// Internal

/// Saves the latest session value to the storage.
private func saveSession() {
guard let session else { return }
storage.saveSession(session)
}
}
46 changes: 29 additions & 17 deletions src/session/Storage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,31 +35,31 @@ public class SessionStorage: DescopeSessionStorage {
public let projectId: String
public let store: Store

private var lastValue: Value?
private var lastSaved: EncodedSession?

public init(projectId: String, store: Store = .keychain) {
self.projectId = projectId
self.store = store
}

public func saveSession(_ session: DescopeSession) {
let value = Value(sessionJwt: session.sessionJwt, refreshJwt: session.refreshJwt, user: session.user)
guard value != lastValue else { return }
guard let data = try? JSONEncoder().encode(value) else { return }
let encoded = EncodedSession(sessionJwt: session.sessionJwt, refreshJwt: session.refreshJwt, user: session.user)
guard lastSaved != encoded else { return }
guard let data = try? JSONEncoder().encode(encoded) else { return }
try? store.saveItem(key: projectId, data: data)
lastValue = value
lastSaved = encoded
}

public func loadSession() -> DescopeSession? {
guard let data = try? store.loadItem(key: projectId) else { return nil }
guard let value = try? JSONDecoder().decode(Value.self, from: data) else { return nil }
let session = try? DescopeSession(sessionJwt: value.sessionJwt, refreshJwt: value.refreshJwt, user: value.user)
lastValue = value
guard let encoded = try? JSONDecoder().decode(EncodedSession.self, from: data) else { return nil }
let session = try? DescopeSession(sessionJwt: encoded.sessionJwt, refreshJwt: encoded.refreshJwt, user: encoded.user)
lastSaved = encoded
return session
}

public func removeSession() {
lastValue = nil
lastSaved = nil
try? store.removeItem(key: projectId)
}

Expand All @@ -80,23 +80,35 @@ public class SessionStorage: DescopeSessionStorage {
}

/// A helper struct for serializing the ``DescopeSession`` data.
private struct Value: Codable, Equatable {
private struct EncodedSession: Codable, Equatable {
var sessionJwt: String
var refreshJwt: String
var user: DescopeUser
}
}

public extension SessionStorage.Store {
extension SessionStorage.Store {
/// A store that does nothing.
static let none = SessionStorage.Store()
public static let none = SessionStorage.Store()

/// A store that saves the session data to the keychain.
static let keychain = SessionStorage.KeychainStore()
public static let keychain = SessionStorage.KeychainStore()
}

public extension SessionStorage {
class KeychainStore: Store {
extension SessionStorage {
public class KeychainStore: Store {
#if os(iOS)
private let accessibility: String

public override init() {
self.accessibility = kSecAttrAccessibleAfterFirstUnlock as String
}

public init(accessibility: String) {
self.accessibility = accessibility
}
#endif

public override func loadItem(key: String) -> Data? {
var query = queryForItem(key: key)
query[kSecReturnData as String] = true
Expand All @@ -115,7 +127,7 @@ public extension SessionStorage {
#if os(macOS)
values[kSecAttrAccess as String] = SecAccessCreateWithOwnerAndACL(getuid(), 0, SecAccessOwnerType(kSecUseOnlyUID), nil, nil)
#else
values[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
values[kSecAttrAccessible as String] = accessibility
#endif

let query = queryForItem(key: key)
Expand Down
23 changes: 23 additions & 0 deletions test/routes/Auth.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,20 @@ class TestAuth: XCTestCase {
try checkUser(authResponse.user)
}

func testRefresh() async throws {
let descope = DescopeSDK.mock()

MockHTTP.push(body: authNoRefreshPayload) { request in
XCTAssertEqual(request.httpMethod, "POST")
XCTAssertEqual(request.url?.absoluteString ?? "", "https://api.descope.com/v1/auth/refresh")
XCTAssertEqual(request.allHTTPHeaderFields?["Authorization"], "Bearer projId:foo")
}

let refreshResponse = try await descope.auth.refreshSession(refreshJwt: "foo")
XCTAssertEqual("bar", refreshResponse.sessionToken.entityId)
XCTAssertNil(refreshResponse.refreshToken)
}

func checkUser(_ user: DescopeUser) throws {
XCTAssertEqual("userId", user.userId)
XCTAssertFalse(user.isVerifiedPhone)
Expand Down Expand Up @@ -93,6 +107,15 @@ private let authPayload = """
}
"""

private let authNoRefreshPayload = """
{
"sessionJwt": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJiYXIiLCJuYW1lIjoiU3dpZnR5IE1jQXBwbGVzIiwiaWF0IjoxNTE2MjM5MDIyLCJpc3MiOiJmb28iLCJleHAiOjE2MDMxNzY2MTQsInBlcm1pc3Npb25zIjpbImQiLCJlIl0sInJvbGVzIjpbInVzZXIiXSwidGVuYW50cyI6eyJ0ZW5hbnQiOnsicGVybWlzc2lvbnMiOlsiYSIsImIiLCJjIl0sInJvbGVzIjpbImFkbWluIl19fX0.LEcNdzkdOXlzxcVNhvlqOIoNwzgYYfcDv1_vzF3awF8",
"refreshJwt": "",
"user": \(userPayload),
"firstSeen": false
}
"""

private let tenantsPayload = """
{
"tenants": [
Expand Down

0 comments on commit 0d71359

Please sign in to comment.