Skip to content
Open
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
12 changes: 11 additions & 1 deletion Amplify/Core/Configuration/AmplifyOutputsData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,12 @@ public struct AmplifyOutputsData: Codable {
public let awsRegion: AWSRegion
public let bucketName: String
public let buckets: [Bucket]?
/// The identifier for the `URLSession` used by the storage service for background transfers.
/// When set, overrides the default session identifier (`com.amazon.aws.default.identifier`).
public let sessionIdentifier: String?
/// The identifier for a shared app group container, allowing extensions and the main app
/// to share upload/download data through a common directory.
public let sharedContainerIdentifier: String?

@_spi(InternalAmplifyConfiguration)
public struct Bucket: Codable {
Expand All @@ -193,11 +199,15 @@ public struct AmplifyOutputsData: Codable {
init(
awsRegion: AWSRegion,
bucketName: String,
buckets: [Bucket]? = nil
buckets: [Bucket]? = nil,
sessionIdentifier: String? = nil,
sharedContainerIdentifier: String? = nil
) {
self.awsRegion = awsRegion
self.bucketName = bucketName
self.buckets = buckets
self.sessionIdentifier = sessionIdentifier
self.sharedContainerIdentifier = sharedContainerIdentifier
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,35 @@ public final class AWSCognitoAuthPlugin: AWSCognitoAuthPluginBehavior {
@_spi(InternalAmplifyConfiguration)
public internal(set) var jsonConfiguration: JSONValue?

/// Lock guarding `_cachedSession` for thread-safe access.
let cachedSessionLock = NSLock()

/// Backing storage for the cached auth session. Access through `cachedSession` instead.
var _cachedSession: AWSAuthCognitoSession?

/// An in-memory cache of the most recently fetched auth session.
///
/// When `fetchAuthSession` is called without `forceRefresh` and the cached tokens are still
/// valid (not within the 2-minute expiry buffer), the cached session is returned immediately,
/// bypassing the `TaskQueue`. This eliminates ~300-1000ms of serialization overhead during
/// concurrent token fetches at app startup. The cache is cleared on `signOut()`.
var cachedSession: AWSAuthCognitoSession? {
get {
cachedSessionLock.lock()
defer { cachedSessionLock.unlock() }
return _cachedSession
}
set {
cachedSessionLock.lock()
defer { cachedSessionLock.unlock() }
_cachedSession = newValue
}
}

func clearCachedSession() {
cachedSession = nil
}

/// The unique key of the plugin within the auth category.
public var key: PluginKey {
return "awsCognitoAuthPlugin"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ extension AWSCognitoAuthPlugin: AuthCategoryBehavior {
}

public func signOut(options: AuthSignOutRequest.Options? = nil) async -> AuthSignOutResult {
clearCachedSession()

let options = options ?? AuthSignOutRequest.Options()
let request = AuthSignOutRequest(options: options)
let task = AWSAuthSignOutTask(request, authStateMachine: authStateMachine)
Expand All @@ -135,6 +137,13 @@ extension AWSCognitoAuthPlugin: AuthCategoryBehavior {

public func fetchAuthSession(options: AuthFetchSessionRequest.Options?) async throws -> AuthSession {
let options = options ?? AuthFetchSessionRequest.Options()

if !options.forceRefresh,
let cached = cachedSession,
cached.areTokensValid() {
return cached
}

let request = AuthFetchSessionRequest(options: options)
let forceReconfigure = secureStoragePreferences?.accessGroup?.name != nil
let task = AWSAuthFetchSessionTask(
Expand All @@ -144,9 +153,16 @@ extension AWSCognitoAuthPlugin: AuthCategoryBehavior {
environment: authEnvironment,
forceReconfigure: forceReconfigure
)
return try await taskQueue.sync {

let session = try await taskQueue.sync {
return try await task.value
} as! AuthSession

if let cognitoSession = session as? AWSAuthCognitoSession {
cachedSession = cognitoSession
}

return session
}

public func resetPassword(for username: String, options: AuthResetPasswordRequest.Options?) async throws -> AuthResetPasswordResult {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,3 +170,19 @@ extension AWSAuthCognitoSession: CustomDebugStringConvertible {
}

extension AWSAuthCognitoSession: Sendable { }

extension AWSAuthCognitoSession {

/// Returns `true` when the session contains user pool tokens that have not yet reached
/// the expiry buffer window (currently 2 minutes before actual expiration).
///
/// Returns `false` if tokens are missing, expired, or within the buffer window.
func areTokensValid() -> Bool {
guard case .success(let tokens) = userPoolTokensResult,
let cognitoTokens = tokens as? AWSCognitoUserPoolTokens else {
return false
}

return !cognitoTokens.doesExpire(in: AmplifyCredentials.expiryBufferInSeconds)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -225,4 +225,104 @@ class AWSAuthCognitoSessionTests: XCTestCase {
XCTAssertEqual(session1.debugDictionary[key] as? String, session2.debugDictionary[key] as? String)
}
}

// MARK: - areTokensValid tests

/// Given: An AWSAuthCognitoSession with tokens expiring well in the future
/// When: areTokensValid() is called
/// Then: It should return true
func testAreTokensValid_validTokens_returnsTrue() {
let tokenData = [
"sub": "1234567890",
"name": "John Doe",
"iat": "1516239022",
"exp": String(Date(timeIntervalSinceNow: 300).timeIntervalSince1970)
]
let error = AuthError.unknown("", nil)
let tokens = AWSCognitoUserPoolTokens(
idToken: CognitoAuthTestHelper.buildToken(for: tokenData),
accessToken: CognitoAuthTestHelper.buildToken(for: tokenData),
refreshToken: "refreshToken"
)

let session = AWSAuthCognitoSession(
isSignedIn: true,
identityIdResult: .failure(error),
awsCredentialsResult: .failure(error),
cognitoTokensResult: .success(tokens)
)

XCTAssertTrue(session.areTokensValid())
}

/// Given: An AWSAuthCognitoSession with tokens that have already expired
/// When: areTokensValid() is called
/// Then: It should return false
func testAreTokensValid_expiredTokens_returnsFalse() {
let tokenData = [
"sub": "1234567890",
"name": "John Doe",
"iat": "1516239022",
"exp": String(Date(timeIntervalSinceNow: -10).timeIntervalSince1970)
]
let error = AuthError.unknown("", nil)
let tokens = AWSCognitoUserPoolTokens(
idToken: CognitoAuthTestHelper.buildToken(for: tokenData),
accessToken: CognitoAuthTestHelper.buildToken(for: tokenData),
refreshToken: "refreshToken"
)

let session = AWSAuthCognitoSession(
isSignedIn: true,
identityIdResult: .failure(error),
awsCredentialsResult: .failure(error),
cognitoTokensResult: .success(tokens)
)

XCTAssertFalse(session.areTokensValid())
}

/// Given: An AWSAuthCognitoSession with a failed token result
/// When: areTokensValid() is called
/// Then: It should return false
func testAreTokensValid_noTokens_returnsFalse() {
let error = AuthError.signedOut("", "", nil)

let session = AWSAuthCognitoSession(
isSignedIn: false,
identityIdResult: .failure(error),
awsCredentialsResult: .failure(error),
cognitoTokensResult: .failure(error)
)

XCTAssertFalse(session.areTokensValid())
}

/// Given: An AWSAuthCognitoSession with tokens expiring within the buffer window (2 minutes)
/// When: areTokensValid() is called
/// Then: It should return false because tokens are about to expire
func testAreTokensValid_tokensWithinExpiryBuffer_returnsFalse() {
let tokenData = [
"sub": "1234567890",
"name": "John Doe",
"iat": "1516239022",
"exp": String(Date(timeIntervalSinceNow: 60).timeIntervalSince1970)
]
let error = AuthError.unknown("", nil)
let tokens = AWSCognitoUserPoolTokens(
idToken: CognitoAuthTestHelper.buildToken(for: tokenData),
accessToken: CognitoAuthTestHelper.buildToken(for: tokenData),
refreshToken: "refreshToken"
)

let session = AWSAuthCognitoSession(
isSignedIn: true,
identityIdResult: .failure(error),
awsCredentialsResult: .failure(error),
cognitoTokensResult: .success(tokens)
)

// Tokens expire in 60s but buffer is 120s, so should be invalid
XCTAssertFalse(session.areTokensValid())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
//
// Copyright Amazon.com Inc. or its affiliates.
// All Rights Reserved.
//
// SPDX-License-Identifier: Apache-2.0
//

import Foundation

import XCTest
@testable import Amplify
@testable import AWSCognitoAuthPlugin
import AWSCognitoIdentity
import AWSPluginsCore

class AWSAuthFetchSessionCacheTests: BaseAuthorizationTests {

/// Given: A signed-in auth plugin with valid tokens
/// When: fetchAuthSession is called twice without forceRefresh
/// Then: The second call should return a cached session
func testFetchAuthSession_validCachedTokens_returnsCachedSession() async throws {
let initialState = AuthState.configured(
AuthenticationState.signedIn(.testData),
AuthorizationState.sessionEstablished(
AmplifyCredentials.testData),
.notStarted)

let plugin = configurePluginWith(initialState: initialState)

// First call populates the cache
let session1 = try await plugin.fetchAuthSession(options: AuthFetchSessionRequest.Options())
XCTAssertTrue(session1.isSignedIn)
XCTAssertNotNil(plugin.cachedSession)

// Second call should use the cache
let session2 = try await plugin.fetchAuthSession(options: AuthFetchSessionRequest.Options())
XCTAssertTrue(session2.isSignedIn)

// Both sessions should be equivalent
let tokens1 = try? (session1 as? AuthCognitoTokensProvider)?.getCognitoTokens().get()
let tokens2 = try? (session2 as? AuthCognitoTokensProvider)?.getCognitoTokens().get()
XCTAssertNotNil(tokens1)
XCTAssertNotNil(tokens2)
XCTAssertEqual(tokens1?.accessToken, tokens2?.accessToken)
}

/// Given: A signed-in auth plugin with a cached session
/// When: fetchAuthSession is called with forceRefresh = true
/// Then: The cache should be bypassed and a new session fetched
func testFetchAuthSession_forceRefresh_bypassesCache() async throws {
let initialState = AuthState.configured(
AuthenticationState.signedIn(.testData),
AuthorizationState.sessionEstablished(
AmplifyCredentials.testData),
.notStarted)

let plugin = configurePluginWith(initialState: initialState)

// Populate cache
_ = try await plugin.fetchAuthSession(options: AuthFetchSessionRequest.Options())
XCTAssertNotNil(plugin.cachedSession)

// Force refresh should bypass cache
let options = AuthFetchSessionRequest.Options(forceRefresh: true)
let session = try await plugin.fetchAuthSession(options: options)
XCTAssertTrue(session.isSignedIn)
}

/// Given: A signed-in auth plugin with a cached session
/// When: signOut is called
/// Then: The cached session should be cleared
func testSignOut_clearsCachedSession() async throws {
let initialState = AuthState.configured(
AuthenticationState.signedIn(.testData),
AuthorizationState.sessionEstablished(
AmplifyCredentials.testData),
.notStarted)

let plugin = configurePluginWith(initialState: initialState)

// Populate cache
_ = try await plugin.fetchAuthSession(options: AuthFetchSessionRequest.Options())
XCTAssertNotNil(plugin.cachedSession)

// Sign out should clear cache
_ = await plugin.signOut()
XCTAssertNil(plugin.cachedSession)
}

/// Given: A newly configured auth plugin with no cached session
/// When: cachedSession is accessed
/// Then: It should be nil
func testCachedSession_initialState_isNil() {
let initialState = AuthState.configured(
AuthenticationState.signedIn(.testData),
AuthorizationState.sessionEstablished(
AmplifyCredentials.testData),
.notStarted)

let plugin = configurePluginWith(initialState: initialState)
XCTAssertNil(plugin.cachedSession)
}
}
Loading