Skip to content
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
83 changes: 79 additions & 4 deletions Sources/RxAuthSwift/OAuthManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,20 @@ public final class OAuthManager: Sendable {
private let configuration: RxAuthConfiguration
private let tokenStorage: TokenStorageProtocol
private let logger: Logger
/// Records the scopes each interactive sign-in requested (see
/// `sessionMissingScopes`).
private let scopeDefaults: UserDefaults

private var refreshTimer: Timer?

public init(
configuration: RxAuthConfiguration,
tokenStorage: TokenStorageProtocol? = nil,
logger: Logger? = nil
logger: Logger? = nil,
scopeDefaults: UserDefaults = .standard
) {
self.configuration = configuration
self.scopeDefaults = scopeDefaults
self.tokenStorage = tokenStorage ?? KeychainTokenStorage(serviceName: configuration.keychainServiceName)

var defaultLogger = logger ?? Logger(label: "com.rxlab.RxAuthSwift")
Expand All @@ -85,6 +90,14 @@ public final class OAuthManager: Sendable {
// MARK: - Public API

public func checkExistingAuth() async {
// A session signed in before `configuration.scopes` grew can never gain
// the new scopes by refreshing, so require a fresh sign-in instead.
if let accessToken = tokenStorage.getAccessToken(),
let missing = sessionMissingScopes(granted: Self.grantedScopes(inAccessToken: accessToken)) {
logger.notice("Stored session is missing scopes \(missing.sorted().joined(separator: " ")); signing out")
await logout()
return
}
if let _ = tokenStorage.getAccessToken(), !tokenStorage.isTokenExpired() {
do {
try await fetchUserInfo()
Expand Down Expand Up @@ -376,13 +389,69 @@ public final class OAuthManager: Sendable {
}

let tokenResponse = try JSONDecoder().decode(TokenResponse.self, from: data)
try saveTokens(tokenResponse)
// A refresh keeps the refresh token's original scopes. If the app now
// requests more, the session can't be upgraded in place: sign out so
// the next interactive sign-in grants the full set.
if let missing = sessionMissingScopes(granted: Self.grantedScopes(in: tokenResponse)) {
logger.notice("Refreshed session is missing scopes \(missing.sorted().joined(separator: " ")); signing out")
await handleTokenRefreshFailure()
throw OAuthError.tokenRefreshFailed(
"The session is missing scopes \(missing.sorted().joined(separator: " ")). Sign in again."
)
}
try saveTokens(tokenResponse, fromRefresh: true)
try await fetchUserInfo()

authState = .authenticated
logger.info("Token refreshed successfully")
}

// MARK: - Scopes

private var signedInScopesKey: String {
"RxAuthSwift.signedInScopes.\(configuration.clientID).\(configuration.keychainServiceName)"
}

/// Configured scopes the current session can't have, or nil when it is
/// up to date. Compares against the scopes the last interactive sign-in
/// requested, so a server that grants fewer scopes than requested can't
/// cause a sign-out loop. Sessions signed in before that was recorded fall
/// back to the token's granted scopes; unknown grants (opaque tokens
/// without a `scope` field) are treated as up to date.
func sessionMissingScopes(granted: Set<String>?) -> Set<String>? {
let baseline: Set<String>
if let recorded = scopeDefaults.stringArray(forKey: signedInScopesKey) {
baseline = Set(recorded)
} else if let granted {
baseline = granted
} else {
return nil
}
let missing = Set(configuration.scopes).subtracting(baseline)
return missing.isEmpty ? nil : missing
}

/// Scopes of a token response: its `scope` field, else the access
/// token's `scope` claim.
nonisolated static func grantedScopes(in response: TokenResponse) -> Set<String>? {
if let scope = response.scope {
return Set(scope.split(separator: " ").map(String.init))
}
return grantedScopes(inAccessToken: response.accessToken)
}

/// The `scope` claim of a JWT access token, or nil for opaque tokens and
/// tokens without one.
nonisolated static func grantedScopes(inAccessToken token: String) -> Set<String>? {
let parts = token.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count == 3,
let data = Base64URL.decode(String(parts[1])),
let claims = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let scope = claims["scope"] as? String
else { return nil }
return Set(scope.split(separator: " ").map(String.init))
}

// MARK: - Private

private func platformAuthenticate(url: URL, callbackScheme: String) async throws -> URL {
Expand Down Expand Up @@ -1038,8 +1107,11 @@ public final class OAuthManager: Sendable {
currentUser = try JSONDecoder().decode(User.self, from: data)
}

private func saveTokens(_ tokenResponse: TokenResponse) throws {
private func saveTokens(_ tokenResponse: TokenResponse, fromRefresh: Bool = false) throws {
try tokenStorage.saveAccessToken(tokenResponse.accessToken)
if !fromRefresh {
scopeDefaults.set(configuration.scopes, forKey: signedInScopesKey)
}

if let refreshToken = tokenResponse.refreshToken {
try tokenStorage.saveRefreshToken(refreshToken)
Expand Down Expand Up @@ -1222,17 +1294,20 @@ private struct SignupPendingVerificationResponse: Decodable {
}
}

private struct TokenResponse: Decodable {
struct TokenResponse: Decodable {
let accessToken: String
let refreshToken: String?
let expiresIn: Int?
let tokenType: String?
/// Space-separated granted scopes (RFC 6749 §5.1), when the server sends them.
let scope: String?

enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case refreshToken = "refresh_token"
case expiresIn = "expires_in"
case tokenType = "token_type"
case scope
}
}

Expand Down
127 changes: 127 additions & 0 deletions Tests/RxAuthSwiftTests/OAuthManagerScopeUpgradeTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import Foundation
import Testing
@testable import RxAuthSwift

private final class ScopeMockURLProtocol: URLProtocol, @unchecked Sendable {
nonisolated(unsafe) static var requestHandler: (@Sendable (URLRequest) -> (HTTPURLResponse, Data))?

override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }

override func startLoading() {
guard let handler = Self.requestHandler else {
client?.urlProtocolDidFinishLoading(self)
return
}
let (response, data) = handler(request)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
}

override func stopLoading() {}
}

@Suite("OAuthManager scope upgrades", .serialized)
struct OAuthManagerScopeUpgradeTests {
private let scopes = ["openid", "read:profile", "read:email"]

private func makeConfig() -> RxAuthConfiguration {
RxAuthConfiguration(
issuer: "https://auth.example.com",
clientID: "scope-test-client",
redirectURI: "testapp://callback",
scopes: scopes
)
}

private func makeDefaults() -> UserDefaults {
let name = "OAuthManagerScopeUpgradeTests-\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: name)!
defaults.removePersistentDomain(forName: name)
return defaults
}

private func jwt(scope: String?) -> String {
var claims: [String: Any] = ["sub": "user-1"]
if let scope { claims["scope"] = scope }
let payload = try! JSONSerialization.data(withJSONObject: claims)
return "eyJhbGciOiJSUzI1NiJ9.\(Base64URL.encode(payload)).sig"
}

private var signedInScopesKey: String {
"RxAuthSwift.signedInScopes.scope-test-client.com.rxlab.RxAuthSwift"
}

@Test func grantedScopesAreReadFromJWTAccessTokens() {
#expect(OAuthManager.grantedScopes(inAccessToken: jwt(scope: "openid read:email")) == ["openid", "read:email"])
#expect(OAuthManager.grantedScopes(inAccessToken: jwt(scope: nil)) == nil)
#expect(OAuthManager.grantedScopes(inAccessToken: "opaque") == nil)
}

@Test @MainActor func restoreSignsOutASessionGrantedFewerScopes() async throws {
let storage = InMemoryTokenStorage()
try storage.saveAccessToken(jwt(scope: "openid"))
try storage.saveRefreshToken("refresh")
try storage.saveExpiresAt(Date().addingTimeInterval(3600))
let manager = OAuthManager(configuration: makeConfig(), tokenStorage: storage, scopeDefaults: makeDefaults())

await manager.checkExistingAuth()

#expect(manager.authState == .unauthenticated)
#expect(storage.getAccessToken() == nil)
#expect(storage.getRefreshToken() == nil)
}

@Test @MainActor func recordedSignInScopesPreventASignOutLoop() async throws {
let defaults = makeDefaults()
// The last interactive sign-in already requested today's scopes, even
// though the server granted fewer — signing out again wouldn't help.
defaults.set(scopes, forKey: signedInScopesKey)
let manager = OAuthManager(configuration: makeConfig(), tokenStorage: InMemoryTokenStorage(), scopeDefaults: defaults)

#expect(manager.sessionMissingScopes(granted: ["openid"]) == nil)

defaults.set(["openid"], forKey: signedInScopesKey)
#expect(manager.sessionMissingScopes(granted: Set(scopes)) == ["read:profile", "read:email"])
}

@Test @MainActor func unknownGrantsAreTreatedAsUpToDate() {
let manager = OAuthManager(configuration: makeConfig(), tokenStorage: InMemoryTokenStorage(), scopeDefaults: makeDefaults())
#expect(manager.sessionMissingScopes(granted: nil) == nil)
#expect(manager.sessionMissingScopes(granted: Set(scopes)) == nil)
}

@Test @MainActor func refreshWithNarrowerScopesSignsOutInsteadOfSaving() async throws {
let narrowToken = jwt(scope: "openid")
ScopeMockURLProtocol.requestHandler = { request in
let body = try! JSONSerialization.data(withJSONObject: [
"access_token": narrowToken,
"refresh_token": "rotated",
"expires_in": 3600,
"token_type": "Bearer",
"scope": "openid",
])
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (response, body)
}
URLProtocol.registerClass(ScopeMockURLProtocol.self)
defer {
URLProtocol.unregisterClass(ScopeMockURLProtocol.self)
ScopeMockURLProtocol.requestHandler = nil
}

let storage = InMemoryTokenStorage()
try storage.saveAccessToken("expired-opaque")
try storage.saveRefreshToken("old-refresh")
try storage.saveExpiresAt(Date().addingTimeInterval(-3600))
let manager = OAuthManager(configuration: makeConfig(), tokenStorage: storage, scopeDefaults: makeDefaults())

await #expect(throws: OAuthError.self) {
try await manager.refreshTokenIfNeeded()
}
#expect(manager.authState == .unauthenticated)
#expect(storage.getAccessToken() == nil)
#expect(storage.getRefreshToken() == nil)
}
}