Skip to content
Closed
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
17 changes: 7 additions & 10 deletions Sources/OAuthenticator/Authenticator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -219,16 +219,13 @@ extension Authenticator {
try await storage.storeLogin(login)
}

private func clearLogin() async {
guard let storage = config.loginStorage else { return }

let invalidLogin = Login(token: "invalid", validUntilDate: .distantPast)

do {
try await storage.storeLogin(invalidLogin)
} catch {
print("failed to store an invalid login, possibly stuck", error)
private func clearLogin() async throws {
guard let storage = config.loginStorage else {
self.localLogin = nil
return
}

try await storage.clearLogin()
}
}

Expand Down Expand Up @@ -356,7 +353,7 @@ extension Authenticator {

return login
} catch {
await clearLogin()
try await clearLogin()

throw error
}
Expand Down
9 changes: 8 additions & 1 deletion Sources/OAuthenticator/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,20 @@ public struct AppCredentials: Codable, Hashable, Sendable {
public struct LoginStorage: Sendable {
public typealias RetrieveLogin = @Sendable () async throws -> Login?
public typealias StoreLogin = @Sendable (Login) async throws -> Void
public typealias ClearLogin = @Sendable () async throws -> Void

public let retrieveLogin: RetrieveLogin
public let storeLogin: StoreLogin
public let clearLogin: ClearLogin

public init(retrieveLogin: @escaping RetrieveLogin, storeLogin: @escaping StoreLogin) {
public init(
retrieveLogin: @escaping RetrieveLogin,
storeLogin: @escaping StoreLogin,
clearLogin: @escaping ClearLogin
) {
self.retrieveLogin = retrieveLogin
self.storeLogin = storeLogin
self.clearLogin = clearLogin
}
}

Expand Down
69 changes: 69 additions & 0 deletions Tests/OAuthenticatorTests/AuthenticatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ final class AuthenticatorTests: XCTestCase {
XCTAssertEqual($0, Login(token: "TOKEN"))

storeTokenExp.fulfill()
} clearLogin: {
XCTFail()
}

let config = Authenticator.Configuration(
Expand Down Expand Up @@ -152,6 +154,8 @@ final class AuthenticatorTests: XCTestCase {
return Login(token: "TOKEN")
} storeLogin: { _ in
XCTFail()
} clearLogin: {
XCTFail()
}

let config = Authenticator.Configuration(appCredentials: Self.mockCredentials,
Expand Down Expand Up @@ -204,6 +208,8 @@ final class AuthenticatorTests: XCTestCase {
storeTokenExp.fulfill()

XCTAssertEqual(login.accessToken.value, "REFRESHED")
} clearLogin: {
XCTFail()
}

let config = Authenticator.Configuration(appCredentials: Self.mockCredentials,
Expand All @@ -218,6 +224,65 @@ final class AuthenticatorTests: XCTestCase {
await fulfillment(of: [retrieveTokenExp, refreshExp, storeTokenExp, authedLoadExp], timeout: 1.0, enforceOrder: true)
}

@MainActor
func testExpiredTokenRefreshFailing() async throws {
let mockLoader: URLResponseProvider = { request in
// We should never load the resource, since we failed to refresh the session:
XCTFail()

return MockURLResponseProvider.dummyResponse
}

let refreshExp = expectation(description: "refresh")
let refreshProvider: TokenHandling.RefreshProvider = { login, _, _ in
XCTAssertEqual(login.accessToken.value, "EXPIRED")
XCTAssertEqual(login.refreshToken?.value, "REFRESH")

refreshExp.fulfill()

// Fail the refresh attempt, e.g., the refresh token has expired:
throw AuthenticatorError.refreshNotPossible
}

let tokenHandling = TokenHandling(authorizationURLProvider: Self.disabledAuthorizationURLProvider,
loginProvider: Self.disabledLoginProvider,
refreshProvider: refreshProvider,
responseStatusProvider: TokenHandling.allResponsesValid)

let retrieveTokenExp = expectation(description: "get token")
let clearTokenExp = expectation(description: "clear token")

let storage = LoginStorage {
retrieveTokenExp.fulfill()

return Login(accessToken: Token(value: "EXPIRED", expiry: .distantPast),
refreshToken: Token(value: "REFRESH"))
} storeLogin: { login in
XCTFail()
} clearLogin: {
clearTokenExp.fulfill()
}

let config = Authenticator.Configuration(appCredentials: Self.mockCredentials,
loginStorage: storage,
tokenHandling: tokenHandling,
userAuthenticator: Self.disabledUserAuthenticator)

let auth = Authenticator(config: config, urlLoader: mockLoader)

do {
let (_, _) = try await auth.response(for: URLRequest(url: URL(string: "https://example.com")!))

XCTFail()
} catch AuthenticatorError.refreshNotPossible {
// we expect this error to be thrown
} catch {
XCTFail()
}

await fulfillment(of: [retrieveTokenExp, refreshExp, clearTokenExp], timeout: 1.0, enforceOrder: true)
}

@MainActor
func testManualAuthentication() async throws {
let urlProvider: TokenHandling.AuthorizationURLProvider = { parameters in
Expand Down Expand Up @@ -418,6 +483,8 @@ final class AuthenticatorTests: XCTestCase {
refreshToken: Token(value: "REFRESH"))
} storeLogin: { login in
XCTAssertEqual(login.accessToken.value, "REFRESHED")
} clearLogin: {
XCTFail()
}

let config = Authenticator.Configuration(appCredentials: Self.mockCredentials,
Expand Down Expand Up @@ -479,6 +546,8 @@ final class AuthenticatorTests: XCTestCase {
return storedLogin
} storeLogin: { @MainActor login in
savedLogins.append(login)
} clearLogin: {
XCTFail()
}

let config = Authenticator.Configuration(appCredentials: Self.mockCredentials,
Expand Down
Loading