From ff257a40e50f6366030c2d44bed0212744f5afb6 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:50:38 +0800 Subject: [PATCH] fix: scope issue --- Sources/RxAuthSwift/OAuthManager.swift | 83 +++++++++++- .../OAuthManagerScopeUpgradeTests.swift | 127 ++++++++++++++++++ 2 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 Tests/RxAuthSwiftTests/OAuthManagerScopeUpgradeTests.swift diff --git a/Sources/RxAuthSwift/OAuthManager.swift b/Sources/RxAuthSwift/OAuthManager.swift index 89de9a0..75aa291 100644 --- a/Sources/RxAuthSwift/OAuthManager.swift +++ b/Sources/RxAuthSwift/OAuthManager.swift @@ -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") @@ -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() @@ -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?) -> Set? { + let baseline: Set + 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? { + 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? { + 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 { @@ -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) @@ -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 } } diff --git a/Tests/RxAuthSwiftTests/OAuthManagerScopeUpgradeTests.swift b/Tests/RxAuthSwiftTests/OAuthManagerScopeUpgradeTests.swift new file mode 100644 index 0000000..e9b1331 --- /dev/null +++ b/Tests/RxAuthSwiftTests/OAuthManagerScopeUpgradeTests.swift @@ -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) + } +}