diff --git a/CHANGELOG.md b/CHANGELOG.md index 266fde42..5b30c0e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ When migrating to 0.8, you may need to add `import UberCore` to files previously * `LoginManager` now uses `SFAuthenticationSession`, `SFSafariViewController`, or external Safari for web-based OAuth flows. * `Deeplinking` protocol simplified. Public properties from the previous protocol is now available under the `.url` property. * `UberAuthenticating` protocol simplified. +* `AccessToken` adds two new initializers intended to make custom OAuth flows easier. Fixes [Issue #187](https://github.com/uber/rides-ios-sdk/issues/187) ### Moved to UberCore diff --git a/source/UberCore/Authentication/Tokens/AccessToken.swift b/source/UberCore/Authentication/Tokens/AccessToken.swift index deed1286..770bdde6 100644 --- a/source/UberCore/Authentication/Tokens/AccessToken.swift +++ b/source/UberCore/Authentication/Tokens/AccessToken.swift @@ -22,11 +22,16 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -/// Stores information about an access token used for authorizing requests. -@objc(UBSDKAccessToken) public class AccessToken: NSObject, NSCoding, Decodable { - +/** + Stores information about an access token used for authorizing requests. + + This class implements NSCoding, but its representation is an internal representation + not compatible with the OAuth representation. Use an initializer if you want to serialize this + via an OAuth representation. + */ +@objc(UBSDKAccessToken) public class AccessToken: NSObject, NSCoding { /// String containing the bearer token. - @objc public private(set) var tokenString: String? + @objc public private(set) var tokenString: String /// String containing the refresh token. @objc public private(set) var refreshToken: String? @@ -40,15 +45,63 @@ /** Initializes an AccessToken with the provided tokenString - - parameter tokenString: The tokenString to use for this AccessToken - - - returns: an initialized AccessToken object + - parameter tokenString: The access token string */ @objc public init(tokenString: String) { + self.tokenString = tokenString super.init() + } + + /** + Initializes an AccessToken with the provided parameters + + - parameter tokenString: The access token string + - parameter refreshToken: String containing the refresh token. + - parameter expirationDate: The expiration date for this access token + - parameter grantedScopes: The scopes this token is valid for + */ + @objc public init(tokenString: String, + refreshToken: String?, + expirationDate: Date?, + grantedScopes: [UberScope]) { + self.tokenString = tokenString + self.refreshToken = refreshToken + self.expirationDate = expirationDate + self.grantedScopes = grantedScopes + super.init() + } + + /** + Initializes an AccessToken using a dictionary with key/values matching + the OAuth access token response. + + See https://tools.ietf.org/html/rfc6749#section-5.1 for more details. + The `token_type` parameter is not required for this initializer. + + - parameter oauthDictionary: A dictionary with key/values matching + the OAuth access token response. + */ + @objc public init?(oauthDictionary: [String: Any]) { + guard let tokenString = oauthDictionary["access_token"] as? String else { return nil } self.tokenString = tokenString + self.refreshToken = oauthDictionary["refresh_token"] as? String + if let expiresIn = oauthDictionary["expires_in"] as? Double { + self.expirationDate = Date(timeIntervalSinceNow: expiresIn) + } else if let expiresIn = oauthDictionary["expires_in"] as? String, + let expiresInDouble = Double(expiresIn) { + self.expirationDate = Date(timeIntervalSinceNow: expiresInDouble) + } + self.grantedScopes = (oauthDictionary["scope"] as? String)?.toUberScopesArray() ?? [] } + // MARK: NSCoding methods. + + /** + Note for reference. It would be better if these NSCoding methods allowed for serialization/deserialization for JSON. + However, this is used for serializing to Keychain via NSKeyedArchiver, and would take work to maintain backwards compatibility + if this was changed. Also, the OAuth `expires_in` parameter is a relative seconds string, which can't be stored by itself. + */ + /** Initializer to build an accessToken from the provided NSCoder. Allows for serialization of an AccessToken @@ -58,7 +111,6 @@ - returns: An initialized AccessToken, or nil if something went wrong */ @objc public required init?(coder decoder: NSCoder) { - super.init() guard let token = decoder.decodeObject(forKey: "tokenString") as? String else { return nil } @@ -68,6 +120,7 @@ if let scopesString = decoder.decodeObject(forKey: "grantedScopes") as? String { grantedScopes = scopesString.toUberScopesArray() } + super.init() } /** @@ -81,26 +134,4 @@ coder.encode(self.expirationDate, forKey: "expirationDate") coder.encode(self.grantedScopes.toUberScopeString(), forKey: "grantedScopes") } - - /** - Mapping function used by ObjectMapper. Builds an AccessToken using the provided - Map data - - - parameter map: The Map to use for populatng this AccessToken. - */ - enum CodingKeys: String, CodingKey { - case tokenString = "access_token" - case refreshToken = "refresh_token" - case expirationDate = "expiration_date" - case scopesString = "scope" - } - - public required init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - tokenString = try container.decode(String.self, forKey: .tokenString) - refreshToken = try container.decodeIfPresent(String.self, forKey: .refreshToken) - expirationDate = try container.decodeIfPresent(Date.self, forKey: .expirationDate) - let scopesString = try container.decodeIfPresent(String.self, forKey: .scopesString) - grantedScopes = scopesString?.toUberScopesArray() ?? [] - } } diff --git a/source/UberCore/Authentication/Tokens/AccessTokenFactory.swift b/source/UberCore/Authentication/Tokens/AccessTokenFactory.swift index 1cc719ce..c3a3d907 100644 --- a/source/UberCore/Authentication/Tokens/AccessTokenFactory.swift +++ b/source/UberCore/Authentication/Tokens/AccessTokenFactory.swift @@ -27,15 +27,15 @@ import Foundation /** Factory class to build access tokens */ -@objc(UBSDKAccessTokenFactory) class AccessTokenFactory: NSObject { +@objc(UBSDKAccessTokenFactory) public class AccessTokenFactory: NSObject { /** Builds an AccessToken from the provided redirect URL - - throws: RidesAuthenticationError + - throws: UberAuthenticationError - parameter url: The URL to parse the token from - returns: An initialized AccessToken, or nil if one couldn't be created */ - static func createAccessToken(fromRedirectURL redirectURL: URL) throws -> AccessToken { + public static func createAccessToken(fromRedirectURL redirectURL: URL) throws -> AccessToken { guard var components = URLComponents(url: redirectURL, resolvingAgainstBaseURL: false) else { throw UberAuthenticationErrorFactory.errorForType(ridesAuthenticationErrorType: .invalidResponse) } @@ -60,23 +60,32 @@ Factory class to build access tokens } queryDictionary[queryItem.name] = value } - if let error = queryDictionary["error"] as? String { + + return try createAccessToken(from: queryDictionary) + } + + /** + Builds an AccessToken from the provided JSON data + + - throws: UberAuthenticationError + - parameter jsonData: The JSON Data to parse the token from + - returns: An initialized AccessToken + */ + public static func createAccessToken(fromJSONData jsonData: Data) throws -> AccessToken { + guard let responseDictionary = (try? JSONSerialization.jsonObject(with: jsonData, options: [])) as? [String: Any] else { + throw UberAuthenticationErrorFactory.errorForType(ridesAuthenticationErrorType: .invalidResponse) + } + return try createAccessToken(from: responseDictionary) + } + + private static func createAccessToken(from oauthResponseDictionary: [String: Any]) throws -> AccessToken { + if let error = oauthResponseDictionary["error"] as? String { guard let error = UberAuthenticationErrorFactory.createRidesAuthenticationError(rawValue: error) else { throw UberAuthenticationErrorFactory.errorForType(ridesAuthenticationErrorType: .invalidRequest) } throw error - } else { - if let expiresInString = queryDictionary["expires_in"] as? String { - let expiresInSeconds = TimeInterval(atof(expiresInString)) - let expirationDateSeconds = Date().timeIntervalSince1970 + expiresInSeconds - queryDictionary["expiration_date"] = expirationDateSeconds - queryDictionary.removeValue(forKey: "expires_in") - } - - if let json = try? JSONSerialization.data(withJSONObject: queryDictionary, options: []), - let token = try? JSONDecoder.uberDecoder.decode(AccessToken.self, from: json) { - return token - } + } else if let token = AccessToken(oauthDictionary: oauthResponseDictionary) { + return token } throw UberAuthenticationErrorFactory.errorForType(ridesAuthenticationErrorType: .invalidResponse) } diff --git a/source/UberCoreTests/AccessTokenFactoryTests.swift b/source/UberCoreTests/AccessTokenFactoryTests.swift index 6449391b..78dc0417 100644 --- a/source/UberCoreTests/AccessTokenFactoryTests.swift +++ b/source/UberCoreTests/AccessTokenFactoryTests.swift @@ -33,8 +33,6 @@ class AccessTokenFactoryTests: XCTestCase { private let allowedScopesString = "profile history" private let errorString = "invalid_parameters" - private let maxExpirationDifference = 2.0 - override func setUp() { super.setUp() // Put setup code here. This method is called before the invocation of each test method in the class. @@ -54,21 +52,12 @@ class AccessTokenFactoryTests: XCTestCase { return } do { - let expectedExpirationInterval = Date().timeIntervalSince1970 + expirationTime - let token : AccessToken = try AccessTokenFactory.createAccessToken(fromRedirectURL: url) XCTAssertNotNil(token) XCTAssertEqual(token.tokenString, tokenString) XCTAssertEqual(token.refreshToken, refreshTokenString) XCTAssertEqual(token.grantedScopes.toUberScopeString(), allowedScopesString) - - guard let expiration = token.expirationDate?.timeIntervalSince1970 else { - XCTAssert(false) - return - } - - let timeDiff = abs(expiration - expectedExpirationInterval) - XCTAssertLessThanOrEqual(timeDiff, maxExpirationDifference) + UBSDKAssert(date: token.expirationDate!, approximatelyIn: expirationTime) } catch _ as NSError { XCTAssert(false) @@ -166,21 +155,12 @@ class AccessTokenFactoryTests: XCTestCase { return } do { - let expectedExpirationInterval = Date().timeIntervalSince1970 + expirationTime - let token : AccessToken = try AccessTokenFactory.createAccessToken(fromRedirectURL: url) XCTAssertNotNil(token) XCTAssertEqual(token.tokenString, tokenString) XCTAssertEqual(token.refreshToken, refreshTokenString) XCTAssertEqual(token.grantedScopes.toUberScopeString(), allowedScopesString) - - guard let expiration = token.expirationDate?.timeIntervalSince1970 else { - XCTAssert(false) - return - } - - let timeDiff = abs(expiration - expectedExpirationInterval) - XCTAssertLessThanOrEqual(timeDiff, maxExpirationDifference) + UBSDKAssert(date: token.expirationDate!, approximatelyIn: expirationTime) } catch { XCTAssert(false) @@ -210,18 +190,23 @@ class AccessTokenFactoryTests: XCTestCase { } func testParseValidJsonStringToAccessToken() { - let tokenString = "tokenString1234" - let jsonString = "{\"access_token\": \"\(tokenString)\"}" - let accessToken = try? JSONDecoder.uberDecoder.decode(AccessToken.self, from: jsonString.data(using: .utf8)!) - - XCTAssertNotNil(accessToken) - XCTAssertEqual(accessToken?.tokenString, tokenString) + let jsonString = "{\"access_token\": \"\(tokenString)\", \"refresh_token\": \"\(refreshTokenString)\", \"expires_in\": \"\(expirationTime)\", \"scope\": \"\(allowedScopesString)\"}" + + guard let accessToken = try? AccessTokenFactory.createAccessToken(fromJSONData: jsonString.data(using: .utf8)!) else { + XCTFail() + return + } + XCTAssertEqual(accessToken.tokenString, tokenString) + XCTAssertEqual(accessToken.refreshToken, refreshTokenString) + UBSDKAssert(date: accessToken.expirationDate!, approximatelyIn: expirationTime) + XCTAssert(accessToken.grantedScopes.contains(UberScope.profile)) + XCTAssert(accessToken.grantedScopes.contains(UberScope.history)) } func testParseInvalidJsonStringToAccessToken() { let tokenString = "tokenString1234" let jsonString = "{\"access_token\": \"\(tokenString)\"" - let accessToken = try? JSONDecoder.uberDecoder.decode(AccessToken.self, from: jsonString.data(using: .utf8)!) + let accessToken = try? AccessTokenFactory.createAccessToken(fromJSONData: jsonString.data(using: .utf8)!) XCTAssertNil(accessToken) } diff --git a/source/UberCoreTests/OAuthTests.swift b/source/UberCoreTests/OAuthTests.swift index ac4e77bf..5389f489 100644 --- a/source/UberCoreTests/OAuthTests.swift +++ b/source/UberCoreTests/OAuthTests.swift @@ -31,6 +31,10 @@ class OAuthTests: XCTestCase { var error: NSError? let timeout: TimeInterval = 2 let tokenString = "accessToken1234" + let refreshTokenString = "refresh" + let expiresIn = 10030.23 + let scope = "profile history" + private var redirectURI: URL! override func setUp() { @@ -47,12 +51,6 @@ class OAuthTests: XCTestCase { super.tearDown() } - func testBuildinigWithString() { - let tokenString = "accessTokenString" - let token = AccessToken(tokenString: tokenString) - XCTAssertEqual(token.tokenString, tokenString) - } - /** Test saving and object in keychain and retrieving it. */ @@ -123,6 +121,23 @@ class OAuthTests: XCTestCase { XCTAssert(queryItems.contains(URLQueryItem(name: "redirect_uri", value: redirectURI.absoluteString))) } + func testInitializeAccessTokenFromString() { + let token = AccessToken(tokenString: tokenString) + XCTAssertEqual(token.tokenString, tokenString) + } + + func testInitializeAccessTokenFromOAuthDictionary() { + guard let token = tokenFixture() else { + XCTFail() + return + } + XCTAssertEqual(token.tokenString, tokenString) + XCTAssertEqual(token.refreshToken, refreshTokenString) + UBSDKAssert(date: token.expirationDate!, approximatelyIn: expiresIn) + XCTAssert(token.grantedScopes.contains(UberScope.profile)) + XCTAssert(token.grantedScopes.contains(UberScope.history)) + } + func loginCompletion() -> ((_ accessToken: AccessToken?, _ error: NSError?) -> Void) { return { token, error in self.accessToken = token @@ -130,17 +145,28 @@ class OAuthTests: XCTestCase { self.testExpectation.fulfill() } } + + // Mark: Helper + + func tokenFixture(_ accessToken: String = "accessToken1234") -> AccessToken? + { + var jsonDictionary = [String: Any]() + jsonDictionary["access_token"] = accessToken + jsonDictionary["refresh_token"] = refreshTokenString + jsonDictionary["expires_in"] = expiresIn + jsonDictionary["scope"] = scope + return AccessToken(oauthDictionary: jsonDictionary) + } } -// Mark: Helper - -func tokenFixture(_ accessToken: String = "token") -> AccessToken? -{ - var jsonDictionary = [String: String]() - jsonDictionary["access_token"] = accessToken - jsonDictionary["refresh_token"] = "refresh" - jsonDictionary["expires_in"] = "10030.23" - jsonDictionary["scope"] = "profile history" - let jsonData = try! JSONEncoder().encode(jsonDictionary) - return try? JSONDecoder.uberDecoder.decode(AccessToken.self, from: jsonData) +extension XCTestCase { + func UBSDKAssert(date: Date, approximatelyEqualTo otherDate: Date, _ message: String = "") { + let allowedDifference: TimeInterval = 2 + let difference = abs(date.timeIntervalSince(otherDate)) + XCTAssert(difference < allowedDifference, message) + } + + func UBSDKAssert(date: Date, approximatelyIn seconds: TimeInterval, _ message: String = "") { + UBSDKAssert(date: date, approximatelyEqualTo: Date(timeIntervalSinceNow: seconds), message) + } } diff --git a/source/UberCoreTests/RefreshEndpointTests.swift b/source/UberCoreTests/RefreshEndpointTests.swift index eaa0035a..91f0d8c1 100644 --- a/source/UberCoreTests/RefreshEndpointTests.swift +++ b/source/UberCoreTests/RefreshEndpointTests.swift @@ -88,8 +88,6 @@ class RefreshEndpointTests: XCTestCase { XCTAssertTrue(queryItems.contains(expectedClientID)) XCTAssertTrue(queryItems.contains(expectedRefreshToken)) - - waitForExpectations(timeout: timeout, handler: { error in if let error = error { diff --git a/source/UberRides/RidesClient.swift b/source/UberRides/RidesClient.swift index 9dd98c95..b4604f46 100644 --- a/source/UberRides/RidesClient.swift +++ b/source/UberRides/RidesClient.swift @@ -540,7 +540,7 @@ import UberCore var accessToken: AccessToken? if let data = response.data, response.error == nil { - accessToken = try? JSONDecoder.uberDecoder.decode(AccessToken.self, from: data) + accessToken = try? AccessTokenFactory.createAccessToken(fromJSONData: data) } completion(accessToken, response) }