Skip to content
This repository was archived by the owner on Nov 7, 2024. It is now read-only.

Commit 597b997

Browse files
authored
Merge pull request #1 from AckeeCZ/proposal/initial_version
Implement basic Google sign-in flow functionality
2 parents cbf3c14 + d909659 commit 597b997

File tree

12 files changed

+494
-1
lines changed

12 files changed

+494
-1
lines changed

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.DS_Store
2+
/.build
3+
/Packages
4+
/*.xcodeproj
5+
xcuserdata/
6+
DerivedData/
7+
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata

Package.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// swift-tools-version:5.3
2+
import PackageDescription
3+
4+
let package = Package(
5+
name: "OpenGoogleSignInSDK",
6+
platforms: [
7+
.iOS(.v12),
8+
.macOS("10.9")
9+
],
10+
products: [
11+
.library(
12+
name: "OpenGoogleSignInSDK",
13+
targets: ["OpenGoogleSignInSDK"]),
14+
],
15+
dependencies: [],
16+
targets: [
17+
.target(
18+
name: "OpenGoogleSignInSDK",
19+
dependencies: []),
20+
.testTarget(
21+
name: "OpenGoogleSignInSDKTests",
22+
dependencies: ["OpenGoogleSignInSDK"]),
23+
]
24+
)

README.md

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,66 @@
1-
# OpenGoogleSignInSDK
1+
# OpenGoogleSignInSDK
2+
3+
OpenGoogleSignInSDK is an open-source library which takes care of Google Sign-In flow using OAuth 2.0 and can be used as an alternative to [official GoogleSignInSDK](https://developers.google.com/identity/sign-in/ios/sdk).
4+
5+
## Installation
6+
7+
### Swift Package Manager
8+
9+
SDK can be installed using [SPM](https://github.com/apple/swift-package-manager). Just add following line into your `Package.swift`:
10+
11+
```swift
12+
.package(url: "https://github.com/AckeeCZ/OpenGoogleSignInSDK.git", .upToNextMajor(from: "1.0.0")),
13+
```
14+
15+
or use the Xcode `File -> Swift Packages -> Add Package Dependency` and paste the URL of GitHub repository.
16+
### CocoaPods, Carthage
17+
🔜👀
18+
19+
## Integration guide
20+
21+
### Add a URL scheme to your project
22+
23+
Google Sign-In flow requires a URL scheme to be added to your project:
24+
25+
1. Open your project configuration, select your app from the **Targets** section, then select the **Info** tab and expand the **URL Types** section.
26+
2. Click the **+** button, and add your reversed client ID as a URL scheme. The reversed client ID is your client ID with the order of the dot-delimited fields reversed, like on the picture below:
27+
28+
![url-types|OpenGoogleSignInSDK](Resources/url-types.png)
29+
30+
### Set OAuth 2.0 client ID
31+
32+
First, you need to visit [Google API Console](https://console.developers.google.com/apis/credentials?project=_) to obtain OAuth 2.0 client ID of your project. After obtaining the client ID, you need to configure the `clientID` property of `OpenGoogleSignIn` shared instance in your app delegate:
33+
34+
```swift
35+
func application(_ application: UIApplication,
36+
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
37+
OpenGoogleSignIn.shared.clientID = "YOUR_CLIENT_ID"
38+
39+
return true
40+
}
41+
```
42+
43+
### Handle Sign-In process
44+
45+
To handle Sign-In process, you need to set `OpenGoogleSignInDelegate` and implement the protocol:
46+
47+
```swift
48+
OpenGoogleSignIn.shared.delegate = self
49+
50+
...
51+
...
52+
...
53+
54+
func sign(didSignInFor user: GoogleUser?, withError error: GoogleSignInError?) {
55+
// Signed in user or error received here.
56+
// Perform any required operations.
57+
}
58+
```
59+
60+
## Author
61+
62+
[Ackee](https://ackee.cz) team
63+
64+
## License
65+
66+
OpenGoogleSignInSDK is available under the MIT license. See the LICENSE file for more info.

Resources/url-types.png

30.1 KB
Loading
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import Foundation
2+
3+
/// Google sign-in error.
4+
public enum GoogleSignInError: Error, Equatable {
5+
case authenticationError(Error)
6+
case invalidCode
7+
case invalidResponse
8+
case invalidTokenRequest
9+
case networkError(Error)
10+
case tokenDecodingError(Error)
11+
case userCancelledSignInFlow
12+
}
13+
14+
public func == (lhs: Error, rhs: Error) -> Bool {
15+
guard type(of: lhs) == type(of: rhs) else { return false }
16+
let error1 = lhs as NSError
17+
let error2 = rhs as NSError
18+
return error1.domain == error2.domain && error1.code == error2.code && "\(lhs)" == "\(rhs)"
19+
}
20+
21+
extension Equatable where Self : Error {
22+
public static func == (lhs: Self, rhs: Self) -> Bool {
23+
lhs as Error == rhs as Error
24+
}
25+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/// Google sign-in OAuth 2.0 scope.
2+
public enum GoogleSignInScope: String {
3+
case email
4+
case openID = "openid"
5+
case profile
6+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/// Google sign-in user account.
2+
public struct GoogleUser: Codable, Equatable {
3+
public let accessToken: String
4+
public let expiresIn: Int
5+
public let idToken: String
6+
public let refreshToken: String?
7+
public let scope: String
8+
public let tokenType: String
9+
}
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import AuthenticationServices
2+
import OSLog
3+
import UIKit
4+
5+
/// Google sign-in delegate.
6+
public protocol OpenGoogleSignInDelegate: AnyObject {
7+
/// Indicates that sign-in flow has finished and retrieves `GoogleUser` if successful or `error`.
8+
func sign(didSignInFor user: GoogleUser?, withError error: GoogleSignInError?)
9+
}
10+
11+
/// Signs the user in with Google using OAuth 2.0.
12+
public final class OpenGoogleSignIn: NSObject {
13+
14+
// MARK: - Public properties
15+
16+
public weak var delegate: OpenGoogleSignInDelegate?
17+
18+
/// The client ID of the app.
19+
/// It is required for communication with Google API to work.
20+
public var clientID: String = ""
21+
22+
/// Client secret.
23+
/// It is only used when exchanging the authorization code for an access token.
24+
public var clientSecret: String = ""
25+
26+
/// `URLSession` used to perform data tasks.
27+
public var session: URLSession = URLSession.shared
28+
29+
/// Shared `OpenGoogleSignIn` instance
30+
public static let shared: OpenGoogleSignIn = OpenGoogleSignIn()
31+
32+
/// API scopes requested by the app
33+
public var scopes: Set<GoogleSignInScope> = [.email, .openID, .profile]
34+
35+
/// View controller to present Google sign-in flow.
36+
/// Needs to be set for presenting to work correctly.
37+
public weak var presentingViewController: UIViewController? = nil
38+
39+
// MARK: - Private properties
40+
41+
/// Session used to authenticate a user with Google sign-in.
42+
private var authenticationSession: ASWebAuthenticationSession? = nil
43+
44+
/// Google API OAuth 2.0 token url.
45+
private static let tokenURL: URL? = URL(string: "https://www.googleapis.com/oauth2/v4/token")
46+
47+
/// The client's redirect URI, which is based on `clientID`.
48+
private var redirectURI: String {
49+
String(
50+
clientID
51+
.components(separatedBy: ".")
52+
.reversed()
53+
.joined(separator: ".")
54+
) + ":/oauth2redirect/google"
55+
}
56+
57+
/// Authorization `URL` based on parameters provided by the app.
58+
private var authURL: URL {
59+
let scopes = scopes.map { $0.rawValue }.joined(separator: "+")
60+
var components = URLComponents()
61+
62+
components.scheme = "https"
63+
components.host = "accounts.google.com"
64+
components.path = "/o/oauth2/v2/auth"
65+
66+
components.queryItems = [
67+
URLQueryItem(name: "client_id", value: clientID),
68+
URLQueryItem(name: "redirect_uri", value: redirectURI),
69+
URLQueryItem(name: "response_type", value: "code"),
70+
URLQueryItem(name: "scope", value: scopes)
71+
]
72+
73+
return components.url!
74+
}
75+
76+
// MARK: - Initialization
77+
78+
private override init() { }
79+
80+
// MARK: - Public helpers
81+
82+
83+
/// Handles token response.
84+
/// Calls `OpenGoogleSignInDelegate` with valid response or error.
85+
public func handle(_ url: URL) {
86+
handleTokenResponse(using: url) { [weak self] result in
87+
switch result {
88+
case .success(let response):
89+
self?.delegate?.sign(didSignInFor: response, withError: nil)
90+
case .failure(let error):
91+
self?.delegate?.sign(didSignInFor: nil, withError: error)
92+
}
93+
}
94+
}
95+
96+
/// Starts Google sign-in flow.
97+
/// `OpenGoogleSignInDelegate` will be called on success/error.
98+
public func signIn() {
99+
guard !clientID.isEmpty else {
100+
os_log(.error, "You must specify clientID for Google sign-in to work correctly!")
101+
return
102+
}
103+
104+
// Create authentication session with provided parameters
105+
authenticationSession = ASWebAuthenticationSession(
106+
url: authURL,
107+
callbackURLScheme: clientID
108+
) { [weak self] callbackURL, error in
109+
guard let callbackURL = callbackURL else { return }
110+
111+
if let error = error {
112+
// Throw error if received
113+
self?.delegate?.sign(didSignInFor: nil, withError: .authenticationError(error))
114+
} else {
115+
// Handle received `callbackURL` on success
116+
self?.handle(callbackURL)
117+
}
118+
}
119+
120+
// Set `presentationContextProvider` for iOS 13+ modals to work correctly
121+
if #available(iOS 13.0, *) {
122+
authenticationSession?.presentationContextProvider = self
123+
}
124+
125+
// Start authentication session
126+
authenticationSession?.start()
127+
}
128+
129+
// MARK: - Private helpers
130+
131+
/// Decodes `GoogleUser` from OAuth 2.0 response.
132+
private func decodeUser(from data: Data) throws -> GoogleUser {
133+
let decoder = JSONDecoder()
134+
decoder.keyDecodingStrategy = .convertFromSnakeCase
135+
136+
return try decoder.decode(GoogleUser.self, from: data)
137+
}
138+
139+
/// Handles OAuth 2.0 token response.
140+
private func handleTokenResponse(using redirectUrl: URL, completion: @escaping (Result<GoogleUser, GoogleSignInError>) -> Void) {
141+
guard let code = self.parseCode(from: redirectUrl) else {
142+
completion(.failure(.invalidCode))
143+
return
144+
}
145+
146+
guard let tokenRequest = makeTokenRequest(with: code) else {
147+
completion(.failure(.invalidTokenRequest))
148+
return
149+
}
150+
151+
let task = session.dataTask(with: tokenRequest) { data, response, error in
152+
if let error = error {
153+
completion(.failure(.networkError(error)))
154+
return
155+
}
156+
157+
guard let data = data else {
158+
completion(.failure(.invalidResponse))
159+
return
160+
}
161+
162+
do {
163+
completion(.success(try self.decodeUser(from: data)))
164+
} catch {
165+
completion(.failure(.tokenDecodingError(error)))
166+
}
167+
}
168+
task.resume()
169+
}
170+
171+
/// Returns `code` parsed from provided `redirectURL`.
172+
private func parseCode(from redirectURL: URL) -> String? {
173+
let components = URLComponents(url: redirectURL, resolvingAgainstBaseURL: false)
174+
175+
return components?.queryItems?.first(where: { $0.name == "code" })?.value
176+
}
177+
178+
/// Returns `URLRequest` to retrieve Google sign-in OAuth 2.0 token using arameters provided by the app.
179+
private func makeTokenRequest(with code: String) -> URLRequest? {
180+
guard let tokenURL = OpenGoogleSignIn.tokenURL else { return nil }
181+
182+
var request = URLRequest(url: tokenURL)
183+
request.httpMethod = "POST"
184+
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
185+
186+
let parameters = [
187+
"client_id": clientID,
188+
"client_secret": clientSecret,
189+
"code": code,
190+
"grant_type": "authorization_code",
191+
"redirect_uri": redirectURI
192+
]
193+
194+
let body = parameters
195+
.map { "\($0)=\($1)" }
196+
.joined(separator: "&")
197+
198+
request.httpBody = body.data(using: .utf8)
199+
200+
return request
201+
}
202+
}
203+
204+
// MARK: - ASWebAuthenticationPresentationContextProviding
205+
206+
extension OpenGoogleSignIn: ASWebAuthenticationPresentationContextProviding {
207+
public func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
208+
UIApplication.shared.windows.first { $0.isKeyWindow } ?? ASPresentationAnchor()
209+
}
210+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import XCTest
2+
@testable import OpenGoogleSignInSDK
3+
4+
final class MockOpenGoogleSignInDelegate: OpenGoogleSignInDelegate {
5+
var user: GoogleUser?
6+
var error: GoogleSignInError?
7+
8+
private var expectation: XCTestExpectation?
9+
private let testCase: XCTestCase
10+
11+
init(testCase: XCTestCase) {
12+
self.testCase = testCase
13+
}
14+
15+
func expectSignInFinish() {
16+
expectation = testCase.expectation(description: "Expect sign-in flow to finish")
17+
}
18+
19+
// MARK: - OAuthGoogleSignInDelegate
20+
21+
func sign(didSignInFor user: GoogleUser?, withError error: GoogleSignInError?) {
22+
self.user = user
23+
self.error = error
24+
25+
expectation?.fulfill()
26+
}
27+
}

0 commit comments

Comments
 (0)