@capgo/capacitor-social-login 8.2.25 → 8.3.1
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/Package.swift +1 -1
- package/README.md +191 -22
- package/android/src/main/java/ee/forgr/capacitor/social/login/OAuth2Provider.java +464 -82
- package/android/src/main/java/ee/forgr/capacitor/social/login/SocialLoginPlugin.java +93 -1
- package/dist/docs.json +317 -5
- package/dist/esm/definitions.d.ts +187 -5
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/oauth2-provider.d.ts +18 -1
- package/dist/esm/oauth2-provider.js +227 -40
- package/dist/esm/oauth2-provider.js.map +1 -1
- package/dist/esm/web.d.ts +37 -2
- package/dist/esm/web.js +77 -17
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +304 -57
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +304 -57
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/SocialLoginPlugin/OAuth2Provider.swift +281 -103
- package/ios/Sources/SocialLoginPlugin/SocialLoginPlugin.swift +129 -1
- package/package.json +7 -7
|
@@ -41,16 +41,23 @@ struct OAuth2AccessToken {
|
|
|
41
41
|
|
|
42
42
|
struct OAuth2ProviderConfig {
|
|
43
43
|
let appId: String
|
|
44
|
-
let
|
|
45
|
-
|
|
44
|
+
let issuerUrl: String?
|
|
45
|
+
var authorizationBaseUrl: String?
|
|
46
|
+
var accessTokenEndpoint: String?
|
|
46
47
|
let redirectUrl: String
|
|
47
48
|
let resourceUrl: String?
|
|
48
49
|
let responseType: String
|
|
49
50
|
let pkceEnabled: Bool
|
|
50
51
|
let scope: String
|
|
51
52
|
let additionalParameters: [String: String]?
|
|
53
|
+
let loginHint: String?
|
|
54
|
+
let prompt: String?
|
|
55
|
+
let additionalTokenParameters: [String: String]?
|
|
52
56
|
let additionalResourceHeaders: [String: String]?
|
|
53
|
-
|
|
57
|
+
var logoutUrl: String?
|
|
58
|
+
let postLogoutRedirectUrl: String?
|
|
59
|
+
let additionalLogoutParameters: [String: String]?
|
|
60
|
+
let iosPrefersEphemeralWebBrowserSession: Bool
|
|
54
61
|
let logsEnabled: Bool
|
|
55
62
|
}
|
|
56
63
|
|
|
@@ -62,42 +69,71 @@ class OAuth2Provider: NSObject {
|
|
|
62
69
|
private var currentProviderId: String?
|
|
63
70
|
private let tokenStorageKeyPrefix = "CapgoOAuth2ProviderTokens_"
|
|
64
71
|
|
|
72
|
+
private func normalizeScope(_ value: Any?) -> String {
|
|
73
|
+
if let s = value as? String { return s }
|
|
74
|
+
if let arr = value as? [String] { return arr.joined(separator: " ") }
|
|
75
|
+
if let arr = value as? [Any] {
|
|
76
|
+
return arr.compactMap { $0 as? String }.joined(separator: " ")
|
|
77
|
+
}
|
|
78
|
+
return ""
|
|
79
|
+
}
|
|
80
|
+
|
|
65
81
|
func initializeProviders(configs: [String: [String: Any]]) -> [String] {
|
|
66
82
|
var errors: [String] = []
|
|
67
83
|
|
|
68
84
|
for (providerId, config) in configs {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
guard let authorizationBaseUrl = config["authorizationBaseUrl"] as? String, !authorizationBaseUrl.isEmpty else {
|
|
74
|
-
errors.append("oauth2.\(providerId).authorizationBaseUrl is required")
|
|
85
|
+
let appId = (config["appId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
86
|
+
let clientId = (config["clientId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
87
|
+
guard let resolvedAppId = (appId?.isEmpty == false ? appId : nil) ?? (clientId?.isEmpty == false ? clientId : nil) else {
|
|
88
|
+
errors.append("oauth2.\(providerId).appId (or clientId) is required")
|
|
75
89
|
continue
|
|
76
90
|
}
|
|
91
|
+
let issuerUrl = (config["issuerUrl"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
92
|
+
let authorizationBaseUrl =
|
|
93
|
+
(config["authorizationBaseUrl"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ??
|
|
94
|
+
(config["authorizationEndpoint"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
77
95
|
guard let redirectUrl = config["redirectUrl"] as? String, !redirectUrl.isEmpty else {
|
|
78
96
|
errors.append("oauth2.\(providerId).redirectUrl is required")
|
|
79
97
|
continue
|
|
80
98
|
}
|
|
99
|
+
if (authorizationBaseUrl == nil || authorizationBaseUrl?.isEmpty == true) && (issuerUrl == nil || issuerUrl?.isEmpty == true) {
|
|
100
|
+
errors.append("oauth2.\(providerId).authorizationBaseUrl (or authorizationEndpoint) or issuerUrl is required")
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
81
103
|
|
|
82
104
|
let providerConfig = OAuth2ProviderConfig(
|
|
83
|
-
appId:
|
|
84
|
-
|
|
85
|
-
|
|
105
|
+
appId: resolvedAppId,
|
|
106
|
+
issuerUrl: issuerUrl,
|
|
107
|
+
authorizationBaseUrl: (authorizationBaseUrl?.isEmpty == false ? authorizationBaseUrl : nil),
|
|
108
|
+
accessTokenEndpoint:
|
|
109
|
+
(config["accessTokenEndpoint"] as? String) ??
|
|
110
|
+
(config["tokenEndpoint"] as? String),
|
|
86
111
|
redirectUrl: redirectUrl,
|
|
87
112
|
resourceUrl: config["resourceUrl"] as? String,
|
|
88
113
|
responseType: config["responseType"] as? String ?? "code",
|
|
89
114
|
pkceEnabled: config["pkceEnabled"] as? Bool ?? true,
|
|
90
|
-
scope: config["scope"]
|
|
115
|
+
scope: normalizeScope(config["scope"] ?? config["scopes"]),
|
|
91
116
|
additionalParameters: config["additionalParameters"] as? [String: String],
|
|
117
|
+
loginHint: config["loginHint"] as? String,
|
|
118
|
+
prompt: config["prompt"] as? String,
|
|
119
|
+
additionalTokenParameters: config["additionalTokenParameters"] as? [String: String],
|
|
92
120
|
additionalResourceHeaders: config["additionalResourceHeaders"] as? [String: String],
|
|
93
|
-
logoutUrl: config["logoutUrl"] as? String,
|
|
121
|
+
logoutUrl: (config["logoutUrl"] as? String) ?? (config["endSessionEndpoint"] as? String),
|
|
122
|
+
postLogoutRedirectUrl: config["postLogoutRedirectUrl"] as? String,
|
|
123
|
+
additionalLogoutParameters: config["additionalLogoutParameters"] as? [String: String],
|
|
124
|
+
iosPrefersEphemeralWebBrowserSession:
|
|
125
|
+
(config["iosPrefersEphemeralWebBrowserSession"] as? Bool) ??
|
|
126
|
+
(config["iosPrefersEphemeralSession"] as? Bool) ??
|
|
127
|
+
true,
|
|
94
128
|
logsEnabled: config["logsEnabled"] as? Bool ?? false
|
|
95
129
|
)
|
|
96
130
|
|
|
97
131
|
providers[providerId] = providerConfig
|
|
98
132
|
|
|
99
133
|
if providerConfig.logsEnabled {
|
|
100
|
-
print(
|
|
134
|
+
print(
|
|
135
|
+
"[OAuth2Provider] Initialized provider '\(providerId)' with appId: \(resolvedAppId), issuerUrl: \(issuerUrl ?? "nil"), authorizationBaseUrl: \(authorizationBaseUrl ?? "nil")"
|
|
136
|
+
)
|
|
101
137
|
}
|
|
102
138
|
}
|
|
103
139
|
|
|
@@ -108,93 +144,169 @@ class OAuth2Provider: NSObject {
|
|
|
108
144
|
return providers[providerId]
|
|
109
145
|
}
|
|
110
146
|
|
|
111
|
-
private func
|
|
112
|
-
|
|
147
|
+
private func discoveryUrl(for issuerUrl: String) -> URL? {
|
|
148
|
+
let trimmed = issuerUrl.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "/+$", with: "", options: .regularExpression)
|
|
149
|
+
return URL(string: "\(trimmed)/.well-known/openid-configuration")
|
|
113
150
|
}
|
|
114
151
|
|
|
115
|
-
func
|
|
116
|
-
guard
|
|
152
|
+
private func ensureDiscovered(providerId: String, completion: @escaping (Result<OAuth2ProviderConfig, Error>) -> Void) {
|
|
153
|
+
guard var config = getProvider(providerId) else {
|
|
117
154
|
completion(.failure(NSError(domain: "OAuth2Provider", code: -1, userInfo: [NSLocalizedDescriptionKey: "OAuth2 provider '\(providerId)' not configured. Call initialize()."])))
|
|
118
155
|
return
|
|
119
156
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
}
|
|
157
|
+
guard let issuerUrl = config.issuerUrl, !issuerUrl.isEmpty else {
|
|
158
|
+
completion(.success(config))
|
|
159
|
+
return
|
|
155
160
|
}
|
|
156
161
|
|
|
157
|
-
//
|
|
158
|
-
if
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
+
// Already resolved enough for auth.
|
|
163
|
+
if config.authorizationBaseUrl != nil && (config.responseType != "code" || config.accessTokenEndpoint != nil) {
|
|
164
|
+
completion(.success(config))
|
|
165
|
+
return
|
|
162
166
|
}
|
|
163
167
|
|
|
164
|
-
|
|
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
|
+
guard let url = discoveryUrl(for: issuerUrl) else {
|
|
169
|
+
completion(.failure(NSError(domain: "OAuth2Provider", code: -20, userInfo: [NSLocalizedDescriptionKey: "Invalid issuerUrl for provider '\(providerId)'."])))
|
|
168
170
|
return
|
|
169
171
|
}
|
|
170
172
|
|
|
171
173
|
if config.logsEnabled {
|
|
172
|
-
print("[OAuth2Provider]
|
|
174
|
+
print("[OAuth2Provider] Discovering OIDC configuration at: \(url.absoluteString)")
|
|
173
175
|
}
|
|
174
176
|
|
|
175
|
-
|
|
176
|
-
guard let self = self else { return }
|
|
177
|
+
URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
|
|
177
178
|
if let error = error {
|
|
178
|
-
|
|
179
|
-
completion(.failure(NSError(domain: "OAuth2Provider", code: -3, userInfo: [NSLocalizedDescriptionKey: "User cancelled login."])))
|
|
180
|
-
} else {
|
|
181
|
-
completion(.failure(error))
|
|
182
|
-
}
|
|
179
|
+
completion(.failure(error))
|
|
183
180
|
return
|
|
184
181
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
completion(.failure(NSError(domain: "OAuth2Provider", code: -4, userInfo: [NSLocalizedDescriptionKey: "Invalid callback URL."])))
|
|
182
|
+
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode), let data = data else {
|
|
183
|
+
completion(.failure(NSError(domain: "OAuth2Provider", code: -21, userInfo: [NSLocalizedDescriptionKey: "OIDC discovery failed for provider '\(providerId)'."])))
|
|
188
184
|
return
|
|
189
185
|
}
|
|
186
|
+
do {
|
|
187
|
+
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
188
|
+
let auth = json?["authorization_endpoint"] as? String
|
|
189
|
+
let token = json?["token_endpoint"] as? String
|
|
190
|
+
let endSession = json?["end_session_endpoint"] as? String
|
|
190
191
|
|
|
191
|
-
|
|
192
|
-
|
|
192
|
+
if config.authorizationBaseUrl == nil, let auth = auth, !auth.isEmpty {
|
|
193
|
+
config.authorizationBaseUrl = auth
|
|
194
|
+
}
|
|
195
|
+
if config.accessTokenEndpoint == nil, let token = token, !token.isEmpty {
|
|
196
|
+
config.accessTokenEndpoint = token
|
|
197
|
+
}
|
|
198
|
+
if config.logoutUrl == nil, let endSession = endSession, !endSession.isEmpty {
|
|
199
|
+
config.logoutUrl = endSession
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Persist updated config back into map
|
|
203
|
+
self?.providers[providerId] = config
|
|
204
|
+
completion(.success(config))
|
|
205
|
+
} catch {
|
|
206
|
+
completion(.failure(error))
|
|
207
|
+
}
|
|
208
|
+
}.resume()
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private func tokenStorageKey(for providerId: String) -> String {
|
|
212
|
+
return "\(tokenStorageKeyPrefix)\(providerId)"
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
func login(providerId: String, payload: [String: Any], completion: @escaping (Result<OAuth2LoginResponse, Error>) -> Void) {
|
|
216
|
+
ensureDiscovered(providerId: providerId) { [weak self] res in
|
|
217
|
+
guard let self = self else { return }
|
|
218
|
+
switch res {
|
|
219
|
+
case .failure(let err):
|
|
220
|
+
completion(.failure(err))
|
|
221
|
+
case .success(let config):
|
|
222
|
+
guard let authorizationBaseUrl = config.authorizationBaseUrl, !authorizationBaseUrl.isEmpty else {
|
|
223
|
+
completion(.failure(NSError(domain: "OAuth2Provider", code: -2, userInfo: [NSLocalizedDescriptionKey: "Missing authorization endpoint (discovery may have failed)."])))
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let loginScope = self.normalizeScope(payload["scope"] ?? payload["scopes"] ?? config.scope)
|
|
228
|
+
let state = payload["state"] as? String ?? UUID().uuidString
|
|
229
|
+
let codeVerifier = payload["codeVerifier"] as? String ?? self.generateCodeVerifier()
|
|
230
|
+
let redirect = payload["redirectUrl"] as? String ?? config.redirectUrl
|
|
231
|
+
let additionalLoginParams = payload["additionalParameters"] as? [String: String]
|
|
232
|
+
|
|
233
|
+
self.currentState = state
|
|
234
|
+
self.currentCodeVerifier = codeVerifier
|
|
235
|
+
self.currentProviderId = providerId
|
|
236
|
+
|
|
237
|
+
var components = URLComponents(string: authorizationBaseUrl)
|
|
238
|
+
var queryItems: [URLQueryItem] = [
|
|
239
|
+
URLQueryItem(name: "response_type", value: config.responseType),
|
|
240
|
+
URLQueryItem(name: "client_id", value: config.appId),
|
|
241
|
+
URLQueryItem(name: "redirect_uri", value: redirect),
|
|
242
|
+
URLQueryItem(name: "state", value: state)
|
|
243
|
+
]
|
|
244
|
+
|
|
245
|
+
if !loginScope.isEmpty {
|
|
246
|
+
queryItems.append(URLQueryItem(name: "scope", value: loginScope))
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Add PKCE for code flow
|
|
250
|
+
if config.responseType == "code" && config.pkceEnabled {
|
|
251
|
+
let codeChallenge = self.generateCodeChallenge(from: codeVerifier)
|
|
252
|
+
queryItems.append(URLQueryItem(name: "code_challenge", value: codeChallenge))
|
|
253
|
+
queryItems.append(URLQueryItem(name: "code_challenge_method", value: "S256"))
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Merge additional parameters (config + per-login)
|
|
257
|
+
var merged: [String: String] = [:]
|
|
258
|
+
if let base = config.additionalParameters {
|
|
259
|
+
for (k, v) in base { merged[k] = v }
|
|
260
|
+
}
|
|
261
|
+
if let login = additionalLoginParams {
|
|
262
|
+
for (k, v) in login { merged[k] = v }
|
|
263
|
+
}
|
|
264
|
+
if let loginHint = payload["loginHint"] as? String ?? config.loginHint, merged["login_hint"] == nil {
|
|
265
|
+
merged["login_hint"] = loginHint
|
|
266
|
+
}
|
|
267
|
+
if let prompt = payload["prompt"] as? String ?? config.prompt, merged["prompt"] == nil {
|
|
268
|
+
merged["prompt"] = prompt
|
|
269
|
+
}
|
|
270
|
+
for (k, v) in merged {
|
|
271
|
+
queryItems.append(URLQueryItem(name: k, value: v))
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
components?.queryItems = queryItems
|
|
275
|
+
|
|
276
|
+
guard let authUrl = components?.url, let callbackScheme = URL(string: redirect)?.scheme else {
|
|
277
|
+
completion(.failure(NSError(domain: "OAuth2Provider", code: -2, userInfo: [NSLocalizedDescriptionKey: "Invalid redirect URL configuration."])))
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if config.logsEnabled {
|
|
282
|
+
print("[OAuth2Provider] Opening authorization URL: \(authUrl.absoluteString)")
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let session = ASWebAuthenticationSession(url: authUrl, callbackURLScheme: callbackScheme) { [weak self] callbackURL, error in
|
|
286
|
+
guard let self = self else { return }
|
|
287
|
+
if let error = error {
|
|
288
|
+
if (error as NSError).code == ASWebAuthenticationSessionError.canceledLogin.rawValue {
|
|
289
|
+
completion(.failure(NSError(domain: "OAuth2Provider", code: -3, userInfo: [NSLocalizedDescriptionKey: "User cancelled login."])))
|
|
290
|
+
} else {
|
|
291
|
+
completion(.failure(error))
|
|
292
|
+
}
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
guard let callbackURL = callbackURL else {
|
|
297
|
+
completion(.failure(NSError(domain: "OAuth2Provider", code: -4, userInfo: [NSLocalizedDescriptionKey: "Invalid callback URL."])))
|
|
298
|
+
return
|
|
299
|
+
}
|
|
193
300
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
301
|
+
self.handleCallback(providerId: providerId, config: config, callbackURL: callbackURL, redirectUri: redirect, completion: completion)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
session.presentationContextProvider = self
|
|
305
|
+
session.prefersEphemeralWebBrowserSession = config.iosPrefersEphemeralWebBrowserSession
|
|
306
|
+
self.currentSession = session
|
|
307
|
+
session.start()
|
|
308
|
+
}
|
|
309
|
+
}
|
|
198
310
|
}
|
|
199
311
|
|
|
200
312
|
private func handleCallback(providerId: String, config: OAuth2ProviderConfig, callbackURL: URL, redirectUri: String, completion: @escaping (Result<OAuth2LoginResponse, Error>) -> Void) {
|
|
@@ -251,7 +363,7 @@ class OAuth2Provider: NSObject {
|
|
|
251
363
|
refresh_token: nil,
|
|
252
364
|
id_token: params["id_token"]
|
|
253
365
|
)
|
|
254
|
-
handleTokenSuccess(providerId: providerId, config: config, tokenResponse: tokenResponse, completion: completion)
|
|
366
|
+
handleTokenSuccess(providerId: providerId, config: config, tokenResponse: tokenResponse, fallbackRefreshToken: nil, completion: completion)
|
|
255
367
|
return
|
|
256
368
|
}
|
|
257
369
|
|
|
@@ -260,14 +372,38 @@ class OAuth2Provider: NSObject {
|
|
|
260
372
|
|
|
261
373
|
func logout(providerId: String, completion: @escaping (Result<Void, Error>) -> Void) {
|
|
262
374
|
UserDefaults.standard.removeObject(forKey: tokenStorageKey(for: providerId))
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
375
|
+
ensureDiscovered(providerId: providerId) { [weak self] res in
|
|
376
|
+
guard let self = self else {
|
|
377
|
+
completion(.success(()))
|
|
378
|
+
return
|
|
267
379
|
}
|
|
380
|
+
if case .success(let config) = res, let logoutUrl = config.logoutUrl, let base = URL(string: logoutUrl) {
|
|
381
|
+
var url = base
|
|
382
|
+
if var components = URLComponents(url: base, resolvingAgainstBaseURL: false) {
|
|
383
|
+
var items = components.queryItems ?? []
|
|
384
|
+
if let idToken = self.loadTokens(for: providerId)?.idToken, !idToken.isEmpty {
|
|
385
|
+
items.append(URLQueryItem(name: "id_token_hint", value: idToken))
|
|
386
|
+
}
|
|
387
|
+
if let post = config.postLogoutRedirectUrl, !post.isEmpty {
|
|
388
|
+
items.append(URLQueryItem(name: "post_logout_redirect_uri", value: post))
|
|
389
|
+
}
|
|
390
|
+
if let extra = config.additionalLogoutParameters {
|
|
391
|
+
for (k, v) in extra {
|
|
392
|
+
items.append(URLQueryItem(name: k, value: v))
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
components.queryItems = items
|
|
396
|
+
if let composed = components.url {
|
|
397
|
+
url = composed
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
DispatchQueue.main.async {
|
|
401
|
+
UIApplication.shared.open(url)
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
completion(.success(()))
|
|
268
405
|
}
|
|
269
406
|
|
|
270
|
-
completion(.success(()))
|
|
271
407
|
}
|
|
272
408
|
|
|
273
409
|
func isLoggedIn(providerId: String, completion: @escaping (Result<Bool, Error>) -> Void) {
|
|
@@ -292,17 +428,25 @@ class OAuth2Provider: NSObject {
|
|
|
292
428
|
}
|
|
293
429
|
|
|
294
430
|
func refresh(providerId: String, completion: @escaping (Result<OAuth2LoginResponse, Error>) -> Void) {
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
return
|
|
298
|
-
}
|
|
431
|
+
refreshToken(providerId: providerId, refreshToken: nil, additionalParameters: nil, completion: completion)
|
|
432
|
+
}
|
|
299
433
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
return
|
|
434
|
+
func refreshToken(providerId: String, refreshToken: String?, additionalParameters: [String: String]?, completion: @escaping (Result<OAuth2LoginResponse, Error>) -> Void) {
|
|
435
|
+
ensureDiscovered(providerId: providerId) { [weak self] res in
|
|
436
|
+
guard let self = self else { return }
|
|
437
|
+
switch res {
|
|
438
|
+
case .failure(let err):
|
|
439
|
+
completion(.failure(err))
|
|
440
|
+
case .success(let config):
|
|
441
|
+
let stored = self.loadTokens(for: providerId)
|
|
442
|
+
let effectiveRefreshToken = refreshToken ?? stored?.refreshToken
|
|
443
|
+
guard let rt = effectiveRefreshToken, !rt.isEmpty else {
|
|
444
|
+
completion(.failure(NSError(domain: "OAuth2Provider", code: -10, userInfo: [NSLocalizedDescriptionKey: "No refresh token available. Include offline_access scope."])))
|
|
445
|
+
return
|
|
446
|
+
}
|
|
447
|
+
self.refreshTokens(providerId: providerId, config: config, refreshToken: rt, additionalParameters: additionalParameters, completion: completion)
|
|
448
|
+
}
|
|
303
449
|
}
|
|
304
|
-
|
|
305
|
-
refreshTokens(providerId: providerId, config: config, refreshToken: refreshToken, completion: completion)
|
|
306
450
|
}
|
|
307
451
|
|
|
308
452
|
private func exchangeCode(providerId: String, config: OAuth2ProviderConfig, code: String, redirectUri: String, codeVerifier: String, completion: @escaping (Result<OAuth2LoginResponse, Error>) -> Void) {
|
|
@@ -322,6 +466,10 @@ class OAuth2Provider: NSObject {
|
|
|
322
466
|
body["code_verifier"] = codeVerifier
|
|
323
467
|
}
|
|
324
468
|
|
|
469
|
+
if let extra = config.additionalTokenParameters {
|
|
470
|
+
for (k, v) in extra { body[k] = v }
|
|
471
|
+
}
|
|
472
|
+
|
|
325
473
|
if config.logsEnabled {
|
|
326
474
|
print("[OAuth2Provider] Exchanging code at: \(accessTokenEndpoint)")
|
|
327
475
|
}
|
|
@@ -329,29 +477,36 @@ class OAuth2Provider: NSObject {
|
|
|
329
477
|
performTokenRequest(endpoint: accessTokenEndpoint, body: body) { [weak self] result in
|
|
330
478
|
switch result {
|
|
331
479
|
case .success(let tokenResponse):
|
|
332
|
-
self?.handleTokenSuccess(providerId: providerId, config: config, tokenResponse: tokenResponse, completion: completion)
|
|
480
|
+
self?.handleTokenSuccess(providerId: providerId, config: config, tokenResponse: tokenResponse, fallbackRefreshToken: nil, completion: completion)
|
|
333
481
|
case .failure(let error):
|
|
334
482
|
completion(.failure(error))
|
|
335
483
|
}
|
|
336
484
|
}
|
|
337
485
|
}
|
|
338
486
|
|
|
339
|
-
private func refreshTokens(providerId: String, config: OAuth2ProviderConfig, refreshToken: String, completion: @escaping (Result<OAuth2LoginResponse, Error>) -> Void) {
|
|
487
|
+
private func refreshTokens(providerId: String, config: OAuth2ProviderConfig, refreshToken: String, additionalParameters: [String: String]? = nil, completion: @escaping (Result<OAuth2LoginResponse, Error>) -> Void) {
|
|
340
488
|
guard let accessTokenEndpoint = config.accessTokenEndpoint else {
|
|
341
489
|
completion(.failure(NSError(domain: "OAuth2Provider", code: -12, userInfo: [NSLocalizedDescriptionKey: "Missing accessTokenEndpoint for token refresh."])))
|
|
342
490
|
return
|
|
343
491
|
}
|
|
344
492
|
|
|
345
|
-
|
|
493
|
+
var body: [String: String] = [
|
|
346
494
|
"grant_type": "refresh_token",
|
|
347
495
|
"refresh_token": refreshToken,
|
|
348
496
|
"client_id": config.appId
|
|
349
497
|
]
|
|
350
498
|
|
|
499
|
+
if let extra = config.additionalTokenParameters {
|
|
500
|
+
for (k, v) in extra { body[k] = v }
|
|
501
|
+
}
|
|
502
|
+
if let extra = additionalParameters {
|
|
503
|
+
for (k, v) in extra { body[k] = v }
|
|
504
|
+
}
|
|
505
|
+
|
|
351
506
|
performTokenRequest(endpoint: accessTokenEndpoint, body: body) { [weak self] result in
|
|
352
507
|
switch result {
|
|
353
508
|
case .success(let tokenResponse):
|
|
354
|
-
self?.handleTokenSuccess(providerId: providerId, config: config, tokenResponse: tokenResponse, completion: completion)
|
|
509
|
+
self?.handleTokenSuccess(providerId: providerId, config: config, tokenResponse: tokenResponse, fallbackRefreshToken: refreshToken, completion: completion)
|
|
355
510
|
case .failure(let error):
|
|
356
511
|
completion(.failure(error))
|
|
357
512
|
}
|
|
@@ -394,9 +549,10 @@ class OAuth2Provider: NSObject {
|
|
|
394
549
|
.resume()
|
|
395
550
|
}
|
|
396
551
|
|
|
397
|
-
private func handleTokenSuccess(providerId: String, config: OAuth2ProviderConfig, tokenResponse: OAuth2TokenResponse, completion: @escaping (Result<OAuth2LoginResponse, Error>) -> Void) {
|
|
552
|
+
private func handleTokenSuccess(providerId: String, config: OAuth2ProviderConfig, tokenResponse: OAuth2TokenResponse, fallbackRefreshToken: String?, completion: @escaping (Result<OAuth2LoginResponse, Error>) -> Void) {
|
|
398
553
|
let expiresAt = tokenResponse.expires_in.map { Date().addingTimeInterval(TimeInterval($0)) } ?? Date().addingTimeInterval(3600)
|
|
399
554
|
let scopeArray = tokenResponse.scope?.split(separator: " ").map { String($0) } ?? []
|
|
555
|
+
let effectiveRefreshToken = tokenResponse.refresh_token ?? fallbackRefreshToken
|
|
400
556
|
|
|
401
557
|
// Fetch resource data if configured
|
|
402
558
|
if let resourceUrl = config.resourceUrl {
|
|
@@ -417,6 +573,7 @@ class OAuth2Provider: NSObject {
|
|
|
417
573
|
self.completeLogin(
|
|
418
574
|
providerId: providerId,
|
|
419
575
|
tokenResponse: tokenResponse,
|
|
576
|
+
effectiveRefreshToken: effectiveRefreshToken,
|
|
420
577
|
expiresAt: expiresAt,
|
|
421
578
|
scopeArray: scopeArray,
|
|
422
579
|
resourceData: resourceData,
|
|
@@ -427,6 +584,7 @@ class OAuth2Provider: NSObject {
|
|
|
427
584
|
completeLogin(
|
|
428
585
|
providerId: providerId,
|
|
429
586
|
tokenResponse: tokenResponse,
|
|
587
|
+
effectiveRefreshToken: effectiveRefreshToken,
|
|
430
588
|
expiresAt: expiresAt,
|
|
431
589
|
scopeArray: scopeArray,
|
|
432
590
|
resourceData: nil,
|
|
@@ -438,6 +596,7 @@ class OAuth2Provider: NSObject {
|
|
|
438
596
|
private func completeLogin(
|
|
439
597
|
providerId: String,
|
|
440
598
|
tokenResponse: OAuth2TokenResponse,
|
|
599
|
+
effectiveRefreshToken: String?,
|
|
441
600
|
expiresAt: Date,
|
|
442
601
|
scopeArray: [String],
|
|
443
602
|
resourceData: [String: Any]?,
|
|
@@ -445,7 +604,7 @@ class OAuth2Provider: NSObject {
|
|
|
445
604
|
) {
|
|
446
605
|
let stored = OAuth2StoredTokens(
|
|
447
606
|
accessToken: tokenResponse.access_token,
|
|
448
|
-
refreshToken:
|
|
607
|
+
refreshToken: effectiveRefreshToken,
|
|
449
608
|
idToken: tokenResponse.id_token,
|
|
450
609
|
expiresAt: expiresAt,
|
|
451
610
|
scope: scopeArray,
|
|
@@ -459,10 +618,10 @@ class OAuth2Provider: NSObject {
|
|
|
459
618
|
token: tokenResponse.access_token,
|
|
460
619
|
tokenType: tokenResponse.token_type,
|
|
461
620
|
expires: ISO8601DateFormatter().string(from: expiresAt),
|
|
462
|
-
refreshToken:
|
|
621
|
+
refreshToken: effectiveRefreshToken
|
|
463
622
|
),
|
|
464
623
|
idToken: tokenResponse.id_token,
|
|
465
|
-
refreshToken:
|
|
624
|
+
refreshToken: effectiveRefreshToken,
|
|
466
625
|
resourceData: resourceData,
|
|
467
626
|
scope: scopeArray,
|
|
468
627
|
tokenType: tokenResponse.token_type,
|
|
@@ -535,6 +694,25 @@ class OAuth2Provider: NSObject {
|
|
|
535
694
|
}
|
|
536
695
|
}
|
|
537
696
|
|
|
697
|
+
func getAccessTokenExpirationDate(providerId: String) -> Date? {
|
|
698
|
+
return loadTokens(for: providerId)?.expiresAt
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
func isAccessTokenAvailable(providerId: String) -> Bool {
|
|
702
|
+
guard let token = loadTokens(for: providerId)?.accessToken else { return false }
|
|
703
|
+
return !token.isEmpty
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
func isAccessTokenExpired(providerId: String) -> Bool {
|
|
707
|
+
guard let expiresAt = loadTokens(for: providerId)?.expiresAt else { return true }
|
|
708
|
+
return expiresAt <= Date()
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
func isRefreshTokenAvailable(providerId: String) -> Bool {
|
|
712
|
+
guard let rt = loadTokens(for: providerId)?.refreshToken else { return false }
|
|
713
|
+
return !rt.isEmpty
|
|
714
|
+
}
|
|
715
|
+
|
|
538
716
|
private func generateCodeVerifier() -> String {
|
|
539
717
|
var data = Data(count: 64)
|
|
540
718
|
_ = data.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 64, $0.baseAddress!) }
|