Skip to content

Commit 5a5c3d5

Browse files
authored
chore: kickoff release
2 parents 735d083 + f64c471 commit 5a5c3d5

File tree

7 files changed

+225
-21
lines changed

7 files changed

+225
-21
lines changed

AmplifyPlugins/API/Sources/AWSAPIPlugin/Configuration/AWSAPIEndpointInterceptors.swift

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,20 @@ struct AWSAPIEndpointInterceptors {
3030

3131
var postludeInterceptors: [URLRequestInterceptor] = []
3232

33+
/// Validates whether the access token has expired. A best-effort attempt is made,
34+
/// and it returns `false` if the expiration cannot be determined.
35+
var expiryValidator: ((String) -> Bool) {
36+
{ token in
37+
guard let authService,
38+
let claims = try? authService.getTokenClaims(tokenString: token).get(),
39+
let tokenExpiration = claims["exp"]?.doubleValue else {
40+
return false
41+
}
42+
let currentTime = Date().timeIntervalSince1970
43+
return currentTime > tokenExpiration
44+
}
45+
}
46+
3347
init(endpointName: APIEndpointName,
3448
apiAuthProviderFactory: APIAuthProviderFactory,
3549
authService: AWSAuthServiceBehavior? = nil) {
@@ -71,7 +85,8 @@ struct AWSAPIEndpointInterceptors {
7185
"")
7286
}
7387
let provider = BasicUserPoolTokenProvider(authService: authService)
74-
let interceptor = AuthTokenURLRequestInterceptor(authTokenProvider: provider)
88+
let interceptor = AuthTokenURLRequestInterceptor(authTokenProvider: provider,
89+
isTokenExpired: expiryValidator)
7590
preludeInterceptors.append(interceptor)
7691
case .openIDConnect:
7792
guard let oidcAuthProvider = apiAuthProviderFactory.oidcAuthProvider() else {

AmplifyPlugins/API/Sources/AWSAPIPlugin/Interceptor/RequestInterceptor/AuthTokenURLRequestInterceptor.swift

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,12 @@ struct AuthTokenURLRequestInterceptor: URLRequestInterceptor {
1515

1616
private let userAgent = AmplifyAWSServiceConfiguration.userAgentLib
1717
let authTokenProvider: AuthTokenProvider
18+
let isTokenExpired: ((String) -> Bool)?
1819

19-
init(authTokenProvider: AuthTokenProvider) {
20+
init(authTokenProvider: AuthTokenProvider,
21+
isTokenExpired: ((String) -> Bool)? = nil) {
2022
self.authTokenProvider = authTokenProvider
23+
self.isTokenExpired = isTokenExpired
2124
}
2225

2326
func intercept(_ request: URLRequest) async throws -> URLRequest {
@@ -41,6 +44,14 @@ struct AuthTokenURLRequestInterceptor: URLRequestInterceptor {
4144
} catch {
4245
throw APIError.operationError("Failed to retrieve authorization token.", "", error)
4346
}
47+
48+
if isTokenExpired?(token) ?? false {
49+
// If the access token has expired, we send back the underlying "AuthError.sessionExpired" error.
50+
// Without a more specific AuthError case like "tokenExpired", this is the closest representation.
51+
throw APIError.operationError("Auth Token Provider returned a expired token.",
52+
"Please call `Amplify.Auth.fetchAuthSession()` or sign in again.",
53+
AuthError.sessionExpired("", "", nil))
54+
}
4455

4556
mutableRequest.setValue(token, forHTTPHeaderField: "authorization")
4657
return mutableRequest as URLRequest

AmplifyPlugins/API/Tests/AWSAPIPluginTests/Configuration/AWSAPIEndpointInterceptorsTests.swift

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,13 +80,33 @@ class AWSAPIEndpointInterceptorsTests: XCTestCase {
8080
XCTAssertNotNil(interceptorConfig.postludeInterceptors[0] as? IAMURLRequestInterceptor)
8181
}
8282

83+
func testExpiryValidator_Valid() {
84+
let validToken = Date().timeIntervalSince1970 + 1
85+
let authService = MockAWSAuthService()
86+
authService.tokenClaims = ["exp": validToken as AnyObject]
87+
let interceptorConfig = createAPIInterceptorConfig(authService: authService)
88+
89+
let result = interceptorConfig.expiryValidator("")
90+
XCTAssertFalse(result)
91+
}
92+
93+
func testExpiryValidator_Expired() {
94+
let expiredToken = Date().timeIntervalSince1970 - 1
95+
let authService = MockAWSAuthService()
96+
authService.tokenClaims = ["exp": expiredToken as AnyObject]
97+
let interceptorConfig = createAPIInterceptorConfig(authService: authService)
98+
99+
let result = interceptorConfig.expiryValidator("")
100+
XCTAssertTrue(result)
101+
}
102+
83103
// MARK: - Test Helpers
84104

85-
func createAPIInterceptorConfig() -> AWSAPIEndpointInterceptors {
105+
func createAPIInterceptorConfig(authService: AWSAuthServiceBehavior = MockAWSAuthService()) -> AWSAPIEndpointInterceptors {
86106
return AWSAPIEndpointInterceptors(
87107
endpointName: endpointName,
88108
apiAuthProviderFactory: APIAuthProviderFactory(),
89-
authService: MockAWSAuthService())
109+
authService: authService)
90110
}
91111

92112
struct CustomInterceptor: URLRequestInterceptor {

AmplifyPlugins/API/Tests/AWSAPIPluginTests/Interceptor/AuthTokenURLRequestInterceptorTests.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,28 @@ class AuthTokenURLRequestInterceptorTests: XCTestCase {
3131
XCTAssertNotNil(headers[URLRequestConstants.Header.xAmzDate])
3232
XCTAssertNotNil(headers[URLRequestConstants.Header.userAgent])
3333
}
34+
35+
func testAuthTokenInterceptor_ThrowsInvalid() async throws {
36+
let mockTokenProvider = MockTokenProvider()
37+
let interceptor = AuthTokenURLRequestInterceptor(authTokenProvider: mockTokenProvider,
38+
isTokenExpired: { _ in return true })
39+
let request = RESTOperationRequestUtils.constructURLRequest(
40+
with: URL(string: "http://anapiendpoint.ca")!,
41+
operationType: .get,
42+
requestPayload: nil
43+
)
44+
45+
do {
46+
_ = try await interceptor.intercept(request).allHTTPHeaderFields
47+
} catch {
48+
guard case .operationError(let description, _, let underlyingError) = error as? APIError,
49+
let authError = underlyingError as? AuthError,
50+
case .sessionExpired = authError else {
51+
XCTFail("Should be API.operationError with underlying AuthError.sessionExpired")
52+
return
53+
}
54+
}
55+
}
3456
}
3557

3658
// MARK: - Mocks

AmplifyPlugins/DataStore/Sources/AWSDataStorePlugin/Sync/MutationSync/OutgoingMutationQueue/SyncMutationToCloudOperation.swift

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ class SyncMutationToCloudOperation: AsynchronousOperation {
277277
}
278278

279279
/// - Warning: Must be invoked from a locking context
280-
private func getRetryAdviceIfRetryable(error: APIError) -> RequestRetryAdvice {
280+
func getRetryAdviceIfRetryable(error: APIError) -> RequestRetryAdvice {
281281
var advice = RequestRetryAdvice(shouldRetry: false, retryInterval: DispatchTimeInterval.never)
282282

283283
switch error {
@@ -288,23 +288,25 @@ class SyncMutationToCloudOperation: AsynchronousOperation {
288288
httpURLResponse: nil,
289289
attemptNumber: currentAttemptNumber)
290290

291-
// we can't unify the following two cases as they have different associated values.
291+
// we can't unify the following two cases (case 1 and case 2) as they have different associated values.
292292
// should retry with a different authType if server returned "Unauthorized Error"
293-
case .httpStatusError(_, let httpURLResponse) where httpURLResponse.statusCode == 401:
293+
case .httpStatusError(_, let httpURLResponse) where httpURLResponse.statusCode == 401: // case 1
294294
advice = shouldRetryWithDifferentAuthType()
295-
// should retry with a different authType if request failed locally with an AuthError
296-
case .operationError(_, _, let error) where (error as? AuthError) != nil:
297-
298-
// Not all AuthError's are unauthorized errors. If `AuthError.sessionExpired` then
299-
// the request never made it to the server. We should keep trying until the user is signed in.
300-
// Otherwise we may be making the wrong determination to remove this mutation event.
301-
if case .sessionExpired = error as? AuthError {
302-
// Use `userAuthenticationRequired` to ensure advice to retry is true.
303-
advice = requestRetryablePolicy.retryRequestAdvice(urlError: URLError(.userAuthenticationRequired),
304-
httpURLResponse: nil,
305-
attemptNumber: currentAttemptNumber)
306-
} else {
307-
advice = shouldRetryWithDifferentAuthType()
295+
case .operationError(_, _, let error): // case 2
296+
if let authError = error as? AuthError { // case 2
297+
// Not all AuthError's are unauthorized errors. If `AuthError.sessionExpired` or `.signedOut` then
298+
// the request never made it to the server. We should keep trying until the user is signed in.
299+
// Otherwise we may be making the wrong determination to remove this mutation event.
300+
switch authError {
301+
case .sessionExpired, .signedOut:
302+
// use `userAuthenticationRequired` to ensure advice to retry is true.
303+
advice = requestRetryablePolicy.retryRequestAdvice(urlError: URLError(.userAuthenticationRequired),
304+
httpURLResponse: nil,
305+
attemptNumber: currentAttemptNumber)
306+
default:
307+
// should retry with a different authType if request failed locally with any other AuthError
308+
advice = shouldRetryWithDifferentAuthType()
309+
}
308310
}
309311
case .httpStatusError(_, let httpURLResponse):
310312
advice = requestRetryablePolicy.retryRequestAdvice(urlError: nil,

AmplifyPlugins/DataStore/Tests/AWSDataStorePluginTests/Sync/MutationQueue/SyncMutationToCloudOperationTests.swift

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,132 @@ class SyncMutationToCloudOperationTests: XCTestCase {
254254
operation.cancel()
255255
await fulfillment(of: [expectMutationRequestFailed], timeout: defaultAsyncWaitTimeout)
256256
}
257+
258+
// MARK: - GetRetryAdviceIfRetryableTests
259+
260+
func testGetRetryAdvice_NetworkError_RetryTrue() async throws {
261+
let operation = await SyncMutationToCloudOperation(
262+
mutationEvent: try createMutationEvent(),
263+
getLatestSyncMetadata: { nil },
264+
api: mockAPIPlugin,
265+
authModeStrategy: AWSDefaultAuthModeStrategy(),
266+
networkReachabilityPublisher: publisher,
267+
currentAttemptNumber: 1,
268+
completion: { _ in }
269+
)
270+
271+
let error = APIError.networkError("", nil, URLError(.userAuthenticationRequired))
272+
let advice = operation.getRetryAdviceIfRetryable(error: error)
273+
XCTAssertTrue(advice.shouldRetry)
274+
}
275+
276+
func testGetRetryAdvice_HTTPStatusError401WithMultiAuth_RetryTrue() async throws {
277+
let operation = await SyncMutationToCloudOperation(
278+
mutationEvent: try createMutationEvent(),
279+
getLatestSyncMetadata: { nil },
280+
api: mockAPIPlugin,
281+
authModeStrategy: MockMultiAuthModeStrategy(),
282+
networkReachabilityPublisher: publisher,
283+
currentAttemptNumber: 1,
284+
completion: { _ in }
285+
)
286+
let response = HTTPURLResponse(url: URL(string: "http://localhost")!,
287+
statusCode: 401,
288+
httpVersion: nil,
289+
headerFields: nil)!
290+
let error = APIError.httpStatusError(401, response)
291+
let advice = operation.getRetryAdviceIfRetryable(error: error)
292+
XCTAssertTrue(advice.shouldRetry)
293+
}
294+
295+
func testGetRetryAdvice_OperationErrorAuthErrorWithMultiAuth_RetryTrue() async throws {
296+
let operation = await SyncMutationToCloudOperation(
297+
mutationEvent: try createMutationEvent(),
298+
getLatestSyncMetadata: { nil },
299+
api: mockAPIPlugin,
300+
authModeStrategy: MockMultiAuthModeStrategy(),
301+
networkReachabilityPublisher: publisher,
302+
currentAttemptNumber: 1,
303+
completion: { _ in }
304+
)
305+
306+
let authError = AuthError.notAuthorized("", "", nil)
307+
let error = APIError.operationError("", "", authError)
308+
let advice = operation.getRetryAdviceIfRetryable(error: error)
309+
XCTAssertTrue(advice.shouldRetry)
310+
}
311+
312+
func testGetRetryAdvice_OperationErrorAuthErrorWithSingleAuth_RetryFalse() async throws {
313+
let operation = await SyncMutationToCloudOperation(
314+
mutationEvent: try createMutationEvent(),
315+
getLatestSyncMetadata: { nil },
316+
api: mockAPIPlugin,
317+
authModeStrategy: AWSDefaultAuthModeStrategy(),
318+
networkReachabilityPublisher: publisher,
319+
currentAttemptNumber: 1,
320+
completion: { _ in }
321+
)
322+
323+
let authError = AuthError.notAuthorized("", "", nil)
324+
let error = APIError.operationError("", "", authError)
325+
let advice = operation.getRetryAdviceIfRetryable(error: error)
326+
XCTAssertFalse(advice.shouldRetry)
327+
}
328+
329+
func testGetRetryAdvice_OperationErrorAuthErrorSessionExpired_RetryTrue() async throws {
330+
let operation = await SyncMutationToCloudOperation(
331+
mutationEvent: try createMutationEvent(),
332+
getLatestSyncMetadata: { nil },
333+
api: mockAPIPlugin,
334+
authModeStrategy: AWSDefaultAuthModeStrategy(),
335+
networkReachabilityPublisher: publisher,
336+
currentAttemptNumber: 1,
337+
completion: { _ in }
338+
)
339+
340+
let authError = AuthError.sessionExpired("", "", nil)
341+
let error = APIError.operationError("", "", authError)
342+
let advice = operation.getRetryAdviceIfRetryable(error: error)
343+
XCTAssertTrue(advice.shouldRetry)
344+
}
345+
346+
func testGetRetryAdvice_OperationErrorAuthErrorSignedOut_RetryTrue() async throws {
347+
let operation = await SyncMutationToCloudOperation(
348+
mutationEvent: try createMutationEvent(),
349+
getLatestSyncMetadata: { nil },
350+
api: mockAPIPlugin,
351+
authModeStrategy: AWSDefaultAuthModeStrategy(),
352+
networkReachabilityPublisher: publisher,
353+
currentAttemptNumber: 1,
354+
completion: { _ in }
355+
)
356+
357+
let authError = AuthError.signedOut("", "", nil)
358+
let error = APIError.operationError("", "", authError)
359+
let advice = operation.getRetryAdviceIfRetryable(error: error)
360+
XCTAssertTrue(advice.shouldRetry)
361+
}
362+
363+
private func createMutationEvent() throws -> MutationEvent {
364+
let post1 = Post(title: "post1", content: "content1", createdAt: .now())
365+
return try MutationEvent(model: post1, modelSchema: post1.schema, mutationType: .create)
366+
}
367+
368+
}
369+
370+
public class MockMultiAuthModeStrategy: AuthModeStrategy {
371+
public weak var authDelegate: AuthModeStrategyDelegate?
372+
required public init() {}
373+
374+
public func authTypesFor(schema: ModelSchema,
375+
operation: ModelOperation) -> AWSAuthorizationTypeIterator {
376+
return AWSAuthorizationTypeIterator(withValues: [.amazonCognitoUserPools, .apiKey])
377+
}
378+
379+
public func authTypesFor(schema: ModelSchema,
380+
operations: [ModelOperation]) -> AWSAuthorizationTypeIterator {
381+
return AWSAuthorizationTypeIterator(withValues: [.amazonCognitoUserPools, .apiKey])
382+
}
257383
}
258384

259385
extension SyncMutationToCloudOperationTests {

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,15 @@ This library is licensed under the Apache 2.0 License.
5555

5656
## Installation
5757

58-
Amplify requires Xcode 14.1 or higher to build for iOS and macOS. Building for watchOS and tvOS requires Xcode 14.3 or higher.
58+
Amplify requires the following Xcode versions, according to the targeted platform:
59+
60+
| Platform | Xcode Version |
61+
| -------------:| ------------: |
62+
| iOS | 14.1+ |
63+
| macOS | 14.1+ |
64+
| tvOS | 14.3+ |
65+
| watchOS | 14.3+ |
66+
| visionOS | 15 beta 2+ |
5967

6068
| For more detailed instructions, follow the getting started guides in our [documentation site](https://docs.amplify.aws/lib/q/platform/ios) |
6169
|-------------------------------------------------|

0 commit comments

Comments
 (0)