@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.
@@ -41,16 +41,23 @@ struct OAuth2AccessToken {
41
41
 
42
42
  struct OAuth2ProviderConfig {
43
43
  let appId: String
44
- let authorizationBaseUrl: String
45
- let accessTokenEndpoint: String?
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
- let logoutUrl: String?
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
- 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")
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: appId,
84
- authorizationBaseUrl: authorizationBaseUrl,
85
- accessTokenEndpoint: config["accessTokenEndpoint"] as? String,
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"] as? String ?? "",
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("[OAuth2Provider] Initialized provider '\(providerId)' with appId: \(appId), authorizationBaseUrl: \(authorizationBaseUrl)")
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 tokenStorageKey(for providerId: String) -> String {
112
- return "\(tokenStorageKeyPrefix)\(providerId)"
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 login(providerId: String, payload: [String: Any], completion: @escaping (Result<OAuth2LoginResponse, Error>) -> Void) {
116
- guard let config = getProvider(providerId) else {
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
- 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
- }
157
+ guard let issuerUrl = config.issuerUrl, !issuerUrl.isEmpty else {
158
+ completion(.success(config))
159
+ return
155
160
  }
156
161
 
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
+ // 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
- 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
+ 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] Opening authorization URL: \(authUrl.absoluteString)")
174
+ print("[OAuth2Provider] Discovering OIDC configuration at: \(url.absoluteString)")
173
175
  }
174
176
 
175
- let session = ASWebAuthenticationSession(url: authUrl, callbackURLScheme: callbackScheme) { [weak self] callbackURL, error in
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
- 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
- }
179
+ completion(.failure(error))
183
180
  return
184
181
  }
185
-
186
- guard let callbackURL = callbackURL else {
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
- self.handleCallback(providerId: providerId, config: config, callbackURL: callbackURL, redirectUri: redirect, completion: completion)
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
- session.presentationContextProvider = self
195
- session.prefersEphemeralWebBrowserSession = true
196
- currentSession = session
197
- session.start()
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
- if let config = getProvider(providerId), let logoutUrl = config.logoutUrl, let url = URL(string: logoutUrl) {
265
- DispatchQueue.main.async {
266
- UIApplication.shared.open(url)
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
- guard let config = getProvider(providerId) else {
296
- completion(.failure(NSError(domain: "OAuth2Provider", code: -1, userInfo: [NSLocalizedDescriptionKey: "OAuth2 provider '\(providerId)' not configured."])))
297
- return
298
- }
431
+ refreshToken(providerId: providerId, refreshToken: nil, additionalParameters: nil, completion: completion)
432
+ }
299
433
 
300
- guard let tokens = loadTokens(for: providerId), let refreshToken = tokens.refreshToken else {
301
- completion(.failure(NSError(domain: "OAuth2Provider", code: -10, userInfo: [NSLocalizedDescriptionKey: "No refresh token available. Include offline_access scope."])))
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
- let body: [String: String] = [
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: tokenResponse.refresh_token,
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: tokenResponse.refresh_token
621
+ refreshToken: effectiveRefreshToken
463
622
  ),
464
623
  idToken: tokenResponse.id_token,
465
- refreshToken: tokenResponse.refresh_token,
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!) }