diff --git a/Sources/OpenGoogleSignInSDK/JSONDecoder.swift b/Sources/OpenGoogleSignInSDK/JSONDecoder.swift new file mode 100644 index 0000000..5684f0a --- /dev/null +++ b/Sources/OpenGoogleSignInSDK/JSONDecoder.swift @@ -0,0 +1,11 @@ +import Foundation + +extension JSONDecoder { + /// Framework's setup of the `JSONDecoder` + /// tailored for its use + static let app: JSONDecoder = { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return decoder + }() +} diff --git a/Sources/OpenGoogleSignInSDK/Model/GoogleSignInError.swift b/Sources/OpenGoogleSignInSDK/Model/GoogleSignInError.swift index aab88c3..f9724be 100644 --- a/Sources/OpenGoogleSignInSDK/Model/GoogleSignInError.swift +++ b/Sources/OpenGoogleSignInSDK/Model/GoogleSignInError.swift @@ -5,10 +5,10 @@ public enum GoogleSignInError: Error, Equatable { case authenticationError(Error) case invalidCode case invalidResponse - case invalidTokenRequest case networkError(Error) case tokenDecodingError(Error) case userCancelledSignInFlow + case noProfile(Error) } public func == (lhs: Error, rhs: Error) -> Bool { diff --git a/Sources/OpenGoogleSignInSDK/Model/GoogleUser.swift b/Sources/OpenGoogleSignInSDK/Model/GoogleUser.swift index 571d6b5..8ccda98 100644 --- a/Sources/OpenGoogleSignInSDK/Model/GoogleUser.swift +++ b/Sources/OpenGoogleSignInSDK/Model/GoogleUser.swift @@ -1,9 +1,54 @@ +import Foundation + /// Google sign-in user account. public struct GoogleUser: Codable, Equatable { + + /// User's profile info. + /// + /// All the properties are optional according + /// to the [documentation](https://googleapis.dev/nodejs/googleapis/latest/oauth2/interfaces/Schema$Userinfo.html#info). + public struct Profile: Codable, Equatable { + + /// The obfuscated ID of the user. + public let id: String? + + /// The user's email address. + public let email: String? + + /// Boolean flag which is true if the email address is verified. + /// Always verified because we only return the user's primary email address. + public let verifiedEmail: Bool? + + /// The user's full name. + public let name: String? + + /// The user's first name. + public let givenName: String? + + /// The user's last name. + public let familyName: String? + + /// The user's gender. + public let gender: String? + + /// URL of the profile page. + public let link: URL? + + /// URL of the user's picture image. + public let picture: URL? + + /// The hosted domain e.g. example.com if the user is Google apps user. + public let hd: String? + + /// The user's preferred locale. + public let locale: String? + } + public let accessToken: String public let expiresIn: Int public let idToken: String public let refreshToken: String? public let scope: String public let tokenType: String + public var profile: Profile? } diff --git a/Sources/OpenGoogleSignInSDK/OpenGoogleSignInSDK.swift b/Sources/OpenGoogleSignInSDK/OpenGoogleSignInSDK.swift index d8d4fa4..69a8dbf 100644 --- a/Sources/OpenGoogleSignInSDK/OpenGoogleSignInSDK.swift +++ b/Sources/OpenGoogleSignInSDK/OpenGoogleSignInSDK.swift @@ -43,6 +43,9 @@ public final class OpenGoogleSignIn: NSObject { /// Google API OAuth 2.0 token url. private static let tokenURL: URL? = URL(string: "https://www.googleapis.com/oauth2/v4/token") + + /// Google API profile url + private static let profileURL: URL? = URL(string: "https://www.googleapis.com/oauth2/v1/userinfo?alt=json") /// The client's redirect URI, which is based on `clientID`. private var redirectURI: String { @@ -136,25 +139,64 @@ public final class OpenGoogleSignIn: NSObject { /// Decodes `GoogleUser` from OAuth 2.0 response. private func decodeUser(from data: Data) throws -> GoogleUser { - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - - return try decoder.decode(GoogleUser.self, from: data) + try JSONDecoder.app.decode(GoogleUser.self, from: data) } /// Handles OAuth 2.0 token response. + /// + /// After successful authentication we made another request + /// to obtain user's profile data, e.g. email and name. private func handleTokenResponse(using redirectUrl: URL, completion: @escaping (Result) -> Void) { - guard let code = self.parseCode(from: redirectUrl) else { + guard let code = parseCode(from: redirectUrl) else { completion(.failure(.invalidCode)) return } guard let tokenRequest = makeTokenRequest(with: code) else { - completion(.failure(.invalidTokenRequest)) + assertionFailure("Invalid token request") + return + } + + makeRequest(tokenRequest) { result in + switch result { + case let .success(data): + do { + let user = try self.decodeUser(from: data) + self.fetchProfile(user: user, completion: completion) + } catch { + completion(.failure(.tokenDecodingError(error))) + } + + case let .failure(error): + completion(.failure(error)) + } + } + } + + private func fetchProfile(user: GoogleUser, completion: @escaping (Result) -> Void) { + guard let profileRequest = makeProfileRequest(user: user) else { + assertionFailure("Invalid profile request") return } - let task = session.dataTask(with: tokenRequest) { data, response, error in + var user = user + + makeRequest(profileRequest) { result in + switch result { + case let .success(data): + let profile = try? JSONDecoder.app.decode(GoogleUser.Profile.self, from: data) + user.profile = profile + completion(.success(user)) + + case let .failure(error): + completion(.failure(.noProfile(error))) + } + } + } + + /// Wrapper for easier `URLRequest` handling. + private func makeRequest(_ request: URLRequest, completion: @escaping (Result) -> Void) { + let task = session.dataTask(with: request) { data, response, error in if let error = error { completion(.failure(.networkError(error))) return @@ -165,11 +207,7 @@ public final class OpenGoogleSignIn: NSObject { return } - do { - completion(.success(try self.decodeUser(from: data))) - } catch { - completion(.failure(.tokenDecodingError(error))) - } + completion(.success(data)) } task.resume() } @@ -182,7 +220,7 @@ public final class OpenGoogleSignIn: NSObject { } /// Returns `URLRequest` to retrieve Google sign-in OAuth 2.0 token using arameters provided by the app. - private func makeTokenRequest(with code: String) -> URLRequest? { + func makeTokenRequest(with code: String) -> URLRequest? { guard let tokenURL = OpenGoogleSignIn.tokenURL else { return nil } var request = URLRequest(url: tokenURL) @@ -205,6 +243,16 @@ public final class OpenGoogleSignIn: NSObject { return request } + + /// Returns `URLRequest` to retrieve user's profile data + func makeProfileRequest(user: GoogleUser) -> URLRequest? { + guard let profileURL = OpenGoogleSignIn.profileURL else { return nil } + + var request = URLRequest(url: profileURL) + request.setValue("Bearer " + user.accessToken, forHTTPHeaderField: "Authorization") + + return request + } } // MARK: - ASWebAuthenticationPresentationContextProviding diff --git a/Tests/OpenGoogleSignInSDKTests/OpenGoogleSignInSDKTests.swift b/Tests/OpenGoogleSignInSDKTests/OpenGoogleSignInSDKTests.swift index 8154250..b22cce0 100644 --- a/Tests/OpenGoogleSignInSDKTests/OpenGoogleSignInSDKTests.swift +++ b/Tests/OpenGoogleSignInSDKTests/OpenGoogleSignInSDKTests.swift @@ -74,6 +74,28 @@ final class OpenGoogleSignInTests: XCTestCase { XCTAssertNotNil(mockDelegate.user) } + // Since `URL` has optional initializer we need to + // check if the URL provided by us is valid and + // therefore we don't need to worry about this edge case. + func test_tokenRequest_isValid() { + // When + let request = sharedInstance.makeTokenRequest(with: "code") + + // Then + XCTAssertNotNil(request) + } + + // Since `URL` has optional initializer we need to + // check if the URL provided by us is valid and + // therefore we don't need to worry about this edge case. + func test_profileRequest_isValid() { + // Given + let user = mockUser() + + // Then + XCTAssertNotNil(sharedInstance.makeProfileRequest(user: user)) + } + // MARK: - Private helpers private func mockUser() -> GoogleUser {