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

Session lifecycle updates #83

Merged
merged 4 commits into from
Jan 7, 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
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