@capgo/capacitor-social-login 8.1.1 → 8.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +215 -35
- package/android/src/main/AndroidManifest.xml +4 -0
- package/android/src/main/java/ee/forgr/capacitor/social/login/OAuth2LoginActivity.java +110 -0
- package/android/src/main/java/ee/forgr/capacitor/social/login/OAuth2Provider.java +848 -0
- package/android/src/main/java/ee/forgr/capacitor/social/login/SocialLoginPlugin.java +27 -1
- package/dist/docs.json +352 -22
- package/dist/esm/definitions.d.ts +167 -3
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/oauth2-provider.d.ts +41 -0
- package/dist/esm/oauth2-provider.js +444 -0
- package/dist/esm/oauth2-provider.js.map +1 -0
- package/dist/esm/web.d.ts +3 -1
- package/dist/esm/web.js +32 -0
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +474 -0
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +474 -0
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/SocialLoginPlugin/OAuth2Provider.swift +575 -0
- package/ios/Sources/SocialLoginPlugin/SocialLoginPlugin.swift +111 -2
- package/package.json +1 -1
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import AuthenticationServices
|
|
3
|
+
import CryptoKit
|
|
4
|
+
import UIKit
|
|
5
|
+
|
|
6
|
+
struct OAuth2TokenResponse: Codable {
|
|
7
|
+
let token_type: String
|
|
8
|
+
let expires_in: Int?
|
|
9
|
+
let access_token: String
|
|
10
|
+
let scope: String?
|
|
11
|
+
let refresh_token: String?
|
|
12
|
+
let id_token: String?
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
struct OAuth2StoredTokens: Codable {
|
|
16
|
+
let accessToken: String
|
|
17
|
+
let refreshToken: String?
|
|
18
|
+
let idToken: String?
|
|
19
|
+
let expiresAt: Date
|
|
20
|
+
let scope: [String]
|
|
21
|
+
let tokenType: String
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
struct OAuth2LoginResponse {
|
|
25
|
+
let providerId: String
|
|
26
|
+
let accessToken: OAuth2AccessToken
|
|
27
|
+
let idToken: String?
|
|
28
|
+
let refreshToken: String?
|
|
29
|
+
let resourceData: [String: Any]?
|
|
30
|
+
let scope: [String]
|
|
31
|
+
let tokenType: String
|
|
32
|
+
let expiresIn: Int?
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
struct OAuth2AccessToken {
|
|
36
|
+
let token: String
|
|
37
|
+
let tokenType: String
|
|
38
|
+
let expires: String?
|
|
39
|
+
let refreshToken: String?
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
struct OAuth2ProviderConfig {
|
|
43
|
+
let appId: String
|
|
44
|
+
let authorizationBaseUrl: String
|
|
45
|
+
let accessTokenEndpoint: String?
|
|
46
|
+
let redirectUrl: String
|
|
47
|
+
let resourceUrl: String?
|
|
48
|
+
let responseType: String
|
|
49
|
+
let pkceEnabled: Bool
|
|
50
|
+
let scope: String
|
|
51
|
+
let additionalParameters: [String: String]?
|
|
52
|
+
let additionalResourceHeaders: [String: String]?
|
|
53
|
+
let logoutUrl: String?
|
|
54
|
+
let logsEnabled: Bool
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
class OAuth2Provider: NSObject {
|
|
58
|
+
private var providers: [String: OAuth2ProviderConfig] = [:]
|
|
59
|
+
private var currentSession: ASWebAuthenticationSession?
|
|
60
|
+
private var currentState: String?
|
|
61
|
+
private var currentCodeVerifier: String?
|
|
62
|
+
private var currentProviderId: String?
|
|
63
|
+
private let tokenStorageKeyPrefix = "CapgoOAuth2ProviderTokens_"
|
|
64
|
+
|
|
65
|
+
func initializeProviders(configs: [String: [String: Any]]) -> [String] {
|
|
66
|
+
var errors: [String] = []
|
|
67
|
+
|
|
68
|
+
for (providerId, config) in configs {
|
|
69
|
+
guard let appId = config["appId"] as? String, !appId.isEmpty else {
|
|
70
|
+
errors.append("oauth2.\(providerId).appId is required")
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
guard let authorizationBaseUrl = config["authorizationBaseUrl"] as? String, !authorizationBaseUrl.isEmpty else {
|
|
74
|
+
errors.append("oauth2.\(providerId).authorizationBaseUrl is required")
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
guard let redirectUrl = config["redirectUrl"] as? String, !redirectUrl.isEmpty else {
|
|
78
|
+
errors.append("oauth2.\(providerId).redirectUrl is required")
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let providerConfig = OAuth2ProviderConfig(
|
|
83
|
+
appId: appId,
|
|
84
|
+
authorizationBaseUrl: authorizationBaseUrl,
|
|
85
|
+
accessTokenEndpoint: config["accessTokenEndpoint"] as? String,
|
|
86
|
+
redirectUrl: redirectUrl,
|
|
87
|
+
resourceUrl: config["resourceUrl"] as? String,
|
|
88
|
+
responseType: config["responseType"] as? String ?? "code",
|
|
89
|
+
pkceEnabled: config["pkceEnabled"] as? Bool ?? true,
|
|
90
|
+
scope: config["scope"] as? String ?? "",
|
|
91
|
+
additionalParameters: config["additionalParameters"] as? [String: String],
|
|
92
|
+
additionalResourceHeaders: config["additionalResourceHeaders"] as? [String: String],
|
|
93
|
+
logoutUrl: config["logoutUrl"] as? String,
|
|
94
|
+
logsEnabled: config["logsEnabled"] as? Bool ?? false
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
providers[providerId] = providerConfig
|
|
98
|
+
|
|
99
|
+
if providerConfig.logsEnabled {
|
|
100
|
+
print("[OAuth2Provider] Initialized provider '\(providerId)' with appId: \(appId), authorizationBaseUrl: \(authorizationBaseUrl)")
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return errors
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private func getProvider(_ providerId: String) -> OAuth2ProviderConfig? {
|
|
108
|
+
return providers[providerId]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private func tokenStorageKey(for providerId: String) -> String {
|
|
112
|
+
return "\(tokenStorageKeyPrefix)\(providerId)"
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
func login(providerId: String, payload: [String: Any], completion: @escaping (Result<OAuth2LoginResponse, Error>) -> Void) {
|
|
116
|
+
guard let config = getProvider(providerId) else {
|
|
117
|
+
completion(.failure(NSError(domain: "OAuth2Provider", code: -1, userInfo: [NSLocalizedDescriptionKey: "OAuth2 provider '\(providerId)' not configured. Call initialize()."])))
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let loginScope = payload["scope"] as? String ?? config.scope
|
|
122
|
+
let state = payload["state"] as? String ?? UUID().uuidString
|
|
123
|
+
let codeVerifier = payload["codeVerifier"] as? String ?? generateCodeVerifier()
|
|
124
|
+
let redirect = payload["redirectUrl"] as? String ?? config.redirectUrl
|
|
125
|
+
let additionalLoginParams = payload["additionalParameters"] as? [String: String]
|
|
126
|
+
|
|
127
|
+
currentState = state
|
|
128
|
+
currentCodeVerifier = codeVerifier
|
|
129
|
+
currentProviderId = providerId
|
|
130
|
+
|
|
131
|
+
var components = URLComponents(string: config.authorizationBaseUrl)
|
|
132
|
+
var queryItems: [URLQueryItem] = [
|
|
133
|
+
URLQueryItem(name: "response_type", value: config.responseType),
|
|
134
|
+
URLQueryItem(name: "client_id", value: config.appId),
|
|
135
|
+
URLQueryItem(name: "redirect_uri", value: redirect),
|
|
136
|
+
URLQueryItem(name: "state", value: state)
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
if !loginScope.isEmpty {
|
|
140
|
+
queryItems.append(URLQueryItem(name: "scope", value: loginScope))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Add PKCE for code flow
|
|
144
|
+
if config.responseType == "code" && config.pkceEnabled {
|
|
145
|
+
let codeChallenge = generateCodeChallenge(from: codeVerifier)
|
|
146
|
+
queryItems.append(URLQueryItem(name: "code_challenge", value: codeChallenge))
|
|
147
|
+
queryItems.append(URLQueryItem(name: "code_challenge_method", value: "S256"))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Add additional parameters from config
|
|
151
|
+
if let additionalParams = config.additionalParameters {
|
|
152
|
+
for (key, value) in additionalParams {
|
|
153
|
+
queryItems.append(URLQueryItem(name: key, value: value))
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Add additional parameters from login options
|
|
158
|
+
if let loginParams = additionalLoginParams {
|
|
159
|
+
for (key, value) in loginParams {
|
|
160
|
+
queryItems.append(URLQueryItem(name: key, value: value))
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
components?.queryItems = queryItems
|
|
165
|
+
|
|
166
|
+
guard let authUrl = components?.url, let callbackScheme = URL(string: redirect)?.scheme else {
|
|
167
|
+
completion(.failure(NSError(domain: "OAuth2Provider", code: -2, userInfo: [NSLocalizedDescriptionKey: "Invalid redirect URL configuration."])))
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if config.logsEnabled {
|
|
172
|
+
print("[OAuth2Provider] Opening authorization URL: \(authUrl.absoluteString)")
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let session = ASWebAuthenticationSession(url: authUrl, callbackURLScheme: callbackScheme) { [weak self] callbackURL, error in
|
|
176
|
+
guard let self = self else { return }
|
|
177
|
+
if let error = error {
|
|
178
|
+
if (error as NSError).code == ASWebAuthenticationSessionError.canceledLogin.rawValue {
|
|
179
|
+
completion(.failure(NSError(domain: "OAuth2Provider", code: -3, userInfo: [NSLocalizedDescriptionKey: "User cancelled login."])))
|
|
180
|
+
} else {
|
|
181
|
+
completion(.failure(error))
|
|
182
|
+
}
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
guard let callbackURL = callbackURL else {
|
|
187
|
+
completion(.failure(NSError(domain: "OAuth2Provider", code: -4, userInfo: [NSLocalizedDescriptionKey: "Invalid callback URL."])))
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
self.handleCallback(providerId: providerId, config: config, callbackURL: callbackURL, redirectUri: redirect, completion: completion)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if #available(iOS 13.0, *) {
|
|
195
|
+
session.presentationContextProvider = self
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
session.prefersEphemeralWebBrowserSession = true
|
|
199
|
+
currentSession = session
|
|
200
|
+
session.start()
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private func handleCallback(providerId: String, config: OAuth2ProviderConfig, callbackURL: URL, redirectUri: String, completion: @escaping (Result<OAuth2LoginResponse, Error>) -> Void) {
|
|
204
|
+
// Parse both query params and fragment
|
|
205
|
+
var params: [String: String] = [:]
|
|
206
|
+
|
|
207
|
+
if let queryItems = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems {
|
|
208
|
+
for item in queryItems {
|
|
209
|
+
params[item.name] = item.value
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Parse fragment for implicit flow
|
|
214
|
+
if let fragment = callbackURL.fragment {
|
|
215
|
+
let fragmentParams = fragment.components(separatedBy: "&")
|
|
216
|
+
for param in fragmentParams {
|
|
217
|
+
let parts = param.components(separatedBy: "=")
|
|
218
|
+
if parts.count == 2 {
|
|
219
|
+
params[parts[0]] = parts[1].removingPercentEncoding
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Validate state
|
|
225
|
+
if let returnedState = params["state"], returnedState != currentState {
|
|
226
|
+
completion(.failure(NSError(domain: "OAuth2Provider", code: -5, userInfo: [NSLocalizedDescriptionKey: "State mismatch detected during OAuth2 login."])))
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Check for error
|
|
231
|
+
if let errorCode = params["error"] {
|
|
232
|
+
let errorDescription = params["error_description"] ?? errorCode
|
|
233
|
+
completion(.failure(NSError(domain: "OAuth2Provider", code: -6, userInfo: [NSLocalizedDescriptionKey: errorDescription])))
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Handle code flow
|
|
238
|
+
if let code = params["code"] {
|
|
239
|
+
guard let codeVerifier = currentCodeVerifier else {
|
|
240
|
+
completion(.failure(NSError(domain: "OAuth2Provider", code: -7, userInfo: [NSLocalizedDescriptionKey: "Missing code verifier."])))
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
exchangeCode(providerId: providerId, config: config, code: code, redirectUri: redirectUri, codeVerifier: codeVerifier, completion: completion)
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Handle implicit flow
|
|
248
|
+
if let accessToken = params["access_token"] {
|
|
249
|
+
let tokenResponse = OAuth2TokenResponse(
|
|
250
|
+
token_type: params["token_type"] ?? "bearer",
|
|
251
|
+
expires_in: params["expires_in"].flatMap { Int($0) },
|
|
252
|
+
access_token: accessToken,
|
|
253
|
+
scope: params["scope"],
|
|
254
|
+
refresh_token: nil,
|
|
255
|
+
id_token: params["id_token"]
|
|
256
|
+
)
|
|
257
|
+
handleTokenSuccess(providerId: providerId, config: config, tokenResponse: tokenResponse, completion: completion)
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
completion(.failure(NSError(domain: "OAuth2Provider", code: -8, userInfo: [NSLocalizedDescriptionKey: "No authorization code or access token in callback."])))
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
func logout(providerId: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
|
265
|
+
UserDefaults.standard.removeObject(forKey: tokenStorageKey(for: providerId))
|
|
266
|
+
|
|
267
|
+
if let config = getProvider(providerId), let logoutUrl = config.logoutUrl, let url = URL(string: logoutUrl) {
|
|
268
|
+
DispatchQueue.main.async {
|
|
269
|
+
UIApplication.shared.open(url)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
completion(.success(()))
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
func isLoggedIn(providerId: String, completion: @escaping (Result<Bool, Error>) -> Void) {
|
|
277
|
+
if let tokens = loadTokens(for: providerId) {
|
|
278
|
+
completion(.success(tokens.expiresAt > Date()))
|
|
279
|
+
} else {
|
|
280
|
+
completion(.success(false))
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
func getAuthorizationCode(providerId: String, completion: @escaping (Result<OAuth2AccessToken, Error>) -> Void) {
|
|
285
|
+
guard let tokens = loadTokens(for: providerId) else {
|
|
286
|
+
completion(.failure(NSError(domain: "OAuth2Provider", code: -9, userInfo: [NSLocalizedDescriptionKey: "User not logged in."])))
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
completion(.success(OAuth2AccessToken(
|
|
290
|
+
token: tokens.accessToken,
|
|
291
|
+
tokenType: tokens.tokenType,
|
|
292
|
+
expires: ISO8601DateFormatter().string(from: tokens.expiresAt),
|
|
293
|
+
refreshToken: tokens.refreshToken
|
|
294
|
+
)))
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
func refresh(providerId: String, completion: @escaping (Result<OAuth2LoginResponse, Error>) -> Void) {
|
|
298
|
+
guard let config = getProvider(providerId) else {
|
|
299
|
+
completion(.failure(NSError(domain: "OAuth2Provider", code: -1, userInfo: [NSLocalizedDescriptionKey: "OAuth2 provider '\(providerId)' not configured."])))
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
guard let tokens = loadTokens(for: providerId), let refreshToken = tokens.refreshToken else {
|
|
304
|
+
completion(.failure(NSError(domain: "OAuth2Provider", code: -10, userInfo: [NSLocalizedDescriptionKey: "No refresh token available. Include offline_access scope."])))
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
refreshTokens(providerId: providerId, config: config, refreshToken: refreshToken, completion: completion)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private func exchangeCode(providerId: String, config: OAuth2ProviderConfig, code: String, redirectUri: String, codeVerifier: String, completion: @escaping (Result<OAuth2LoginResponse, Error>) -> Void) {
|
|
312
|
+
guard let accessTokenEndpoint = config.accessTokenEndpoint else {
|
|
313
|
+
completion(.failure(NSError(domain: "OAuth2Provider", code: -11, userInfo: [NSLocalizedDescriptionKey: "Missing accessTokenEndpoint for code exchange."])))
|
|
314
|
+
return
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
var body: [String: String] = [
|
|
318
|
+
"grant_type": "authorization_code",
|
|
319
|
+
"client_id": config.appId,
|
|
320
|
+
"code": code,
|
|
321
|
+
"redirect_uri": redirectUri
|
|
322
|
+
]
|
|
323
|
+
|
|
324
|
+
if config.pkceEnabled {
|
|
325
|
+
body["code_verifier"] = codeVerifier
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if config.logsEnabled {
|
|
329
|
+
print("[OAuth2Provider] Exchanging code at: \(accessTokenEndpoint)")
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
performTokenRequest(endpoint: accessTokenEndpoint, body: body) { [weak self] result in
|
|
333
|
+
switch result {
|
|
334
|
+
case .success(let tokenResponse):
|
|
335
|
+
self?.handleTokenSuccess(providerId: providerId, config: config, tokenResponse: tokenResponse, completion: completion)
|
|
336
|
+
case .failure(let error):
|
|
337
|
+
completion(.failure(error))
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private func refreshTokens(providerId: String, config: OAuth2ProviderConfig, refreshToken: String, completion: @escaping (Result<OAuth2LoginResponse, Error>) -> Void) {
|
|
343
|
+
guard let accessTokenEndpoint = config.accessTokenEndpoint else {
|
|
344
|
+
completion(.failure(NSError(domain: "OAuth2Provider", code: -12, userInfo: [NSLocalizedDescriptionKey: "Missing accessTokenEndpoint for token refresh."])))
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
let body: [String: String] = [
|
|
349
|
+
"grant_type": "refresh_token",
|
|
350
|
+
"refresh_token": refreshToken,
|
|
351
|
+
"client_id": config.appId
|
|
352
|
+
]
|
|
353
|
+
|
|
354
|
+
performTokenRequest(endpoint: accessTokenEndpoint, body: body) { [weak self] result in
|
|
355
|
+
switch result {
|
|
356
|
+
case .success(let tokenResponse):
|
|
357
|
+
self?.handleTokenSuccess(providerId: providerId, config: config, tokenResponse: tokenResponse, completion: completion)
|
|
358
|
+
case .failure(let error):
|
|
359
|
+
completion(.failure(error))
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private func performTokenRequest(endpoint: String, body: [String: String], completion: @escaping (Result<OAuth2TokenResponse, Error>) -> Void) {
|
|
365
|
+
guard let url = URL(string: endpoint) else {
|
|
366
|
+
completion(.failure(NSError(domain: "OAuth2Provider", code: -13, userInfo: [NSLocalizedDescriptionKey: "Invalid token endpoint."])))
|
|
367
|
+
return
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
var request = URLRequest(url: url)
|
|
371
|
+
request.httpMethod = "POST"
|
|
372
|
+
request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
|
373
|
+
request.httpBody = body.percentEncoded()
|
|
374
|
+
|
|
375
|
+
URLSession.shared.dataTask(with: request) { data, response, error in
|
|
376
|
+
if let error = error {
|
|
377
|
+
completion(.failure(error))
|
|
378
|
+
return
|
|
379
|
+
}
|
|
380
|
+
guard let httpResponse = response as? HTTPURLResponse else {
|
|
381
|
+
completion(.failure(NSError(domain: "OAuth2Provider", code: -14, userInfo: [NSLocalizedDescriptionKey: "Invalid response."])))
|
|
382
|
+
return
|
|
383
|
+
}
|
|
384
|
+
guard let data = data, httpResponse.statusCode == 200 else {
|
|
385
|
+
let message = String(data: data ?? Data(), encoding: .utf8) ?? "Unknown error"
|
|
386
|
+
completion(.failure(NSError(domain: "OAuth2Provider", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: message])))
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
do {
|
|
391
|
+
let decoded = try JSONDecoder().decode(OAuth2TokenResponse.self, from: data)
|
|
392
|
+
completion(.success(decoded))
|
|
393
|
+
} catch {
|
|
394
|
+
completion(.failure(error))
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
.resume()
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
private func handleTokenSuccess(providerId: String, config: OAuth2ProviderConfig, tokenResponse: OAuth2TokenResponse, completion: @escaping (Result<OAuth2LoginResponse, Error>) -> Void) {
|
|
401
|
+
let expiresAt = tokenResponse.expires_in.map { Date().addingTimeInterval(TimeInterval($0)) } ?? Date().addingTimeInterval(3600)
|
|
402
|
+
let scopeArray = tokenResponse.scope?.split(separator: " ").map { String($0) } ?? []
|
|
403
|
+
|
|
404
|
+
// Fetch resource data if configured
|
|
405
|
+
if let resourceUrl = config.resourceUrl {
|
|
406
|
+
fetchResource(config: config, accessToken: tokenResponse.access_token) { [weak self] resourceResult in
|
|
407
|
+
guard let self = self else { return }
|
|
408
|
+
|
|
409
|
+
let resourceData: [String: Any]?
|
|
410
|
+
switch resourceResult {
|
|
411
|
+
case .success(let data):
|
|
412
|
+
resourceData = data
|
|
413
|
+
case .failure(let error):
|
|
414
|
+
if config.logsEnabled {
|
|
415
|
+
print("[OAuth2Provider] Failed to fetch resource: \(error.localizedDescription)")
|
|
416
|
+
}
|
|
417
|
+
resourceData = nil
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
self.completeLogin(
|
|
421
|
+
providerId: providerId,
|
|
422
|
+
tokenResponse: tokenResponse,
|
|
423
|
+
expiresAt: expiresAt,
|
|
424
|
+
scopeArray: scopeArray,
|
|
425
|
+
resourceData: resourceData,
|
|
426
|
+
completion: completion
|
|
427
|
+
)
|
|
428
|
+
}
|
|
429
|
+
} else {
|
|
430
|
+
completeLogin(
|
|
431
|
+
providerId: providerId,
|
|
432
|
+
tokenResponse: tokenResponse,
|
|
433
|
+
expiresAt: expiresAt,
|
|
434
|
+
scopeArray: scopeArray,
|
|
435
|
+
resourceData: nil,
|
|
436
|
+
completion: completion
|
|
437
|
+
)
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private func completeLogin(
|
|
442
|
+
providerId: String,
|
|
443
|
+
tokenResponse: OAuth2TokenResponse,
|
|
444
|
+
expiresAt: Date,
|
|
445
|
+
scopeArray: [String],
|
|
446
|
+
resourceData: [String: Any]?,
|
|
447
|
+
completion: @escaping (Result<OAuth2LoginResponse, Error>) -> Void
|
|
448
|
+
) {
|
|
449
|
+
let stored = OAuth2StoredTokens(
|
|
450
|
+
accessToken: tokenResponse.access_token,
|
|
451
|
+
refreshToken: tokenResponse.refresh_token,
|
|
452
|
+
idToken: tokenResponse.id_token,
|
|
453
|
+
expiresAt: expiresAt,
|
|
454
|
+
scope: scopeArray,
|
|
455
|
+
tokenType: tokenResponse.token_type
|
|
456
|
+
)
|
|
457
|
+
persistTokens(tokens: stored, for: providerId)
|
|
458
|
+
|
|
459
|
+
let response = OAuth2LoginResponse(
|
|
460
|
+
providerId: providerId,
|
|
461
|
+
accessToken: OAuth2AccessToken(
|
|
462
|
+
token: tokenResponse.access_token,
|
|
463
|
+
tokenType: tokenResponse.token_type,
|
|
464
|
+
expires: ISO8601DateFormatter().string(from: expiresAt),
|
|
465
|
+
refreshToken: tokenResponse.refresh_token
|
|
466
|
+
),
|
|
467
|
+
idToken: tokenResponse.id_token,
|
|
468
|
+
refreshToken: tokenResponse.refresh_token,
|
|
469
|
+
resourceData: resourceData,
|
|
470
|
+
scope: scopeArray,
|
|
471
|
+
tokenType: tokenResponse.token_type,
|
|
472
|
+
expiresIn: tokenResponse.expires_in
|
|
473
|
+
)
|
|
474
|
+
completion(.success(response))
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private func fetchResource(config: OAuth2ProviderConfig, accessToken: String, completion: @escaping (Result<[String: Any], Error>) -> Void) {
|
|
478
|
+
guard let resourceUrl = config.resourceUrl, let url = URL(string: resourceUrl) else {
|
|
479
|
+
completion(.failure(NSError(domain: "OAuth2Provider", code: -15, userInfo: [NSLocalizedDescriptionKey: "Invalid resource URL."])))
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
var request = URLRequest(url: url)
|
|
484
|
+
request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
|
|
485
|
+
|
|
486
|
+
if let additionalHeaders = config.additionalResourceHeaders {
|
|
487
|
+
for (key, value) in additionalHeaders {
|
|
488
|
+
request.addValue(value, forHTTPHeaderField: key)
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
URLSession.shared.dataTask(with: request) { data, response, error in
|
|
493
|
+
if let error = error {
|
|
494
|
+
completion(.failure(error))
|
|
495
|
+
return
|
|
496
|
+
}
|
|
497
|
+
guard let httpResponse = response as? HTTPURLResponse else {
|
|
498
|
+
completion(.failure(NSError(domain: "OAuth2Provider", code: -16, userInfo: [NSLocalizedDescriptionKey: "Invalid response."])))
|
|
499
|
+
return
|
|
500
|
+
}
|
|
501
|
+
guard let data = data, httpResponse.statusCode == 200 else {
|
|
502
|
+
let message = String(data: data ?? Data(), encoding: .utf8) ?? "Unknown error"
|
|
503
|
+
completion(.failure(NSError(domain: "OAuth2Provider", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: message])))
|
|
504
|
+
return
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
do {
|
|
508
|
+
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
|
509
|
+
completion(.success(json))
|
|
510
|
+
} else {
|
|
511
|
+
completion(.failure(NSError(domain: "OAuth2Provider", code: -17, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON response."])))
|
|
512
|
+
}
|
|
513
|
+
} catch {
|
|
514
|
+
completion(.failure(error))
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
.resume()
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
private func persistTokens(tokens: OAuth2StoredTokens, for providerId: String) {
|
|
521
|
+
do {
|
|
522
|
+
let data = try JSONEncoder().encode(tokens)
|
|
523
|
+
UserDefaults.standard.set(data, forKey: tokenStorageKey(for: providerId))
|
|
524
|
+
} catch {
|
|
525
|
+
print("OAuth2Provider persistTokens error: \(error.localizedDescription)")
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
private func loadTokens(for providerId: String) -> OAuth2StoredTokens? {
|
|
530
|
+
guard let data = UserDefaults.standard.data(forKey: tokenStorageKey(for: providerId)) else {
|
|
531
|
+
return nil
|
|
532
|
+
}
|
|
533
|
+
do {
|
|
534
|
+
return try JSONDecoder().decode(OAuth2StoredTokens.self, from: data)
|
|
535
|
+
} catch {
|
|
536
|
+
print("OAuth2Provider loadTokens error: \(error.localizedDescription)")
|
|
537
|
+
return nil
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
private func generateCodeVerifier() -> String {
|
|
542
|
+
var data = Data(count: 64)
|
|
543
|
+
_ = data.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 64, $0.baseAddress!) }
|
|
544
|
+
return data.base64EncodedString()
|
|
545
|
+
.replacingOccurrences(of: "=", with: "")
|
|
546
|
+
.replacingOccurrences(of: "+", with: "-")
|
|
547
|
+
.replacingOccurrences(of: "/", with: "_")
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
private func generateCodeChallenge(from verifier: String) -> String {
|
|
551
|
+
let data = verifier.data(using: .utf8) ?? Data()
|
|
552
|
+
let digest = SHA256.hash(data: data)
|
|
553
|
+
return Data(digest).base64EncodedString()
|
|
554
|
+
.replacingOccurrences(of: "+", with: "-")
|
|
555
|
+
.replacingOccurrences(of: "/", with: "_")
|
|
556
|
+
.replacingOccurrences(of: "=", with: "")
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
extension OAuth2Provider: ASWebAuthenticationPresentationContextProviding {
|
|
561
|
+
@available(iOS 13.0, *)
|
|
562
|
+
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
|
563
|
+
return UIApplication.shared.windows.first { $0.isKeyWindow } ?? ASPresentationAnchor()
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
private extension Dictionary where Key == String, Value == String {
|
|
568
|
+
func percentEncoded() -> Data? {
|
|
569
|
+
map { key, value in
|
|
570
|
+
"\(key.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? key)=\(value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value)"
|
|
571
|
+
}
|
|
572
|
+
.joined(separator: "&")
|
|
573
|
+
.data(using: .utf8)
|
|
574
|
+
}
|
|
575
|
+
}
|