From 78a5619a7a7f9e6a839905fbf61b57bf3c76733d Mon Sep 17 00:00:00 2001 From: luizgustavo09 Date: Wed, 3 Jun 2026 16:51:04 -0300 Subject: [PATCH 1/3] Add nonce and state to authorize request --- .../Authorize/AuthSecurityParamProvider.swift | 55 ++++ .../Authorize/AuthenticationSession.swift | 10 +- .../AuthorizationCodeAuthProvider.swift | 111 +++++-- .../UberAuth/Authorize/AuthorizeRequest.swift | 16 +- .../UberAuth/Authorize/OAuthParameters.swift | 86 ++++++ Sources/UberAuth/Client.swift | 8 +- Sources/UberAuth/Errors/UberAuthError.swift | 10 + Sources/UberAuth/PAR/ParRequest.swift | 25 +- Sources/UberAuth/Token/AccessToken.swift | 17 +- .../UberSDK/UberSDKTests/Mocks/Mocks.swift | 2 +- .../AuthorizationCodeAuthProviderTests.swift | 283 ++++++++++++++++-- .../UberAuth/AuthorizeRequestTests.swift | 28 +- .../UberAuth/OAuthParametersTests.swift | 72 +++++ .../UberAuth/ParRequestTests.swift | 19 +- 14 files changed, 657 insertions(+), 85 deletions(-) create mode 100644 Sources/UberAuth/Authorize/AuthSecurityParamProvider.swift create mode 100644 Sources/UberAuth/Authorize/OAuthParameters.swift create mode 100644 examples/UberSDK/UberSDKTests/UberAuth/OAuthParametersTests.swift diff --git a/Sources/UberAuth/Authorize/AuthSecurityParamProvider.swift b/Sources/UberAuth/Authorize/AuthSecurityParamProvider.swift new file mode 100644 index 0000000..bc0b553 --- /dev/null +++ b/Sources/UberAuth/Authorize/AuthSecurityParamProvider.swift @@ -0,0 +1,55 @@ +// +// AuthSecurityParamProvider.swift +// UberAuth +// +// Copyright © 2024 Uber Technologies, Inc. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +/// Manages per-request nonce and state for a single authorization flow. +final class AuthSecurityParamProvider { + + private let clientNonce: String? + private(set) var nonce: String? + private(set) var state: String? + + init(nonce: String? = nil) { + self.clientNonce = nonce + } + + /// Generates fresh nonce and state values for a new authorization request. + func begin() { + nonce = clientNonce ?? Nonce.generate() + state = State.generate() + } + + /// Consumes and clears the pending state, returning its value. + /// Called by `handle(response:)` on the native deep-link path. + func consumeState() -> String? { + defer { state = nil } + return state + } + + /// Clears all pending values and returns the nonce. + /// Called once the authorization flow completes (success or failure). + func end() -> String? { + defer { nonce = nil; state = nil } + return nonce + } +} diff --git a/Sources/UberAuth/Authorize/AuthenticationSession.swift b/Sources/UberAuth/Authorize/AuthenticationSession.swift index 12d6ba9..64d3833 100644 --- a/Sources/UberAuth/Authorize/AuthenticationSession.swift +++ b/Sources/UberAuth/Authorize/AuthenticationSession.swift @@ -31,8 +31,9 @@ protocol AuthenticationSessioning { init(anchor: ASPresentationAnchor, callbackURLScheme: String, url: URL, + pendingState: String?, completion: @escaping AuthCompletion) - + func start() } @@ -43,8 +44,9 @@ final class AuthenticationSession: AuthenticationSessioning { private let presentationContextProvider: ASWebAuthenticationPresentationContextProviding? init(anchor: ASPresentationAnchor = ASPresentationAnchor(), - callbackURLScheme: String, + callbackURLScheme: String, url: URL, + pendingState: String? = nil, completion: @escaping AuthCompletion) { self.presentationContextProvider = AuthPresentationContextProvider(anchor: anchor) self.authSession = ASWebAuthenticationSession( @@ -57,6 +59,10 @@ final class AuthenticationSession: AuthenticationSessioning { case (.none, _): completion(.failure(UberAuthError.invalidAuthCode)) case (.some(let url), _): + if let pending = pendingState, State.value(from: url) != pending { + completion(.failure(.stateMismatch)) + return + } guard let code = Self.parse(url: url) else { completion(.failure(Self.parseError(url: url))) return diff --git a/Sources/UberAuth/Authorize/AuthorizationCodeAuthProvider.swift b/Sources/UberAuth/Authorize/AuthorizationCodeAuthProvider.swift index 4724b65..ce1c10e 100644 --- a/Sources/UberAuth/Authorize/AuthorizationCodeAuthProvider.swift +++ b/Sources/UberAuth/Authorize/AuthorizationCodeAuthProvider.swift @@ -42,7 +42,7 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { var currentSession: AuthenticationSessioning? - typealias AuthenticationSessionBuilder = (ASPresentationAnchor, String, URL, AuthCompletion) -> (AuthenticationSessioning) + typealias AuthenticationSessionBuilder = (ASPresentationAnchor, String, URL, String?, AuthCompletion) -> (AuthenticationSessioning) // MARK: Private Properties @@ -55,7 +55,9 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { private let configurationProvider: ConfigurationProviding private let pkce = PKCE() - + + private let paramsProvider: AuthSecurityParamProvider + private let presentationAnchor: ASPresentationAnchor private let responseParser: AuthorizationCodeResponseParsing @@ -78,6 +80,7 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { scopes: [String] = AuthorizationCodeAuthProvider.defaultScopes, shouldExchangeAuthCode: Bool = false, prompt: Prompt? = nil, + nonce: String? = nil, environment: UberEnvironment = .production) { self.configurationProvider = ConfigurationProvider() self.applicationLauncher = UIApplication.shared @@ -92,6 +95,7 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { self.tokenManager = TokenManager(environment: environment) self.scopes = scopes self.prompt = prompt + self.paramsProvider = AuthSecurityParamProvider(nonce: nonce) } init(presentationAnchor: ASPresentationAnchor = .init(), @@ -99,6 +103,7 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { scopes: [String] = AuthorizationCodeAuthProvider.defaultScopes, prompt: Prompt? = nil, shouldExchangeAuthCode: Bool = false, + nonce: String? = nil, configurationProvider: ConfigurationProviding = ConfigurationProvider(), applicationLauncher: ApplicationLaunching = UIApplication.shared, responseParser: AuthorizationCodeResponseParsing = AuthorizationCodeResponseParser(), @@ -119,28 +124,35 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { self.tokenManager = tokenManager self.scopes = scopes self.prompt = prompt + self.paramsProvider = AuthSecurityParamProvider(nonce: nonce) } - + // MARK: AuthProviding public func execute(authDestination: AuthDestination, prefill: Prefill? = nil, completion: @escaping Completion) { - - // Completion is stored for native handle callback + paramsProvider.begin() // Upon completion, intercept result and exchange for token if enabled let authCompletion: Completion = { [weak self] result in guard let self else { return } - + + let nonce = paramsProvider.end() + switch result { case .success(let client): // Exchange auth code for token if needed if shouldExchangeAuthCode, let code = client.authorizationCode { - exchange(code: code, completion: completion) + exchange(code: code, nonce: nonce, completion: completion) self.completion = nil return } + completion(.success(Client( + authorizationCode: client.authorizationCode, + accessToken: client.accessToken, + nonce: nonce + ))) case .failure: break } @@ -167,6 +179,8 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { public func execute(authDestination: AuthDestination, prefill: Prefill? = nil) async throws -> Client { + paramsProvider.begin() + let requestURI = await executePar(prefill: prefill) let client = try await executeLogin(authDestination: authDestination, requestURI: requestURI) @@ -175,7 +189,12 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { return try await exchange(code: code) } - return client + let nonce = paramsProvider.end() + return Client( + authorizationCode: client.authorizationCode, + accessToken: client.accessToken, + nonce: nonce + ) } public func logout() -> Bool { @@ -187,9 +206,15 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { return false } + guard let pending = paramsProvider.consumeState() else { return false } + guard State.value(from: url) == pending else { + completion?(.failure(.stateMismatch)) + return true + } + let result = responseParser(url: url) completion?(result) - + return true } @@ -244,10 +269,12 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { app: nil, clientID: clientID, codeChallenge: shouldExchangeAuthCode ? pkce.codeChallenge : nil, + nonce:paramsProvider.nonce, prompt: prompt, redirectURI: redirectURI, requestURI: requestURI, - scopes: scopes + scopes: scopes, + state:paramsProvider.state ) guard let url = request.url(baseUrl: baseUrl) else { @@ -261,11 +288,12 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { return } - currentSession = authenticationSessionBuilder?(ASPresentationAnchor(), callbackURLScheme, url, completion) ?? + currentSession = authenticationSessionBuilder?(ASPresentationAnchor(), callbackURLScheme, url,paramsProvider.state, completion) ?? AuthenticationSession( anchor: presentationAnchor, callbackURLScheme: callbackURLScheme, url: url, + pendingState:paramsProvider.state, completion: { [weak self] result in guard let self else { return } completion(result) @@ -284,10 +312,12 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { app: nil, clientID: clientID, codeChallenge: shouldExchangeAuthCode ? pkce.codeChallenge : nil, + nonce:paramsProvider.nonce, prompt: prompt, redirectURI: redirectURI, requestURI: requestURI, - scopes: scopes + scopes: scopes, + state:paramsProvider.state ) guard let url = request.url(baseUrl: baseUrl) else { @@ -305,12 +335,13 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { presentationAnchor, callbackURLScheme, url, + paramsProvider.state, { [weak self] result in continuation.resume(with: result) self?.currentSession = nil }) } else { - currentSession = AuthenticationSession(anchor: presentationAnchor, callbackURLScheme: callbackURLScheme, url: url) { [weak self] result in + currentSession = AuthenticationSession(anchor: presentationAnchor, callbackURLScheme: callbackURLScheme, url: url, pendingState:paramsProvider.state) { [weak self] result in continuation.resume(with: result) self?.currentSession = nil @@ -415,10 +446,12 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { app: app, clientID: clientID, codeChallenge: shouldExchangeAuthCode ? pkce.codeChallenge : nil, + nonce:paramsProvider.nonce, prompt: prompt, redirectURI: redirectURI, requestURI: requestURI, - scopes: scopes + scopes: scopes, + state:paramsProvider.state ) guard let url = request.url(baseUrl: baseUrl) else { @@ -448,10 +481,12 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { app: app, clientID: clientID, codeChallenge: shouldExchangeAuthCode ? pkce.codeChallenge : nil, + nonce:paramsProvider.nonce, prompt: prompt, redirectURI: redirectURI, requestURI: requestURI, - scopes: scopes + scopes: scopes, + state:paramsProvider.state ) guard let url = request.url(baseUrl: baseUrl) else { return false } @@ -470,7 +505,9 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { let request = ParRequest( clientID: clientID, - prefill: prefill.dictValue + prefill: prefill.dictValue, + nonce:paramsProvider.nonce, + state:paramsProvider.state ) networkProvider.execute( @@ -489,7 +526,7 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { func executePar(prefill: Prefill?) async -> String? { guard let prefill else { return nil } - let request = ParRequest(clientID: clientID, prefill: prefill.dictValue) + let request = ParRequest(clientID: clientID, prefill: prefill.dictValue, nonce:paramsProvider.nonce, state:paramsProvider.state) let result = try? await networkProvider.execute(request: request) @@ -501,7 +538,8 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { /// Makes a request to the /token endpoing to exchange the authorization code /// for an access token. /// - Parameter code: The authorization code to exchange - private func exchange(code: String, completion: @escaping Completion) { + private func exchange(code: String, nonce: String?, completion: @escaping Completion) { + let request = TokenRequest( clientID: clientID, authorizationCode: code, @@ -514,7 +552,16 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { completion: { [weak self] result in switch result { case .success(let response): - let client = Client(tokenResponse: response) + // If a nonce was sent, the id_token MUST contain a matching nonce claim + if let sentNonce = nonce { + guard let idToken = response.idToken, + let claimNonce = Nonce.claim(from: idToken), + claimNonce == sentNonce else { + completion(.failure(.nonceMismatch)) + return + } + } + let client = Client(tokenResponse: response, nonce: nonce) if let accessToken = client.accessToken { _ = self?.tokenManager.saveToken( accessToken, @@ -530,6 +577,8 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { } func exchange(code: String) async throws -> Client { + let nonce = paramsProvider.end() + let request = TokenRequest( clientID: clientID, authorizationCode: code, @@ -539,7 +588,16 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { let response = try await networkProvider.execute(request: request) - let client = Client(tokenResponse: response) + // If a nonce was sent, the id_token MUST contain a matching nonce claim + if let sentNonce = nonce { + guard let idToken = response.idToken, + let claimNonce = Nonce.claim(from: idToken), + claimNonce == sentNonce else { + throw UberAuthError.nonceMismatch + } + } + + let client = Client(tokenResponse: response, nonce: nonce) if let accessToken = client.accessToken { _ = tokenManager .saveToken( @@ -550,22 +608,17 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { return client } - + } fileprivate extension Client { - init(tokenResponse: TokenRequest.Response) { + init(tokenResponse: TokenRequest.Response, nonce: String? = nil) { self = Client( authorizationCode: nil, - accessToken: AccessToken( - tokenString: tokenResponse.tokenString, - refreshToken: tokenResponse.refreshToken, - tokenType: tokenResponse.tokenType, - expiresIn: tokenResponse.expiresIn, - scope: tokenResponse.scope - ) + accessToken: tokenResponse, + nonce: nonce ) } } diff --git a/Sources/UberAuth/Authorize/AuthorizeRequest.swift b/Sources/UberAuth/Authorize/AuthorizeRequest.swift index 7568844..e88e04b 100644 --- a/Sources/UberAuth/Authorize/AuthorizeRequest.swift +++ b/Sources/UberAuth/Authorize/AuthorizeRequest.swift @@ -38,27 +38,33 @@ struct AuthorizeRequest: NetworkRequest { private let app: UberApp? private let codeChallenge: String? private let clientID: String + private let nonce: String? private let prompt: Prompt? private let redirectURI: String private let requestURI: String? private let scopes: [String] - + private let state: String? + // MARK: Initializers - + init(app: UberApp?, clientID: String, codeChallenge: String?, + nonce: String? = nil, prompt: Prompt? = nil, redirectURI: String, requestURI: String?, - scopes: [String] = []) { + scopes: [String] = [], + state: String? = nil) { self.app = app self.clientID = clientID self.codeChallenge = codeChallenge + self.nonce = nonce self.prompt = prompt self.redirectURI = redirectURI self.requestURI = requestURI self.scopes = scopes + self.state = state } // MARK: Request @@ -71,10 +77,12 @@ struct AuthorizeRequest: NetworkRequest { "client_id": clientID, "code_challenge": codeChallenge, "code_challenge_method": codeChallenge != nil ? "S256" : nil, + "nonce": nonce, "prompt": prompt?.stringValue, "redirect_uri": redirectURI, "request_uri": requestURI, - "scope": scopes.joined(separator: " ") + "scope": scopes.joined(separator: " "), + "state": state ] .compactMapValues { $0 } } diff --git a/Sources/UberAuth/Authorize/OAuthParameters.swift b/Sources/UberAuth/Authorize/OAuthParameters.swift new file mode 100644 index 0000000..e88a0c7 --- /dev/null +++ b/Sources/UberAuth/Authorize/OAuthParameters.swift @@ -0,0 +1,86 @@ +// +// OAuthParameters.swift +// UberAuth +// +// Copyright © 2026 Uber Technologies, Inc. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +import Foundation +import Security + +// MARK: - Nonce + +enum Nonce { + + /// Generates a cryptographically random nonce — 32 bytes from SecRandomCopyBytes, + /// base64url-encoded (RFC 4648 §5, no padding). Sent in /authorize and echoed back + /// in the id_token claim so the partner backend can detect replay attacks. + static func generate() -> String { + randomBase64URL() + } + + /// Decodes the JWT payload (without verifying the signature) and returns the `nonce` claim. + static func claim(from jwt: String) -> String? { + let parts = jwt.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 3 else { return nil } + var payload = String(parts[1]) + payload = payload + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let padLength = (4 - payload.count % 4) % 4 + payload += String(repeating: "=", count: padLength) + guard let data = Data(base64Encoded: payload), + let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + return dict["nonce"] as? String + } +} + +// MARK: - State + +enum State { + + /// Generates a cryptographically random state — 32 bytes from SecRandomCopyBytes, + /// base64url-encoded (RFC 4648 §5, no padding). Sent in /authorize and verified + /// on the callback to bind the response to this SDK session (CSRF protection). + static func generate() -> String { + randomBase64URL() + } + + /// Extracts the `state` query parameter value from a callback URL. + static func value(from url: URL) -> String? { + URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems?.first(where: { $0.name == "state" })?.value + } +} + +// MARK: - Private + +private func randomBase64URL() -> String { + var buffer = [UInt8](repeating: 0, count: 32) + _ = SecRandomCopyBytes(kSecRandomDefault, buffer.count, &buffer) + return Data(buffer) + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") +} diff --git a/Sources/UberAuth/Client.swift b/Sources/UberAuth/Client.swift index 57e5cd9..2b3340f 100644 --- a/Sources/UberAuth/Client.swift +++ b/Sources/UberAuth/Client.swift @@ -34,12 +34,15 @@ public struct Client: Equatable { public let accessToken: AccessToken? + public let nonce: String? // MARK: Initializers public init(authorizationCode: String? = nil, - accessToken: AccessToken? = nil) { + accessToken: AccessToken? = nil, + nonce: String? = nil) { self.authorizationCode = authorizationCode self.accessToken = accessToken + self.nonce = nonce } } @@ -48,7 +51,8 @@ extension Client: CustomStringConvertible { public var description: String { return """ Authorization Code: \(authorizationCode ?? "nil") - Access Token: + Nonce: \(nonce ?? "nil") + Access Token: \(accessToken?.description ?? "nil") """ } diff --git a/Sources/UberAuth/Errors/UberAuthError.swift b/Sources/UberAuth/Errors/UberAuthError.swift index e0146be..8252c7d 100644 --- a/Sources/UberAuth/Errors/UberAuthError.swift +++ b/Sources/UberAuth/Errors/UberAuthError.swift @@ -46,6 +46,12 @@ public enum UberAuthError: Error { // Failed to build the auth request case invalidRequest(String) + // The nonce in the id_token does not match the one sent in /authorize (exchange path only) + case nonceMismatch + + // The state in the callback does not match the one sent in /authorize + case stateMismatch + // An OAuth standard error occurred case oAuth(OAuthError) @@ -76,6 +82,10 @@ extension UberAuthError: LocalizedError { return "The response url could not be parsed" case .invalidRequest(let details): return "Failed to build the auth request: \(details)" + case .nonceMismatch: + return "The nonce in the id_token does not match the one sent in the authorization request" + case .stateMismatch: + return "The state in the callback does not match the one sent in the authorization request" case .other(let error): return "An unknown error occurred: \(error)" case .serviceError: diff --git a/Sources/UberAuth/PAR/ParRequest.swift b/Sources/UberAuth/PAR/ParRequest.swift index 9db720a..b28774e 100644 --- a/Sources/UberAuth/PAR/ParRequest.swift +++ b/Sources/UberAuth/PAR/ParRequest.swift @@ -30,15 +30,23 @@ struct ParRequest: NetworkRequest { // MARK: Private Properties private let clientID: String - + + private let nonce: String? + private let prefill: [String: String] - + + private let state: String? + // MARK: Initializers - + init(clientID: String, - prefill: [String: String]) { + prefill: [String: String], + nonce: String? = nil, + state: String? = nil) { self.clientID = clientID self.prefill = prefill + self.nonce = nonce + self.state = state } // MARK: Request @@ -46,11 +54,18 @@ struct ParRequest: NetworkRequest { typealias Response = Par var body: [String: String]? { - [ + var params: [String: String] = [ "client_id": clientID, "response_type": "code", "login_hint": loginHint ] + if let nonce { + params["nonce"] = nonce + } + if let state { + params["state"] = state + } + return params } var method: HTTPMethod = .post diff --git a/Sources/UberAuth/Token/AccessToken.swift b/Sources/UberAuth/Token/AccessToken.swift index 0f742ce..7026ea8 100644 --- a/Sources/UberAuth/Token/AccessToken.swift +++ b/Sources/UberAuth/Token/AccessToken.swift @@ -42,18 +42,22 @@ public struct AccessToken: Codable, Equatable { public let scope: [String]? + public let idToken: String? + // MARK: Initializers public init(tokenString: String? = nil, refreshToken: String? = nil, tokenType: String? = nil, expiresIn: Int? = nil, - scope: [String]? = nil) { + scope: [String]? = nil, + idToken: String? = nil) { self.tokenString = tokenString self.refreshToken = refreshToken self.tokenType = tokenType self.expiresIn = expiresIn self.scope = scope + self.idToken = idToken } public init(from decoder: Decoder) throws { @@ -62,10 +66,11 @@ public struct AccessToken: Codable, Equatable { let tokenType = try container.decodeIfPresent(String.self, forKey: .tokenType) let expiresIn = try container.decodeIfPresent(Int.self, forKey: .expiresIn) let refreshToken = try container.decodeIfPresent(String.self, forKey: .refreshToken) - + let idToken = try container.decodeIfPresent(String.self, forKey: .idToken) + let scope: [String]? if let scopeString = try? container.decodeIfPresent(String.self, forKey: .scope) { - scope = (scopeString ?? "") + scope = scopeString .split(separator: " ") .map(String.init) } @@ -78,7 +83,8 @@ public struct AccessToken: Codable, Equatable { refreshToken: refreshToken, tokenType: tokenType, expiresIn: expiresIn, - scope: scope + scope: scope, + idToken: idToken ) } @@ -87,6 +93,7 @@ public struct AccessToken: Codable, Equatable { case tokenType = "token_type" case expiresIn = "expires_in" case refreshToken = "refresh_token" + case idToken = "id_token" case scope } } @@ -115,6 +122,7 @@ extension AccessToken { self.tokenString = tokenString self.refreshToken = oAuthDictionary["refresh_token"] as? String self.tokenType = oAuthDictionary["token_type"] as? String + self.idToken = oAuthDictionary["id_token"] as? String self.expiresIn = { if let expiresIn = oAuthDictionary["expires_in"] as? Int { return expiresIn } if let expiresIn = oAuthDictionary["expires_in"] as? String, @@ -173,6 +181,7 @@ extension AccessToken: CustomStringConvertible { Token Type: \(tokenType ?? "nil") Expires In: \(expiresIn ?? -1) Scopes: \(scope?.joined(separator: ", ") ?? "nil") + ID Token: \(idToken != nil ? "" : "nil") """ } } diff --git a/examples/UberSDK/UberSDKTests/Mocks/Mocks.swift b/examples/UberSDK/UberSDKTests/Mocks/Mocks.swift index 9ed92c1..70b75f8 100644 --- a/examples/UberSDK/UberSDKTests/Mocks/Mocks.swift +++ b/examples/UberSDK/UberSDKTests/Mocks/Mocks.swift @@ -199,7 +199,7 @@ final class AuthenticationSessioningMock: AuthenticationSessioning { private var _completion: AuthCompletion! private var _url: URL! init() { } - required init(anchor: ASPresentationAnchor, callbackURLScheme: String = "", url: URL = URL(fileURLWithPath: ""), completion: @escaping AuthCompletion) { + required init(anchor: ASPresentationAnchor, callbackURLScheme: String = "", url: URL = URL(fileURLWithPath: ""), pendingState: String? = nil, completion: @escaping AuthCompletion) { self._anchor = anchor self._callbackURLScheme = callbackURLScheme self._url = url diff --git a/examples/UberSDK/UberSDKTests/UberAuth/AuthorizationCodeAuthProviderTests.swift b/examples/UberSDK/UberSDKTests/UberAuth/AuthorizationCodeAuthProviderTests.swift index c611d75..0d0e22f 100644 --- a/examples/UberSDK/UberSDKTests/UberAuth/AuthorizationCodeAuthProviderTests.swift +++ b/examples/UberSDK/UberSDKTests/UberAuth/AuthorizationCodeAuthProviderTests.swift @@ -82,7 +82,7 @@ final class AuthorizationCodeAuthProviderTests: XCTestCase { var hasCalledAuthenticationSessionBuilder: Bool = false - let authenticationSessionBuilder: AuthorizationCodeAuthProvider.AuthenticationSessionBuilder = { _, _, url, _ in + let authenticationSessionBuilder: AuthorizationCodeAuthProvider.AuthenticationSessionBuilder = { _, _, url, _, _ in XCTAssertFalse(url.absoluteString.contains("code_challenge")) XCTAssertFalse(url.absoluteString.contains("code_challenge_method")) hasCalledAuthenticationSessionBuilder = true @@ -122,7 +122,7 @@ final class AuthorizationCodeAuthProviderTests: XCTestCase { let prompt: Prompt = [.login, .consent] let promptString = prompt.stringValue.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)! - let authenticationSessionBuilder: AuthorizationCodeAuthProvider.AuthenticationSessionBuilder = { _, _, url, _ in + let authenticationSessionBuilder: AuthorizationCodeAuthProvider.AuthenticationSessionBuilder = { _, _, url, _, _ in XCTAssertTrue(url.query()!.contains("prompt=\(promptString)")) hasCalledAuthenticationSessionBuilder = true return AuthenticationSessioningMock() @@ -396,7 +396,7 @@ final class AuthorizationCodeAuthProviderTests: XCTestCase { let expectation = XCTestExpectation() let authenticationSession = AuthenticationSessioningMock() - let authenticationSessionBuilder: AuthorizationCodeAuthProvider.AuthenticationSessionBuilder = { _, _, _, _ in + let authenticationSessionBuilder: AuthorizationCodeAuthProvider.AuthenticationSessionBuilder = { _, _, _, _, _ in expectation.fulfill() return authenticationSession } @@ -436,7 +436,7 @@ final class AuthorizationCodeAuthProviderTests: XCTestCase { let expectation = XCTestExpectation() let authenticationSession = AuthenticationSessioningMock() - let authenticationSessionBuilder: AuthorizationCodeAuthProvider.AuthenticationSessionBuilder = { _, _, _, _ in + let authenticationSessionBuilder: AuthorizationCodeAuthProvider.AuthenticationSessionBuilder = { _, _, _, _, _ in expectation.fulfill() return authenticationSession } @@ -471,18 +471,26 @@ final class AuthorizationCodeAuthProviderTests: XCTestCase { .success(Client()) } + var capturedState: String? + let authSessionBuilder: AuthorizationCodeAuthProvider.AuthenticationSessionBuilder = { _, _, url, _, _ in + capturedState = URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems?.first(where: { $0.name == "state" })?.value + return AuthenticationSessioningMock() + } + let provider = AuthorizationCodeAuthProvider( + authenticationSessionBuilder: authSessionBuilder, + configurationProvider: configurationProvider, responseParser: responseParser ) - let url = URL(string: "scheme://host?code=123")! - let completion: AuthorizationCodeAuthProvider.Completion = { _ in } - provider.execute(authDestination: .native(appPriority: []), prefill: nil, completion: completion) + provider.execute(authDestination: .inApp, prefill: nil, completion: completion) XCTAssertEqual(responseParser.isValidResponseCallCount, 0) XCTAssertEqual(responseParser.callAsFunctionCallCount, 0) + let url = URL(string: "scheme://host?code=123&state=\(capturedState ?? "")")! let handled = provider.handle( response: url ) @@ -587,8 +595,11 @@ final class AuthorizationCodeAuthProviderTests: XCTestCase { true } + var capturedState: String? let applicationLauncher = ApplicationLaunchingMock() - applicationLauncher.launchHandler = { _, completion in + applicationLauncher.launchHandler = { url, completion in + capturedState = URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems?.first(where: { $0.name == "state" })?.value completion?(true) } @@ -603,8 +614,9 @@ final class AuthorizationCodeAuthProviderTests: XCTestCase { authDestination: .native(appPriority: [.rides]), completion: { result in } ) + RunLoop.main.run(mode: .default, before: Date(timeIntervalSinceNow: 0.01)) - let url = URL(string: "test://app?code=123")! + let url = URL(string: "test://app?code=123&state=\(capturedState ?? "")")! _ = provider.handle(response: url) XCTAssertTrue(hasCalledTokenRequest) @@ -625,8 +637,11 @@ final class AuthorizationCodeAuthProviderTests: XCTestCase { true } + var capturedState: String? let applicationLauncher = ApplicationLaunchingMock() - applicationLauncher.launchHandler = { _, completion in + applicationLauncher.launchHandler = { url, completion in + capturedState = URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems?.first(where: { $0.name == "state" })?.value completion?(true) } @@ -640,18 +655,20 @@ final class AuthorizationCodeAuthProviderTests: XCTestCase { authDestination: .native(appPriority: [.rides]), completion: { result in } ) + RunLoop.main.run(mode: .default, before: Date(timeIntervalSinceNow: 0.01)) - let url = URL(string: "test://app?code=123")! + let url = URL(string: "test://app?code=123&state=\(capturedState ?? "")")! _ = provider.handle(response: url) XCTAssertFalse(hasCalledTokenRequest) } func test_nativeAuth_tokenExchange() { - + let token = AccessToken( tokenString: "123", - tokenType: "test_token" + tokenType: "test_token", + idToken: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJub25jZSI6InRlc3Qtbm9uY2UifQ.fakesig" ) let networkProvider = NetworkProvidingMock() @@ -670,13 +687,17 @@ final class AuthorizationCodeAuthProviderTests: XCTestCase { true } + var capturedState: String? let applicationLauncher = ApplicationLaunchingMock() - applicationLauncher.launchHandler = { _, completion in + applicationLauncher.launchHandler = { url, completion in + capturedState = URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems?.first(where: { $0.name == "state" })?.value completion?(true) } let provider = AuthorizationCodeAuthProvider( - shouldExchangeAuthCode: true, + shouldExchangeAuthCode: true, + nonce: "test-nonce", configurationProvider: configurationProvider, applicationLauncher: applicationLauncher, networkProvider: networkProvider @@ -698,15 +719,18 @@ final class AuthorizationCodeAuthProviderTests: XCTestCase { Client( accessToken: AccessToken( tokenString: "123", - tokenType: "test_token" - ) + tokenType: "test_token", + idToken: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJub25jZSI6InRlc3Qtbm9uY2UifQ.fakesig" + ), + nonce: "test-nonce" ) ) } } ) - let url = URL(string: "test://app?code=123")! + RunLoop.main.run(mode: .default, before: Date(timeIntervalSinceNow: 0.01)) + let url = URL(string: "test://app?code=123&state=\(capturedState ?? "")")! _ = provider.handle(response: url) wait(for: [expectation], timeout: 0.1) @@ -860,7 +884,7 @@ extension AuthorizationCodeAuthProviderTests { } func test_executeLogin_async_inApp_usesAuthenticationSession() async throws { - let authSessionBuilder: AuthorizationCodeAuthProvider.AuthenticationSessionBuilder = { _, _, _, completion in + let authSessionBuilder: AuthorizationCodeAuthProvider.AuthenticationSessionBuilder = { _, _, _, _, completion in let client = Client(authorizationCode: "in_app_code") completion(.success(client)) return AuthenticationSessioningMock() @@ -886,7 +910,7 @@ extension AuthorizationCodeAuthProviderTests { let mockLauncher = ApplicationLaunchingMock() mockLauncher.launchResult = false - let authSessionBuilder: AuthorizationCodeAuthProvider.AuthenticationSessionBuilder = { _, _, _, completion in + let authSessionBuilder: AuthorizationCodeAuthProvider.AuthenticationSessionBuilder = { _, _, _, _, completion in let client = Client(authorizationCode: "fallback_code") completion(.success(client)) return AuthenticationSessioningMock() @@ -909,7 +933,7 @@ extension AuthorizationCodeAuthProviderTests { } func test_execute_async_withoutExchange_returnsAuthCode() async throws { - let authSessionBuilder: AuthorizationCodeAuthProvider.AuthenticationSessionBuilder = { _, _, _, completion in + let authSessionBuilder: AuthorizationCodeAuthProvider.AuthenticationSessionBuilder = { _, _, _, _, completion in completion(.success(Client(authorizationCode: "code"))) return AuthenticationSessioningMock() } @@ -933,27 +957,29 @@ extension AuthorizationCodeAuthProviderTests { refreshToken: "refresh", tokenType: "Bearer", expiresIn: 3600, - scope: [] + scope: [], + idToken: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJub25jZSI6InRlc3Qtbm9uY2UifQ.fakesig" )) - + let mockTokenManager = TokenManagingMock() mockTokenManager.saveTokenHandler = { _, _, _ in true } - - let authSessionBuilder: AuthorizationCodeAuthProvider.AuthenticationSessionBuilder = { _, _, _, completion in + + let authSessionBuilder: AuthorizationCodeAuthProvider.AuthenticationSessionBuilder = { _, _, _, _, completion in completion(.success(Client(authorizationCode: "code"))) return AuthenticationSessioningMock() } - + let provider = AuthorizationCodeAuthProvider( authenticationSessionBuilder: authSessionBuilder, shouldExchangeAuthCode: true, + nonce: "test-nonce", configurationProvider: configurationProvider, networkProvider: mockNetwork, tokenManager: mockTokenManager ) - + let client = try await provider.execute(authDestination: .inApp, prefill: nil) - + XCTAssertNotNil(client.accessToken) XCTAssertEqual(mockNetwork.executeCallCount, 1) } @@ -965,7 +991,7 @@ extension AuthorizationCodeAuthProviderTests { expiresIn: Date(timeIntervalSinceNow: 3600) )) - let authSessionBuilder: AuthorizationCodeAuthProvider.AuthenticationSessionBuilder = { _, _, _, completion in + let authSessionBuilder: AuthorizationCodeAuthProvider.AuthenticationSessionBuilder = { _, _, _, _, completion in completion(.success(Client(authorizationCode: "code"))) return AuthenticationSessioningMock() } @@ -983,7 +1009,7 @@ extension AuthorizationCodeAuthProviderTests { } func test_execute_async_authenticationError_throwsError() async { - let authSessionBuilder: AuthorizationCodeAuthProvider.AuthenticationSessionBuilder = { _, _, _, completion in + let authSessionBuilder: AuthorizationCodeAuthProvider.AuthenticationSessionBuilder = { _, _, _, _, completion in completion(.failure(UberAuthError.cancelled)) return AuthenticationSessioningMock() } @@ -1006,7 +1032,7 @@ extension AuthorizationCodeAuthProviderTests { let mockNetwork = NetworkProvidingMock() mockNetwork.executeAsyncResult = .failure(UberAuthError.serviceError) - let authSessionBuilder: AuthorizationCodeAuthProvider.AuthenticationSessionBuilder = { _, _, _, completion in + let authSessionBuilder: AuthorizationCodeAuthProvider.AuthenticationSessionBuilder = { _, _, _, _, completion in completion(.success(Client(authorizationCode: "code"))) return AuthenticationSessioningMock() } @@ -1045,7 +1071,7 @@ extension AuthorizationCodeAuthProviderTests { func test_environment_production_usesProductionBaseUrl() { var capturedUrl: URL? - let authenticationSessionBuilder: AuthorizationCodeAuthProvider.AuthenticationSessionBuilder = { _, _, url, _ in + let authenticationSessionBuilder: AuthorizationCodeAuthProvider.AuthenticationSessionBuilder = { _, _, url, _, _ in capturedUrl = url return AuthenticationSessioningMock() } @@ -1064,7 +1090,7 @@ extension AuthorizationCodeAuthProviderTests { func test_environment_sandbox_usesSandboxBaseUrl() { var capturedUrl: URL? - let authenticationSessionBuilder: AuthorizationCodeAuthProvider.AuthenticationSessionBuilder = { _, _, url, _ in + let authenticationSessionBuilder: AuthorizationCodeAuthProvider.AuthenticationSessionBuilder = { _, _, url, _, _ in capturedUrl = url return AuthenticationSessioningMock() } @@ -1102,4 +1128,195 @@ extension AuthorizationCodeAuthProviderTests { wait(for: [expectation], timeout: 0.2) } + + // MARK: Nonce + + func test_nonce_alwaysIncludedInAuthorizeUrl() { + var capturedUrl: URL? + let authenticationSessionBuilder: AuthorizationCodeAuthProvider.AuthenticationSessionBuilder = { _, _, url, _, _ in + capturedUrl = url + return AuthenticationSessioningMock() + } + + let provider = AuthorizationCodeAuthProvider( + authenticationSessionBuilder: authenticationSessionBuilder, + scopes: ["profile"], + configurationProvider: configurationProvider + ) + + provider.execute(authDestination: .inApp, completion: { _ in }) + + XCTAssertTrue(capturedUrl?.query()?.contains("nonce=") == true) + } + + func test_nonce_noExchangePath_surfacedOnClient() { + let authenticationSessionBuilder: AuthorizationCodeAuthProvider.AuthenticationSessionBuilder = { _, _, _, _, completion in + completion(.success(Client(authorizationCode: "auth_code"))) + return AuthenticationSessioningMock() + } + + let provider = AuthorizationCodeAuthProvider( + authenticationSessionBuilder: authenticationSessionBuilder, + scopes: ["openid", "profile"], + shouldExchangeAuthCode: false, + configurationProvider: configurationProvider + ) + + let expectation = XCTestExpectation() + + provider.execute(authDestination: .inApp, completion: { result in + if case .success(let client) = result { + XCTAssertNotNil(client.nonce) + XCTAssertNotNil(client.authorizationCode) + } else { + XCTFail("Expected success") + } + expectation.fulfill() + }) + + wait(for: [expectation], timeout: 0.1) + } + + func test_nonce_exchangePath_surfacedOnClient() async throws { + let mockNetwork = NetworkProvidingMock() + mockNetwork.executeAsyncResult = .success(AccessToken( + tokenString: "access_token", + tokenType: "Bearer", + expiresIn: 3600, + idToken: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJub25jZSI6InRlc3Qtbm9uY2UifQ.fakesig" + )) + + let mockTokenManager = TokenManagingMock() + mockTokenManager.saveTokenHandler = { _, _, _ in true } + + let authSessionBuilder: AuthorizationCodeAuthProvider.AuthenticationSessionBuilder = { _, _, _, _, completion in + completion(.success(Client(authorizationCode: "code"))) + return AuthenticationSessioningMock() + } + + let provider = AuthorizationCodeAuthProvider( + authenticationSessionBuilder: authSessionBuilder, + scopes: ["openid", "profile"], + shouldExchangeAuthCode: true, + nonce: "test-nonce", + configurationProvider: configurationProvider, + networkProvider: mockNetwork, + tokenManager: mockTokenManager + ) + + let client = try await provider.execute(authDestination: .inApp, prefill: nil) + + XCTAssertNotNil(client.accessToken) + XCTAssertNotNil(client.nonce) + } + + func test_nonce_and_state_uniquePerRequest() { + var capturedNonces: [String] = [] + var capturedStates: [String] = [] + + let makeSessionBuilder: () -> AuthorizationCodeAuthProvider.AuthenticationSessionBuilder = { + return { _, _, url, _, completion in + if let query = url.query() { + if let nonceParam = query.split(separator: "&").first(where: { $0.hasPrefix("nonce=") }), + let nonce = nonceParam.split(separator: "=").last.map(String.init) { + capturedNonces.append(nonce) + } + if let stateParam = query.split(separator: "&").first(where: { $0.hasPrefix("state=") }), + let state = stateParam.split(separator: "=").last.map(String.init) { + capturedStates.append(state) + } + } + completion(.success(Client(authorizationCode: "code"))) + return AuthenticationSessioningMock() + } + } + + let provider1 = AuthorizationCodeAuthProvider( + authenticationSessionBuilder: makeSessionBuilder(), + scopes: ["openid"], + shouldExchangeAuthCode: false, + configurationProvider: configurationProvider + ) + let exp1 = XCTestExpectation(description: "first login") + provider1.execute(authDestination: .inApp, completion: { _ in exp1.fulfill() }) + wait(for: [exp1], timeout: 0.1) + + let provider2 = AuthorizationCodeAuthProvider( + authenticationSessionBuilder: makeSessionBuilder(), + scopes: ["openid"], + shouldExchangeAuthCode: false, + configurationProvider: configurationProvider + ) + let exp2 = XCTestExpectation(description: "second login") + provider2.execute(authDestination: .inApp, completion: { _ in exp2.fulfill() }) + wait(for: [exp2], timeout: 0.1) + + XCTAssertEqual(capturedNonces.count, 2) + XCTAssertNotEqual(capturedNonces[0], capturedNonces[1]) + XCTAssertEqual(capturedStates.count, 2) + XCTAssertNotEqual(capturedStates[0], capturedStates[1]) + } + + func test_state_alwaysIncludedInAuthorizeUrl() { + var capturedURL: URL? + let authSessionBuilder: AuthorizationCodeAuthProvider.AuthenticationSessionBuilder = { _, _, url, _, completion in + capturedURL = url + completion(.success(Client(authorizationCode: "code"))) + return AuthenticationSessioningMock() + } + + let provider = AuthorizationCodeAuthProvider( + authenticationSessionBuilder: authSessionBuilder, + scopes: ["profile"], + shouldExchangeAuthCode: false, + configurationProvider: configurationProvider + ) + + let expectation = XCTestExpectation() + provider.execute(authDestination: .inApp, completion: { _ in expectation.fulfill() }) + wait(for: [expectation], timeout: 0.1) + + XCTAssertNotNil(capturedURL) + XCTAssertTrue(capturedURL!.query()!.contains("state=")) + } + + func test_state_mismatch_returnsStateMismatchError() { + // Test via the native handle(response:) path so the actual State.value(from:) + // comparison in the provider is exercised — not bypassed by the session mock. + configurationProvider.isInstalledHandler = { _, _ in true } + + var capturedState: String? + let applicationLauncher = ApplicationLaunchingMock() + applicationLauncher.launchHandler = { url, completion in + capturedState = URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems?.first(where: { $0.name == "state" })?.value + completion?(true) + } + + let provider = AuthorizationCodeAuthProvider( + scopes: ["profile"], + shouldExchangeAuthCode: false, + configurationProvider: configurationProvider, + applicationLauncher: applicationLauncher + ) + + let expectation = XCTestExpectation() + provider.execute(authDestination: .native(appPriority: [.rides]), completion: { result in + if case .failure(let error) = result { + XCTAssertEqual(error, .stateMismatch) + } else { + XCTFail("Expected stateMismatch error") + } + expectation.fulfill() + }) + RunLoop.main.run(mode: .default, before: Date(timeIntervalSinceNow: 0.01)) + + // Supply a callback URL with a state value that deliberately does not match. + let wrongState = (capturedState ?? "") + "_tampered" + let url = URL(string: "test://app?code=123&state=\(wrongState)")! + _ = provider.handle(response: url) + + wait(for: [expectation], timeout: 0.1) + } + } diff --git a/examples/UberSDK/UberSDKTests/UberAuth/AuthorizeRequestTests.swift b/examples/UberSDK/UberSDKTests/UberAuth/AuthorizeRequestTests.swift index 087922d..2f196ec 100644 --- a/examples/UberSDK/UberSDKTests/UberAuth/AuthorizeRequestTests.swift +++ b/examples/UberSDK/UberSDKTests/UberAuth/AuthorizeRequestTests.swift @@ -30,10 +30,10 @@ import XCTest final class AuthorizeRequestTests: XCTestCase { func test_generatedUrl() { - + let prompt: Prompt = [.consent, .login] let promptString = prompt.stringValue.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)! - + let request = AuthorizeRequest( app: nil, clientID: "test_client_id", @@ -42,10 +42,10 @@ final class AuthorizeRequestTests: XCTestCase { redirectURI: "redirect_uri", requestURI: "request_url" ) - + let urlRequest = request.urlRequest(baseUrl: "https://auth.uber.com")! let url = urlRequest.url! - + XCTAssertEqual(url.host(), "auth.uber.com") XCTAssertEqual(url.scheme, "https") XCTAssertEqual(url.path(), "/oauth/v2/universal/authorize") @@ -56,6 +56,26 @@ final class AuthorizeRequestTests: XCTestCase { XCTAssertTrue(url.query()!.contains("code_challenge_method=S256")) XCTAssertTrue(url.query()!.contains("redirect_uri=redirect_uri")) XCTAssertTrue(url.query()!.contains("prompt=\(promptString)")) + XCTAssertFalse(url.query()!.contains("nonce=")) + XCTAssertFalse(url.query()!.contains("state=")) + } + + func test_generatedUrl_withNonceAndState_includesBothParams() { + let request = AuthorizeRequest( + app: nil, + clientID: "test_client_id", + codeChallenge: nil, + nonce: "test_nonce_value", + redirectURI: "redirect_uri", + requestURI: nil, + state: "test_state_value" + ) + + let urlRequest = request.urlRequest(baseUrl: "https://auth.uber.com")! + let url = urlRequest.url! + + XCTAssertTrue(url.query()!.contains("nonce=test_nonce_value")) + XCTAssertTrue(url.query()!.contains("state=test_state_value")) } func test_appSpecific_generatedUrls() { diff --git a/examples/UberSDK/UberSDKTests/UberAuth/OAuthParametersTests.swift b/examples/UberSDK/UberSDKTests/UberAuth/OAuthParametersTests.swift new file mode 100644 index 0000000..5592158 --- /dev/null +++ b/examples/UberSDK/UberSDKTests/UberAuth/OAuthParametersTests.swift @@ -0,0 +1,72 @@ +// +// OAuthParametersTests.swift +// UberSDKTests +// +// Copyright © 2026 Uber Technologies, Inc. All rights reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + + +@testable import UberAuth +import XCTest + +final class OAuthParametersTests: XCTestCase { + + // MARK: - Nonce + + func test_nonce_generate_producesNonEmptyString() { + XCTAssertFalse(Nonce.generate().isEmpty) + } + + func test_nonce_generate_base64urlCharactersOnly() { + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_")) + let nonce = Nonce.generate() + XCTAssertTrue(nonce.unicodeScalars.allSatisfy { allowed.contains($0) }, + "Nonce contains non-base64url characters: \(nonce)") + } + + func test_nonce_generate_minimumEntropyLength() { + XCTAssertGreaterThanOrEqual(Nonce.generate().count, 43) + } + + func test_nonce_generate_uniqueValues() { + XCTAssertNotEqual(Nonce.generate(), Nonce.generate()) + } + + // MARK: - State + + func test_state_generate_producesNonEmptyString() { + XCTAssertFalse(State.generate().isEmpty) + } + + func test_state_generate_base64urlCharactersOnly() { + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_")) + let state = State.generate() + XCTAssertTrue(state.unicodeScalars.allSatisfy { allowed.contains($0) }, + "State contains non-base64url characters: \(state)") + } + + func test_state_generate_minimumEntropyLength() { + XCTAssertGreaterThanOrEqual(State.generate().count, 43) + } + + func test_state_generate_uniqueValues() { + XCTAssertNotEqual(State.generate(), State.generate()) + } +} diff --git a/examples/UberSDK/UberSDKTests/UberAuth/ParRequestTests.swift b/examples/UberSDK/UberSDKTests/UberAuth/ParRequestTests.swift index 97e0dfa..95bdeb2 100644 --- a/examples/UberSDK/UberSDKTests/UberAuth/ParRequestTests.swift +++ b/examples/UberSDK/UberSDKTests/UberAuth/ParRequestTests.swift @@ -52,5 +52,22 @@ final class ParRequestTests: XCTestCase { XCTAssertEqual(url.path(), "/oauth/v2/par") XCTAssertEqual(request.body?["login_hint"], loginHintString) } - + + func test_body_includesNonceAndStateWhenProvided() { + let request = ParRequest( + clientID: "test_client_id", + prefill: [:], + nonce: "test-nonce", + state: "test-state" + ) + XCTAssertEqual(request.body?["nonce"], "test-nonce") + XCTAssertEqual(request.body?["state"], "test-state") + } + + func test_body_omitsNonceAndStateWhenNil() { + let request = ParRequest(clientID: "test_client_id", prefill: [:]) + XCTAssertNil(request.body?["nonce"]) + XCTAssertNil(request.body?["state"]) + } + } From 6f7b2ca343aeaab6280038a57bef1c862444c2f7 Mon Sep 17 00:00:00 2001 From: luizgustavo09 Date: Thu, 4 Jun 2026 22:20:29 -0300 Subject: [PATCH 2/3] documented handle method and add nonce generator --- Sources/UberAuth/AuthProviding.swift | 6 + .../Authorize/AuthSecurityParamProvider.swift | 55 ----- .../AuthorizationCodeAuthProvider.swift | 217 ++++++++++-------- .../AuthorizationCodeAuthProviderTests.swift | 8 +- 4 files changed, 126 insertions(+), 160 deletions(-) delete mode 100644 Sources/UberAuth/Authorize/AuthSecurityParamProvider.swift diff --git a/Sources/UberAuth/AuthProviding.swift b/Sources/UberAuth/AuthProviding.swift index 20c46cb..bdf07ce 100644 --- a/Sources/UberAuth/AuthProviding.swift +++ b/Sources/UberAuth/AuthProviding.swift @@ -39,6 +39,12 @@ public protocol AuthProviding { func logout() -> Bool + /// Attempts to handle an incoming URL on the native path. + /// + /// - Returns: `true` if the URL was claimed by this provider — meaning it matched the redirect + /// URI and an active auth flow was in progress. A `true` return indicates the URL was handled, + /// the outcome (success or failure) is delivered via the completion passed to `execute`. + /// Returns `false` if the URL does not belong to this provider. func handle(response url: URL) -> Bool var isLoggedIn: Bool { get } diff --git a/Sources/UberAuth/Authorize/AuthSecurityParamProvider.swift b/Sources/UberAuth/Authorize/AuthSecurityParamProvider.swift deleted file mode 100644 index bc0b553..0000000 --- a/Sources/UberAuth/Authorize/AuthSecurityParamProvider.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// AuthSecurityParamProvider.swift -// UberAuth -// -// Copyright © 2024 Uber Technologies, Inc. All rights reserved. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. - -/// Manages per-request nonce and state for a single authorization flow. -final class AuthSecurityParamProvider { - - private let clientNonce: String? - private(set) var nonce: String? - private(set) var state: String? - - init(nonce: String? = nil) { - self.clientNonce = nonce - } - - /// Generates fresh nonce and state values for a new authorization request. - func begin() { - nonce = clientNonce ?? Nonce.generate() - state = State.generate() - } - - /// Consumes and clears the pending state, returning its value. - /// Called by `handle(response:)` on the native deep-link path. - func consumeState() -> String? { - defer { state = nil } - return state - } - - /// Clears all pending values and returns the nonce. - /// Called once the authorization flow completes (success or failure). - func end() -> String? { - defer { nonce = nil; state = nil } - return nonce - } -} diff --git a/Sources/UberAuth/Authorize/AuthorizationCodeAuthProvider.swift b/Sources/UberAuth/Authorize/AuthorizationCodeAuthProvider.swift index ce1c10e..b3cebab 100644 --- a/Sources/UberAuth/Authorize/AuthorizationCodeAuthProvider.swift +++ b/Sources/UberAuth/Authorize/AuthorizationCodeAuthProvider.swift @@ -27,47 +27,53 @@ import Foundation import UberCore public final class AuthorizationCodeAuthProvider: AuthProviding { - + // MARK: Public Properties - + public let clientID: String - + public let redirectURI: String - + public typealias Completion = (Result) -> Void - + + public typealias NonceGenerator = () -> String + public static let defaultScopes = ["profile"] // MARK: Internal Properties - + var currentSession: AuthenticationSessioning? - + typealias AuthenticationSessionBuilder = (ASPresentationAnchor, String, URL, String?, AuthCompletion) -> (AuthenticationSessioning) - + // MARK: Private Properties private let applicationLauncher: ApplicationLaunching - + private let authenticationSessionBuilder: AuthenticationSessionBuilder? - + private var completion: Completion? - + private let configurationProvider: ConfigurationProviding - + private let pkce = PKCE() - private let paramsProvider: AuthSecurityParamProvider + private let nonceGenerator: NonceGenerator + + private var pendingNonce: String? + + private var pendingState: String? private let presentationAnchor: ASPresentationAnchor - + private let responseParser: AuthorizationCodeResponseParsing - + private let shouldExchangeAuthCode: Bool - + private let networkProvider: NetworkProviding - + private let tokenManager: TokenManaging - + private let scopes: [String] private let prompt: Prompt? @@ -80,7 +86,7 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { scopes: [String] = AuthorizationCodeAuthProvider.defaultScopes, shouldExchangeAuthCode: Bool = false, prompt: Prompt? = nil, - nonce: String? = nil, + nonceGenerator: NonceGenerator? = nil, environment: UberEnvironment = .production) { self.configurationProvider = ConfigurationProvider() self.applicationLauncher = UIApplication.shared @@ -95,7 +101,7 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { self.tokenManager = TokenManager(environment: environment) self.scopes = scopes self.prompt = prompt - self.paramsProvider = AuthSecurityParamProvider(nonce: nonce) + self.nonceGenerator = nonceGenerator ?? Nonce.generate } init(presentationAnchor: ASPresentationAnchor = .init(), @@ -103,7 +109,7 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { scopes: [String] = AuthorizationCodeAuthProvider.defaultScopes, prompt: Prompt? = nil, shouldExchangeAuthCode: Bool = false, - nonce: String? = nil, + nonceGenerator: @escaping NonceGenerator = Nonce.generate, configurationProvider: ConfigurationProviding = ConfigurationProvider(), applicationLauncher: ApplicationLaunching = UIApplication.shared, responseParser: AuthorizationCodeResponseParsing = AuthorizationCodeResponseParser(), @@ -124,7 +130,7 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { self.tokenManager = tokenManager self.scopes = scopes self.prompt = prompt - self.paramsProvider = AuthSecurityParamProvider(nonce: nonce) + self.nonceGenerator = nonceGenerator } // MARK: AuthProviding @@ -132,12 +138,16 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { public func execute(authDestination: AuthDestination, prefill: Prefill? = nil, completion: @escaping Completion) { - paramsProvider.begin() + pendingNonce = nonceGenerator() + pendingState = State.generate() + // Upon completion, intercept result and exchange for token if enabled let authCompletion: Completion = { [weak self] result in guard let self else { return } - let nonce = paramsProvider.end() + let nonce = pendingNonce + pendingNonce = nil + pendingState = nil switch result { case .success(let client): @@ -156,7 +166,6 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { case .failure: break } - completion(result) self.completion = nil } @@ -171,42 +180,46 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { ) } ) - + self.completion = authCompletion } /// - Throws: `UberAuthError` public func execute(authDestination: AuthDestination, prefill: Prefill? = nil) async throws -> Client { - - paramsProvider.begin() + + pendingNonce = nonceGenerator() + pendingState = State.generate() let requestURI = await executePar(prefill: prefill) - + let client = try await executeLogin(authDestination: authDestination, requestURI: requestURI) - + if shouldExchangeAuthCode, let code = client.authorizationCode { return try await exchange(code: code) } - - let nonce = paramsProvider.end() + + let nonce = pendingNonce + pendingNonce = nil + pendingState = nil return Client( authorizationCode: client.authorizationCode, accessToken: client.accessToken, nonce: nonce ) } - + public func logout() -> Bool { tokenManager.deleteToken(identifier: TokenManager.defaultAccessTokenIdentifier) } - + public func handle(response url: URL) -> Bool { guard responseParser.isValidResponse(url: url, matching: redirectURI) else { return false } - guard let pending = paramsProvider.consumeState() else { return false } + guard let pending = pendingState else { return false } + pendingState = nil guard State.value(from: url) == pending else { completion?(.failure(.stateMismatch)) return true @@ -217,11 +230,11 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { return true } - + public var isLoggedIn: Bool { tokenManager.getToken(identifier: TokenManager.defaultAccessTokenIdentifier) != nil } - + // MARK: - Private private func executeLogin(authDestination: AuthDestination, @@ -241,7 +254,7 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { ) } } - + func executeLogin(authDestination: AuthDestination, requestURI: String?) async throws -> Client { switch authDestination { @@ -250,33 +263,33 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { case.native(let appPriority): try await executeNativeLogin(appPriority: appPriority, requestURI: requestURI) } - + } - + /// Performs login using an embedded browser within the third party client. /// - Parameters: /// - completion: A closure to handle the login result private func executeInAppLogin(requestURI: String?, completion: @escaping Completion) { - + // Only execute one authentication session at a time guard currentSession == nil else { completion(.failure(.existingAuthSession)) return } - + let request = AuthorizeRequest( app: nil, clientID: clientID, codeChallenge: shouldExchangeAuthCode ? pkce.codeChallenge : nil, - nonce:paramsProvider.nonce, + nonce: pendingNonce, prompt: prompt, redirectURI: redirectURI, requestURI: requestURI, scopes: scopes, - state:paramsProvider.state + state: pendingState ) - + guard let url = request.url(baseUrl: baseUrl) else { completion(.failure(.invalidRequest("Invalid base URL"))) return @@ -287,72 +300,72 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { completion(.failure(.invalidRequest("Invalid redirect URI"))) return } - - currentSession = authenticationSessionBuilder?(ASPresentationAnchor(), callbackURLScheme, url,paramsProvider.state, completion) ?? + + currentSession = authenticationSessionBuilder?(ASPresentationAnchor(), callbackURLScheme, url, pendingState, completion) ?? AuthenticationSession( anchor: presentationAnchor, callbackURLScheme: callbackURLScheme, url: url, - pendingState:paramsProvider.state, + pendingState: pendingState, completion: { [weak self] result in guard let self else { return } completion(result) currentSession = nil } ) - + currentSession?.start() } - + private func executeInAppLogin(requestURI: String?) async throws -> Client { // Only execute one authentication session at a time guard currentSession == nil else { throw UberAuthError.existingAuthSession } - + let request = AuthorizeRequest( app: nil, clientID: clientID, codeChallenge: shouldExchangeAuthCode ? pkce.codeChallenge : nil, - nonce:paramsProvider.nonce, + nonce: pendingNonce, prompt: prompt, redirectURI: redirectURI, requestURI: requestURI, scopes: scopes, - state:paramsProvider.state + state: pendingState ) - + guard let url = request.url(baseUrl: baseUrl) else { throw UberAuthError.invalidRequest("Invalid base URL") } - + guard let callbackURL = URL(string: redirectURI), let callbackURLScheme = callbackURL.scheme else { throw UberAuthError.invalidRequest("Invalid redirect URI") } - + return try await withCheckedThrowingContinuation { continuation in if let sessionBuilder = authenticationSessionBuilder { currentSession = sessionBuilder( presentationAnchor, callbackURLScheme, url, - paramsProvider.state, + pendingState, { [weak self] result in continuation.resume(with: result) self?.currentSession = nil }) } else { - currentSession = AuthenticationSession(anchor: presentationAnchor, callbackURLScheme: callbackURLScheme, url: url, pendingState:paramsProvider.state) { [weak self] result in + currentSession = AuthenticationSession(anchor: presentationAnchor, callbackURLScheme: callbackURLScheme, url: url, pendingState: pendingState) { [weak self] result in continuation.resume(with: result) self?.currentSession = nil - + } } - + currentSession?.start() - + } } - + /// Performs login using one of the native Uber applications if available. /// /// There are three possible destinations for auth through this method: @@ -377,9 +390,9 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { private func executeNativeLogin(appPriority: [UberApp], requestURI: String?, completion: @escaping Completion) { - + var nativeLaunched = false - + // Executes the asynchronous operation `launch` serially for each app in appPriority // Stops the execution after the first app is successfully launched AsyncDispatcher.exec( @@ -394,7 +407,7 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { guard !nativeLaunched else { return } - + // If no native app was launched, fall back to in app login self?.executeInAppLogin( requestURI: requestURI, @@ -403,11 +416,11 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { } ) } - + private func executeNativeLogin(appPriority: [UberApp], requestURI: String?) async throws -> Client { for app in appPriority { let launched = await launch(context: (app, requestURI)) - + if launched { return try await withCheckedThrowingContinuation { continuation in self.completion = { result in @@ -417,10 +430,10 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { } } } - + return try await executeInAppLogin(requestURI: requestURI) } - + /// Attempts to launch a native app with an SSO universal link. /// Calls a closure with a boolean indicating if the application was successfully opened. /// @@ -437,23 +450,23 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { completion?(false) return } - + // .login not supported for native auth var prompt = prompt prompt?.remove(.login) - + let request = AuthorizeRequest( app: app, clientID: clientID, codeChallenge: shouldExchangeAuthCode ? pkce.codeChallenge : nil, - nonce:paramsProvider.nonce, + nonce: pendingNonce, prompt: prompt, redirectURI: redirectURI, requestURI: requestURI, scopes: scopes, - state:paramsProvider.state + state: pendingState ) - + guard let url = request.url(baseUrl: baseUrl) else { completion?(false) return @@ -468,32 +481,32 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { ) } } - + func launch(context: (app: UberApp, requestURI: String?)) async -> Bool { let (app, requestURI) = context guard configurationProvider.isInstalled(app: app, defaultIfUnregistered: true) else { return false } - + // .login not supported for native auth var prompt = prompt prompt?.remove(.login) - + let request = AuthorizeRequest( app: app, clientID: clientID, codeChallenge: shouldExchangeAuthCode ? pkce.codeChallenge : nil, - nonce:paramsProvider.nonce, + nonce: pendingNonce, prompt: prompt, redirectURI: redirectURI, requestURI: requestURI, scopes: scopes, - state:paramsProvider.state + state: pendingState ) - + guard let url = request.url(baseUrl: baseUrl) else { return false } - + return await applicationLauncher.launch(url) } - + private func executePar(prefill: Prefill?, @@ -506,8 +519,8 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { let request = ParRequest( clientID: clientID, prefill: prefill.dictValue, - nonce:paramsProvider.nonce, - state:paramsProvider.state + nonce: pendingNonce, + state: pendingState ) networkProvider.execute( @@ -522,31 +535,31 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { } ) } - + func executePar(prefill: Prefill?) async -> String? { guard let prefill else { return nil } - - let request = ParRequest(clientID: clientID, prefill: prefill.dictValue, nonce:paramsProvider.nonce, state:paramsProvider.state) - + + let request = ParRequest(clientID: clientID, prefill: prefill.dictValue, nonce: pendingNonce, state: pendingState) + let result = try? await networkProvider.execute(request: request) - + return result?.requestURI } - + // MARK: Token Exchange - + /// Makes a request to the /token endpoing to exchange the authorization code /// for an access token. /// - Parameter code: The authorization code to exchange private func exchange(code: String, nonce: String?, completion: @escaping Completion) { - + let request = TokenRequest( clientID: clientID, authorizationCode: code, redirectURI: redirectURI, codeVerifier: pkce.codeVerifier ) - + networkProvider.execute( request: request, completion: { [weak self] result in @@ -575,19 +588,21 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { } ) } - + func exchange(code: String) async throws -> Client { - let nonce = paramsProvider.end() - + let nonce = pendingNonce + pendingNonce = nil + pendingState = nil + let request = TokenRequest( clientID: clientID, authorizationCode: code, redirectURI: redirectURI, codeVerifier: pkce.codeVerifier ) - + let response = try await networkProvider.execute(request: request) - + // If a nonce was sent, the id_token MUST contain a matching nonce claim if let sentNonce = nonce { guard let idToken = response.idToken, @@ -596,7 +611,7 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { throw UberAuthError.nonceMismatch } } - + let client = Client(tokenResponse: response, nonce: nonce) if let accessToken = client.accessToken { _ = tokenManager @@ -605,7 +620,7 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { identifier: TokenManager.defaultAccessTokenIdentifier ) } - + return client } @@ -613,7 +628,7 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { fileprivate extension Client { - + init(tokenResponse: TokenRequest.Response, nonce: String? = nil) { self = Client( authorizationCode: nil, diff --git a/examples/UberSDK/UberSDKTests/UberAuth/AuthorizationCodeAuthProviderTests.swift b/examples/UberSDK/UberSDKTests/UberAuth/AuthorizationCodeAuthProviderTests.swift index 0d0e22f..69f0e05 100644 --- a/examples/UberSDK/UberSDKTests/UberAuth/AuthorizationCodeAuthProviderTests.swift +++ b/examples/UberSDK/UberSDKTests/UberAuth/AuthorizationCodeAuthProviderTests.swift @@ -697,7 +697,7 @@ final class AuthorizationCodeAuthProviderTests: XCTestCase { let provider = AuthorizationCodeAuthProvider( shouldExchangeAuthCode: true, - nonce: "test-nonce", + nonceGenerator: { "test-nonce" }, configurationProvider: configurationProvider, applicationLauncher: applicationLauncher, networkProvider: networkProvider @@ -972,7 +972,7 @@ extension AuthorizationCodeAuthProviderTests { let provider = AuthorizationCodeAuthProvider( authenticationSessionBuilder: authSessionBuilder, shouldExchangeAuthCode: true, - nonce: "test-nonce", + nonceGenerator: { "test-nonce" }, configurationProvider: configurationProvider, networkProvider: mockNetwork, tokenManager: mockTokenManager @@ -983,7 +983,7 @@ extension AuthorizationCodeAuthProviderTests { XCTAssertNotNil(client.accessToken) XCTAssertEqual(mockNetwork.executeCallCount, 1) } - + func test_execute_async_withPrefill_executesPAR() async throws { let mockNetwork = NetworkProvidingMock() mockNetwork.executeAsyncResult = .success(Par( @@ -1198,7 +1198,7 @@ extension AuthorizationCodeAuthProviderTests { authenticationSessionBuilder: authSessionBuilder, scopes: ["openid", "profile"], shouldExchangeAuthCode: true, - nonce: "test-nonce", + nonceGenerator: { "test-nonce" }, configurationProvider: configurationProvider, networkProvider: mockNetwork, tokenManager: mockTokenManager From 94cf79d08476b61dfc40853119ca9fd54cb8754e Mon Sep 17 00:00:00 2001 From: luizgustavo09 Date: Fri, 5 Jun 2026 10:36:28 -0300 Subject: [PATCH 3/3] Update changelog and fix test --- CHANGELOG.md | 6 +++++- .../UberAuth/Authorize/AuthorizationCodeAuthProvider.swift | 4 ++-- Sources/UberCore/Info.plist | 2 +- Sources/UberRides/Info.plist | 2 +- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25a9a28..403e8f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,12 @@ # Change Log +## [2.3.0] 2026-06-05 + +* [Pull #337](https://github.com/uber/uber-ios-sdk/pull/337) OIDC Nonce and OAuth State Support + ## [2.2.1] 2026-05-13 -* [Pull #332](https://github.com/uber/uber-ios-sdk/pull/335) Clear plist fields +* [Pull #335](https://github.com/uber/uber-ios-sdk/pull/335) Clear plist fields ## [2.2.0] 2026-05-05 diff --git a/Sources/UberAuth/Authorize/AuthorizationCodeAuthProvider.swift b/Sources/UberAuth/Authorize/AuthorizationCodeAuthProvider.swift index b3cebab..2abb8c5 100644 --- a/Sources/UberAuth/Authorize/AuthorizationCodeAuthProvider.swift +++ b/Sources/UberAuth/Authorize/AuthorizationCodeAuthProvider.swift @@ -164,9 +164,9 @@ public final class AuthorizationCodeAuthProvider: AuthProviding { nonce: nonce ))) case .failure: - break + completion(result) } - completion(result) + self.completion = nil } diff --git a/Sources/UberCore/Info.plist b/Sources/UberCore/Info.plist index 90af859..0cee5a5 100644 --- a/Sources/UberCore/Info.plist +++ b/Sources/UberCore/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 2.2.1 + 2.3.0 CFBundleVersion $(CURRENT_PROJECT_VERSION) NSPrincipalClass diff --git a/Sources/UberRides/Info.plist b/Sources/UberRides/Info.plist index 51c4067..344518f 100644 --- a/Sources/UberRides/Info.plist +++ b/Sources/UberRides/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 2.2.1 + 2.3.0 CFBundleSignature ???? CFBundleVersion