@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.
@@ -55,6 +55,7 @@ public class OAuth2Provider implements SocialProvider {
55
55
  private static class OAuth2ProviderConfig {
56
56
 
57
57
  final String appId;
58
+ final String issuerUrl;
58
59
  final String authorizationBaseUrl;
59
60
  final String accessTokenEndpoint;
60
61
  final String redirectUrl;
@@ -63,12 +64,18 @@ public class OAuth2Provider implements SocialProvider {
63
64
  final boolean pkceEnabled;
64
65
  final String scope;
65
66
  final Map<String, String> additionalParameters;
67
+ final String loginHint;
68
+ final String prompt;
69
+ final Map<String, String> additionalTokenParameters;
66
70
  final Map<String, String> additionalResourceHeaders;
67
71
  final String logoutUrl;
72
+ final String postLogoutRedirectUrl;
73
+ final Map<String, String> additionalLogoutParameters;
68
74
  final boolean logsEnabled;
69
75
 
70
76
  OAuth2ProviderConfig(
71
77
  String appId,
78
+ String issuerUrl,
72
79
  String authorizationBaseUrl,
73
80
  String accessTokenEndpoint,
74
81
  String redirectUrl,
@@ -77,11 +84,17 @@ public class OAuth2Provider implements SocialProvider {
77
84
  boolean pkceEnabled,
78
85
  String scope,
79
86
  Map<String, String> additionalParameters,
87
+ String loginHint,
88
+ String prompt,
89
+ Map<String, String> additionalTokenParameters,
80
90
  Map<String, String> additionalResourceHeaders,
81
91
  String logoutUrl,
92
+ String postLogoutRedirectUrl,
93
+ Map<String, String> additionalLogoutParameters,
82
94
  boolean logsEnabled
83
95
  ) {
84
96
  this.appId = appId;
97
+ this.issuerUrl = issuerUrl;
85
98
  this.authorizationBaseUrl = authorizationBaseUrl;
86
99
  this.accessTokenEndpoint = accessTokenEndpoint;
87
100
  this.redirectUrl = redirectUrl;
@@ -90,8 +103,13 @@ public class OAuth2Provider implements SocialProvider {
90
103
  this.pkceEnabled = pkceEnabled;
91
104
  this.scope = scope;
92
105
  this.additionalParameters = additionalParameters;
106
+ this.loginHint = loginHint;
107
+ this.prompt = prompt;
108
+ this.additionalTokenParameters = additionalTokenParameters;
93
109
  this.additionalResourceHeaders = additionalResourceHeaders;
94
110
  this.logoutUrl = logoutUrl;
111
+ this.postLogoutRedirectUrl = postLogoutRedirectUrl;
112
+ this.additionalLogoutParameters = additionalLogoutParameters;
95
113
  this.logsEnabled = logsEnabled;
96
114
  }
97
115
  }
@@ -119,6 +137,100 @@ public class OAuth2Provider implements SocialProvider {
119
137
  this.httpClient = new OkHttpClient.Builder().connectTimeout(30, TimeUnit.SECONDS).readTimeout(30, TimeUnit.SECONDS).build();
120
138
  }
121
139
 
140
+ private interface DiscoveryCallback {
141
+ void onSuccess(OAuth2ProviderConfig config);
142
+
143
+ void onError(String message);
144
+ }
145
+
146
+ private static String trimTrailingSlashes(String s) {
147
+ if (s == null) return null;
148
+ int end = s.length();
149
+ while (end > 0 && s.charAt(end - 1) == '/') end--;
150
+ return s.substring(0, end);
151
+ }
152
+
153
+ private void ensureDiscovered(String providerId, OAuth2ProviderConfig config, DiscoveryCallback cb) {
154
+ if (config == null) {
155
+ cb.onError("OAuth2 provider '" + providerId + "' not found");
156
+ return;
157
+ }
158
+ if (config.issuerUrl == null || config.issuerUrl.isEmpty()) {
159
+ cb.onSuccess(config);
160
+ return;
161
+ }
162
+ // Already resolved enough for auth.
163
+ if (config.authorizationBaseUrl != null && !"code".equals(config.responseType)) {
164
+ cb.onSuccess(config);
165
+ return;
166
+ }
167
+ if (config.authorizationBaseUrl != null && config.accessTokenEndpoint != null) {
168
+ cb.onSuccess(config);
169
+ return;
170
+ }
171
+
172
+ String issuer = trimTrailingSlashes(config.issuerUrl);
173
+ String discoveryUrl = issuer + "/.well-known/openid-configuration";
174
+ Request req = new Request.Builder().url(discoveryUrl).get().build();
175
+ if (config.logsEnabled) {
176
+ Log.d(LOG_TAG, "Discovering OIDC configuration at: " + discoveryUrl);
177
+ }
178
+ httpClient
179
+ .newCall(req)
180
+ .enqueue(
181
+ new Callback() {
182
+ @Override
183
+ public void onFailure(Call call, IOException e) {
184
+ cb.onError("OIDC discovery failed: " + e.getMessage());
185
+ }
186
+
187
+ @Override
188
+ public void onResponse(Call call, Response response) throws IOException {
189
+ if (!response.isSuccessful()) {
190
+ cb.onError("OIDC discovery failed: HTTP " + response.code());
191
+ return;
192
+ }
193
+ String body = response.body() != null ? response.body().string() : "";
194
+ try {
195
+ JSONObject json = new JSONObject(body);
196
+ String auth = json.optString("authorization_endpoint", null);
197
+ String token = json.optString("token_endpoint", null);
198
+ String endSession = json.optString("end_session_endpoint", null);
199
+
200
+ OAuth2ProviderConfig resolved = new OAuth2ProviderConfig(
201
+ config.appId,
202
+ config.issuerUrl,
203
+ (config.authorizationBaseUrl != null && !config.authorizationBaseUrl.isEmpty())
204
+ ? config.authorizationBaseUrl
205
+ : auth,
206
+ (config.accessTokenEndpoint != null && !config.accessTokenEndpoint.isEmpty())
207
+ ? config.accessTokenEndpoint
208
+ : token,
209
+ config.redirectUrl,
210
+ config.resourceUrl,
211
+ config.responseType,
212
+ config.pkceEnabled,
213
+ config.scope,
214
+ config.additionalParameters,
215
+ config.loginHint,
216
+ config.prompt,
217
+ config.additionalTokenParameters,
218
+ config.additionalResourceHeaders,
219
+ (config.logoutUrl != null && !config.logoutUrl.isEmpty()) ? config.logoutUrl : endSession,
220
+ config.postLogoutRedirectUrl,
221
+ config.additionalLogoutParameters,
222
+ config.logsEnabled
223
+ );
224
+ providers.put(providerId, resolved);
225
+ cb.onSuccess(resolved);
226
+ } catch (JSONException e) {
227
+ cb.onError("Failed to parse OIDC discovery response");
228
+ }
229
+ }
230
+ }
231
+ );
232
+ }
233
+
122
234
  /**
123
235
  * Initialize multiple OAuth2 providers from a map of configs.
124
236
  * @param configs Map of providerId -> config JSONObject
@@ -133,44 +245,67 @@ public class OAuth2Provider implements SocialProvider {
133
245
  JSONObject config = configs.getJSONObject(providerId);
134
246
 
135
247
  String appId = config.optString("appId", null);
248
+ if (appId == null || appId.isEmpty()) {
249
+ appId = config.optString("clientId", null);
250
+ }
251
+ String issuerUrl = config.optString("issuerUrl", null);
136
252
  String authorizationBaseUrl = config.optString("authorizationBaseUrl", null);
253
+ if (authorizationBaseUrl == null || authorizationBaseUrl.isEmpty()) {
254
+ authorizationBaseUrl = config.optString("authorizationEndpoint", null);
255
+ }
137
256
  String redirectUrl = config.optString("redirectUrl", null);
138
257
 
139
258
  if (appId == null || appId.isEmpty()) {
140
- errors.add("oauth2." + providerId + ".appId is required");
141
- continue;
142
- }
143
- if (authorizationBaseUrl == null || authorizationBaseUrl.isEmpty()) {
144
- errors.add("oauth2." + providerId + ".authorizationBaseUrl is required");
259
+ errors.add("oauth2." + providerId + ".appId (or clientId) is required");
145
260
  continue;
146
261
  }
147
262
  if (redirectUrl == null || redirectUrl.isEmpty()) {
148
263
  errors.add("oauth2." + providerId + ".redirectUrl is required");
149
264
  continue;
150
265
  }
266
+ if ((authorizationBaseUrl == null || authorizationBaseUrl.isEmpty()) && (issuerUrl == null || issuerUrl.isEmpty())) {
267
+ errors.add("oauth2." + providerId + ".authorizationBaseUrl (or authorizationEndpoint) or issuerUrl is required");
268
+ continue;
269
+ }
151
270
 
152
271
  Map<String, String> additionalParameters = null;
153
272
  if (config.has("additionalParameters")) {
154
273
  additionalParameters = jsonObjectToMap(config.getJSONObject("additionalParameters"));
155
274
  }
156
275
 
276
+ Map<String, String> additionalTokenParameters = null;
277
+ if (config.has("additionalTokenParameters")) {
278
+ additionalTokenParameters = jsonObjectToMap(config.getJSONObject("additionalTokenParameters"));
279
+ }
280
+
157
281
  Map<String, String> additionalResourceHeaders = null;
158
282
  if (config.has("additionalResourceHeaders")) {
159
283
  additionalResourceHeaders = jsonObjectToMap(config.getJSONObject("additionalResourceHeaders"));
160
284
  }
161
285
 
286
+ Map<String, String> additionalLogoutParameters = null;
287
+ if (config.has("additionalLogoutParameters")) {
288
+ additionalLogoutParameters = jsonObjectToMap(config.getJSONObject("additionalLogoutParameters"));
289
+ }
290
+
162
291
  OAuth2ProviderConfig providerConfig = new OAuth2ProviderConfig(
163
292
  appId,
164
- authorizationBaseUrl,
165
- config.optString("accessTokenEndpoint", null),
293
+ issuerUrl,
294
+ (authorizationBaseUrl != null && !authorizationBaseUrl.isEmpty()) ? authorizationBaseUrl : null,
295
+ config.has("accessTokenEndpoint") ? config.optString("accessTokenEndpoint", null) : config.optString("tokenEndpoint", null),
166
296
  redirectUrl,
167
297
  config.optString("resourceUrl", null),
168
298
  config.optString("responseType", "code"),
169
299
  config.optBoolean("pkceEnabled", true),
170
- config.optString("scope", ""),
300
+ normalizeScopeValue(config.has("scope") ? config.opt("scope") : config.opt("scopes")),
171
301
  additionalParameters,
302
+ config.optString("loginHint", null),
303
+ config.optString("prompt", null),
304
+ additionalTokenParameters,
172
305
  additionalResourceHeaders,
173
- config.optString("logoutUrl", null),
306
+ config.has("logoutUrl") ? config.optString("logoutUrl", null) : config.optString("endSessionEndpoint", null),
307
+ config.optString("postLogoutRedirectUrl", null),
308
+ additionalLogoutParameters,
174
309
  config.optBoolean("logsEnabled", false)
175
310
  );
176
311
 
@@ -226,8 +361,11 @@ public class OAuth2Provider implements SocialProvider {
226
361
  }
227
362
 
228
363
  String loginScope = providerConfig.scope;
229
- if (config.has("scope")) {
230
- loginScope = config.optString("scope", providerConfig.scope);
364
+ if (config.has("scope") || config.has("scopes")) {
365
+ String normalized = normalizeScopeValue(config.has("scope") ? config.opt("scope") : config.opt("scopes"));
366
+ if (normalized != null && !normalized.isEmpty()) {
367
+ loginScope = normalized;
368
+ }
231
369
  }
232
370
 
233
371
  String redirect = providerConfig.redirectUrl;
@@ -237,7 +375,10 @@ public class OAuth2Provider implements SocialProvider {
237
375
 
238
376
  String state = config.has("state") ? config.optString("state", UUID.randomUUID().toString()) : UUID.randomUUID().toString();
239
377
 
240
- String codeVerifier = generateCodeVerifier();
378
+ String codeVerifier = config.has("codeVerifier") ? config.optString("codeVerifier", null) : null;
379
+ if (codeVerifier == null || codeVerifier.isEmpty()) {
380
+ codeVerifier = generateCodeVerifier();
381
+ }
241
382
  String codeChallenge;
242
383
  try {
243
384
  codeChallenge = generateCodeChallenge(codeVerifier);
@@ -246,55 +387,89 @@ public class OAuth2Provider implements SocialProvider {
246
387
  return;
247
388
  }
248
389
 
249
- pendingState = new OAuth2PendingState(providerId, state, codeVerifier, redirect, loginScope);
250
- pendingCall = call;
390
+ final String finalState = state;
391
+ final String finalCodeVerifier = codeVerifier;
392
+ final String finalRedirect = redirect;
393
+ final String finalLoginScope = loginScope;
394
+ final String finalCodeChallenge = codeChallenge;
395
+
396
+ // Resolve endpoints via discovery if needed, then start the login activity.
397
+ ensureDiscovered(
398
+ providerId,
399
+ providerConfig,
400
+ new DiscoveryCallback() {
401
+ @Override
402
+ public void onSuccess(OAuth2ProviderConfig resolved) {
403
+ if (resolved.authorizationBaseUrl == null || resolved.authorizationBaseUrl.isEmpty()) {
404
+ call.reject("Missing authorization endpoint (discovery may have failed)");
405
+ return;
406
+ }
251
407
 
252
- Uri.Builder builder = Uri.parse(providerConfig.authorizationBaseUrl)
253
- .buildUpon()
254
- .appendQueryParameter("response_type", providerConfig.responseType)
255
- .appendQueryParameter("client_id", providerConfig.appId)
256
- .appendQueryParameter("redirect_uri", redirect)
257
- .appendQueryParameter("state", state);
408
+ pendingState = new OAuth2PendingState(providerId, finalState, finalCodeVerifier, finalRedirect, finalLoginScope);
409
+ pendingCall = call;
258
410
 
259
- if (!loginScope.isEmpty()) {
260
- builder.appendQueryParameter("scope", loginScope);
261
- }
411
+ Uri.Builder builder = Uri.parse(resolved.authorizationBaseUrl)
412
+ .buildUpon()
413
+ .appendQueryParameter("response_type", resolved.responseType)
414
+ .appendQueryParameter("client_id", resolved.appId)
415
+ .appendQueryParameter("redirect_uri", finalRedirect)
416
+ .appendQueryParameter("state", finalState);
262
417
 
263
- // Add PKCE for code flow
264
- if ("code".equals(providerConfig.responseType) && providerConfig.pkceEnabled) {
265
- builder.appendQueryParameter("code_challenge", codeChallenge);
266
- builder.appendQueryParameter("code_challenge_method", "S256");
267
- }
418
+ if (!finalLoginScope.isEmpty()) {
419
+ builder.appendQueryParameter("scope", finalLoginScope);
420
+ }
268
421
 
269
- // Add additional parameters from config
270
- if (providerConfig.additionalParameters != null) {
271
- for (Map.Entry<String, String> entry : providerConfig.additionalParameters.entrySet()) {
272
- builder.appendQueryParameter(entry.getKey(), entry.getValue());
273
- }
274
- }
422
+ // Add PKCE for code flow
423
+ if ("code".equals(resolved.responseType) && resolved.pkceEnabled) {
424
+ builder.appendQueryParameter("code_challenge", finalCodeChallenge);
425
+ builder.appendQueryParameter("code_challenge_method", "S256");
426
+ }
275
427
 
276
- // Add additional parameters from login options
277
- if (config.has("additionalParameters")) {
278
- try {
279
- JSONObject loginParams = config.getJSONObject("additionalParameters");
280
- Iterator<String> keys = loginParams.keys();
281
- while (keys.hasNext()) {
282
- String key = keys.next();
283
- builder.appendQueryParameter(key, loginParams.getString(key));
284
- }
285
- } catch (JSONException e) {
286
- Log.w(LOG_TAG, "Failed to parse additionalParameters", e);
287
- }
288
- }
428
+ // Additional params: config + per-login
429
+ if (resolved.additionalParameters != null) {
430
+ for (Map.Entry<String, String> entry : resolved.additionalParameters.entrySet()) {
431
+ builder.appendQueryParameter(entry.getKey(), entry.getValue());
432
+ }
433
+ }
434
+ if (config.has("additionalParameters")) {
435
+ try {
436
+ JSONObject loginParams = config.getJSONObject("additionalParameters");
437
+ Iterator<String> keys = loginParams.keys();
438
+ while (keys.hasNext()) {
439
+ String key = keys.next();
440
+ builder.appendQueryParameter(key, loginParams.getString(key));
441
+ }
442
+ } catch (JSONException e) {
443
+ Log.w(LOG_TAG, "Failed to parse additionalParameters", e);
444
+ }
445
+ }
289
446
 
290
- if (providerConfig.logsEnabled) {
291
- Log.d(LOG_TAG, "Opening authorization URL: " + builder.build().toString());
292
- }
447
+ // Convenience OIDC params
448
+ String loginHint = config.optString("loginHint", resolved.loginHint);
449
+ if (loginHint != null && !loginHint.isEmpty()) {
450
+ builder.appendQueryParameter("login_hint", loginHint);
451
+ }
452
+ String prompt = config.optString("prompt", resolved.prompt);
453
+ if (prompt != null && !prompt.isEmpty()) {
454
+ builder.appendQueryParameter("prompt", prompt);
455
+ }
456
+
457
+ if (resolved.logsEnabled) {
458
+ Log.d(LOG_TAG, "Opening authorization URL: " + builder.build().toString());
459
+ }
293
460
 
294
- Intent intent = new Intent(activity, OAuth2LoginActivity.class);
295
- intent.putExtra(OAuth2LoginActivity.EXTRA_AUTH_URL, builder.build().toString());
296
- intent.putExtra(OAuth2LoginActivity.EXTRA_REDIRECT_URL, redirect);
297
- activity.startActivityForResult(intent, REQUEST_CODE);
461
+ Intent intent = new Intent(activity, OAuth2LoginActivity.class);
462
+ intent.putExtra(OAuth2LoginActivity.EXTRA_AUTH_URL, builder.build().toString());
463
+ intent.putExtra(OAuth2LoginActivity.EXTRA_REDIRECT_URL, finalRedirect);
464
+ activity.runOnUiThread(() -> activity.startActivityForResult(intent, REQUEST_CODE));
465
+ }
466
+
467
+ @Override
468
+ public void onError(String message) {
469
+ call.reject(message);
470
+ }
471
+ }
472
+ );
298
473
  }
299
474
 
300
475
  @Override
@@ -305,15 +480,43 @@ public class OAuth2Provider implements SocialProvider {
305
480
  return;
306
481
  }
307
482
 
483
+ OAuth2StoredTokens stored = loadStoredTokens(providerId);
308
484
  context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit().remove(getTokenStorageKey(providerId)).apply();
309
485
 
310
486
  OAuth2ProviderConfig config = getProvider(providerId);
311
- if (config != null && config.logoutUrl != null && !config.logoutUrl.isEmpty()) {
312
- Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(config.logoutUrl));
313
- activity.startActivity(browserIntent);
314
- }
487
+ ensureDiscovered(
488
+ providerId,
489
+ config,
490
+ new DiscoveryCallback() {
491
+ @Override
492
+ public void onSuccess(OAuth2ProviderConfig resolved) {
493
+ if (resolved != null && resolved.logoutUrl != null && !resolved.logoutUrl.isEmpty()) {
494
+ Uri base = Uri.parse(resolved.logoutUrl);
495
+ Uri.Builder b = base.buildUpon();
496
+ if (stored != null && stored.idToken != null && !stored.idToken.isEmpty()) {
497
+ b.appendQueryParameter("id_token_hint", stored.idToken);
498
+ }
499
+ if (resolved.postLogoutRedirectUrl != null && !resolved.postLogoutRedirectUrl.isEmpty()) {
500
+ b.appendQueryParameter("post_logout_redirect_uri", resolved.postLogoutRedirectUrl);
501
+ }
502
+ if (resolved.additionalLogoutParameters != null) {
503
+ for (Map.Entry<String, String> entry : resolved.additionalLogoutParameters.entrySet()) {
504
+ b.appendQueryParameter(entry.getKey(), entry.getValue());
505
+ }
506
+ }
507
+ Intent browserIntent = new Intent(Intent.ACTION_VIEW, b.build());
508
+ activity.runOnUiThread(() -> activity.startActivity(browserIntent));
509
+ }
510
+ call.resolve();
511
+ }
315
512
 
316
- call.resolve();
513
+ @Override
514
+ public void onError(String message) {
515
+ // Logout still succeeds locally even if discovery fails
516
+ call.resolve();
517
+ }
518
+ }
519
+ );
317
520
  }
318
521
 
319
522
  @Override
@@ -374,7 +577,43 @@ public class OAuth2Provider implements SocialProvider {
374
577
  call.reject("OAuth2 refresh token is not available. Make sure offline_access scope is granted.");
375
578
  return;
376
579
  }
377
- refreshWithToken(call, providerId, config, tokens.refreshToken);
580
+ refreshWithToken(call, providerId, config, tokens.refreshToken, null, true);
581
+ }
582
+
583
+ public void refreshTokenRaw(PluginCall call, String providerId, String refreshToken, JSONObject additionalParameters) {
584
+ OAuth2ProviderConfig config = getProvider(providerId);
585
+ if (config == null) {
586
+ call.reject("OAuth2 provider '" + providerId + "' is not initialized.");
587
+ return;
588
+ }
589
+ OAuth2StoredTokens stored = loadStoredTokens(providerId);
590
+ String effective = (refreshToken != null && !refreshToken.isEmpty()) ? refreshToken : (stored != null ? stored.refreshToken : null);
591
+ if (effective == null || effective.isEmpty()) {
592
+ call.reject("OAuth2 refresh token is not available. Make sure offline_access scope is granted.");
593
+ return;
594
+ }
595
+ refreshWithToken(call, providerId, config, effective, additionalParameters, false);
596
+ }
597
+
598
+ public Long getAccessTokenExpirationDateMs(String providerId) {
599
+ OAuth2StoredTokens tokens = loadStoredTokens(providerId);
600
+ return tokens != null && tokens.expiresAt > 0 ? tokens.expiresAt : null;
601
+ }
602
+
603
+ public boolean isAccessTokenAvailableStatus(String providerId) {
604
+ OAuth2StoredTokens tokens = loadStoredTokens(providerId);
605
+ return tokens != null && tokens.accessToken != null && !tokens.accessToken.isEmpty();
606
+ }
607
+
608
+ public boolean isAccessTokenExpiredStatus(String providerId) {
609
+ OAuth2StoredTokens tokens = loadStoredTokens(providerId);
610
+ if (tokens == null) return true;
611
+ return tokens.expiresAt <= System.currentTimeMillis();
612
+ }
613
+
614
+ public boolean isRefreshTokenAvailableStatus(String providerId) {
615
+ OAuth2StoredTokens tokens = loadStoredTokens(providerId);
616
+ return tokens != null && tokens.refreshToken != null && !tokens.refreshToken.isEmpty();
378
617
  }
379
618
 
380
619
  public boolean handleActivityResult(int requestCode, int resultCode, Intent data) {
@@ -493,10 +732,40 @@ public class OAuth2Provider implements SocialProvider {
493
732
  }
494
733
 
495
734
  if (config.accessTokenEndpoint == null || config.accessTokenEndpoint.isEmpty()) {
496
- pendingCall.reject("No accessTokenEndpoint configured for code exchange");
735
+ // Try discovery if issuerUrl exists
736
+ ensureDiscovered(
737
+ providerId,
738
+ config,
739
+ new DiscoveryCallback() {
740
+ @Override
741
+ public void onSuccess(OAuth2ProviderConfig resolved) {
742
+ if (resolved.accessTokenEndpoint == null || resolved.accessTokenEndpoint.isEmpty()) {
743
+ pendingCall.reject("No accessTokenEndpoint configured for code exchange");
744
+ cleanupPending();
745
+ return;
746
+ }
747
+ exchangeAuthorizationCodeWithConfig(code, resolved);
748
+ }
749
+
750
+ @Override
751
+ public void onError(String message) {
752
+ pendingCall.reject(message);
753
+ cleanupPending();
754
+ }
755
+ }
756
+ );
757
+ return;
758
+ }
759
+
760
+ exchangeAuthorizationCodeWithConfig(code, config);
761
+ }
762
+
763
+ private void exchangeAuthorizationCodeWithConfig(String code, OAuth2ProviderConfig config) {
764
+ if (pendingState == null || pendingCall == null) {
497
765
  cleanupPending();
498
766
  return;
499
767
  }
768
+ final String providerId = pendingState.providerId;
500
769
 
501
770
  FormBody.Builder bodyBuilder = new FormBody.Builder()
502
771
  .add("grant_type", "authorization_code")
@@ -508,6 +777,12 @@ public class OAuth2Provider implements SocialProvider {
508
777
  bodyBuilder.add("code_verifier", pendingState.codeVerifier);
509
778
  }
510
779
 
780
+ if (config.additionalTokenParameters != null) {
781
+ for (Map.Entry<String, String> entry : config.additionalTokenParameters.entrySet()) {
782
+ bodyBuilder.add(entry.getKey(), entry.getValue());
783
+ }
784
+ }
785
+
511
786
  Request request = new Request.Builder().url(config.accessTokenEndpoint).post(bodyBuilder.build()).build();
512
787
 
513
788
  if (config.logsEnabled) {
@@ -551,19 +826,62 @@ public class OAuth2Provider implements SocialProvider {
551
826
  );
552
827
  }
553
828
 
554
- private void refreshWithToken(final PluginCall pluginCall, String providerId, OAuth2ProviderConfig config, String refreshToken) {
829
+ private void refreshWithToken(
830
+ final PluginCall pluginCall,
831
+ String providerId,
832
+ OAuth2ProviderConfig config,
833
+ String refreshToken,
834
+ JSONObject additionalParameters,
835
+ boolean wrapResponse
836
+ ) {
555
837
  if (config.accessTokenEndpoint == null || config.accessTokenEndpoint.isEmpty()) {
556
- pluginCall.reject("No accessTokenEndpoint configured for refresh");
838
+ // Try discovery if issuerUrl exists
839
+ ensureDiscovered(
840
+ providerId,
841
+ config,
842
+ new DiscoveryCallback() {
843
+ @Override
844
+ public void onSuccess(OAuth2ProviderConfig resolved) {
845
+ if (resolved.accessTokenEndpoint == null || resolved.accessTokenEndpoint.isEmpty()) {
846
+ pluginCall.reject("No accessTokenEndpoint configured for refresh");
847
+ return;
848
+ }
849
+ refreshWithToken(pluginCall, providerId, resolved, refreshToken, additionalParameters, wrapResponse);
850
+ }
851
+
852
+ @Override
853
+ public void onError(String message) {
854
+ pluginCall.reject(message);
855
+ }
856
+ }
857
+ );
557
858
  return;
558
859
  }
559
860
 
560
- FormBody body = new FormBody.Builder()
861
+ FormBody.Builder bodyBuilder = new FormBody.Builder()
561
862
  .add("grant_type", "refresh_token")
562
863
  .add("refresh_token", refreshToken)
563
- .add("client_id", config.appId)
564
- .build();
864
+ .add("client_id", config.appId);
865
+
866
+ if (config.additionalTokenParameters != null) {
867
+ for (Map.Entry<String, String> entry : config.additionalTokenParameters.entrySet()) {
868
+ bodyBuilder.add(entry.getKey(), entry.getValue());
869
+ }
870
+ }
871
+
872
+ if (additionalParameters != null) {
873
+ try {
874
+ Iterator<String> keys = additionalParameters.keys();
875
+ while (keys.hasNext()) {
876
+ String key = keys.next();
877
+ bodyBuilder.add(key, additionalParameters.getString(key));
878
+ }
879
+ } catch (JSONException e) {
880
+ Log.w(LOG_TAG, "Failed to parse additionalParameters for refresh", e);
881
+ }
882
+ }
565
883
 
566
- Request request = new Request.Builder().url(config.accessTokenEndpoint).post(body).build();
884
+ Request request = new Request.Builder().url(config.accessTokenEndpoint).post(bodyBuilder.build()).build();
567
885
 
568
886
  httpClient
569
887
  .newCall(request)
@@ -584,7 +902,7 @@ public class OAuth2Provider implements SocialProvider {
584
902
  String responseBody = response.body() != null ? response.body().string() : "";
585
903
  try {
586
904
  JSONObject tokenPayload = new JSONObject(responseBody);
587
- handleTokenSuccess(providerId, config, tokenPayload, pluginCall);
905
+ handleTokenSuccess(providerId, config, tokenPayload, pluginCall, refreshToken, wrapResponse);
588
906
  } catch (JSONException e) {
589
907
  pluginCall.reject("Failed to parse OAuth2 refresh response", e);
590
908
  }
@@ -594,12 +912,18 @@ public class OAuth2Provider implements SocialProvider {
594
912
  }
595
913
 
596
914
  private void handleTokenSuccess(String providerId, OAuth2ProviderConfig config, JSONObject tokenPayload) throws JSONException {
597
- handleTokenSuccess(providerId, config, tokenPayload, pendingCall);
915
+ handleTokenSuccess(providerId, config, tokenPayload, pendingCall, null, true);
598
916
  cleanupPending();
599
917
  }
600
918
 
601
- private void handleTokenSuccess(String providerId, OAuth2ProviderConfig config, JSONObject tokenPayload, PluginCall call)
602
- throws JSONException {
919
+ private void handleTokenSuccess(
920
+ String providerId,
921
+ OAuth2ProviderConfig config,
922
+ JSONObject tokenPayload,
923
+ PluginCall call,
924
+ String fallbackRefreshToken,
925
+ boolean wrapResponse
926
+ ) throws JSONException {
603
927
  if (call == null) {
604
928
  return;
605
929
  }
@@ -607,7 +931,8 @@ public class OAuth2Provider implements SocialProvider {
607
931
  final String accessToken = tokenPayload.getString("access_token");
608
932
  final String tokenType = tokenPayload.optString("token_type", "bearer");
609
933
  final int expiresIn = tokenPayload.optInt("expires_in", 3600);
610
- final String refreshToken = tokenPayload.optString("refresh_token", null);
934
+ final String refreshToken = tokenPayload.has("refresh_token") ? tokenPayload.optString("refresh_token", null) : null;
935
+ final String effectiveRefreshToken = (refreshToken != null && !refreshToken.isEmpty()) ? refreshToken : fallbackRefreshToken;
611
936
  final String idToken = tokenPayload.optString("id_token", null);
612
937
  final String scopeRaw = tokenPayload.optString("scope", "");
613
938
  final List<String> scopes = scopeRaw.isEmpty() ? Collections.emptyList() : Arrays.asList(scopeRaw.split(" "));
@@ -628,11 +953,12 @@ public class OAuth2Provider implements SocialProvider {
628
953
  tokenType,
629
954
  expiresIn,
630
955
  expiresAt,
631
- refreshToken,
956
+ effectiveRefreshToken,
632
957
  idToken,
633
958
  scopes,
634
959
  resourceData,
635
- call
960
+ call,
961
+ wrapResponse
636
962
  );
637
963
  }
638
964
 
@@ -641,12 +967,36 @@ public class OAuth2Provider implements SocialProvider {
641
967
  if (config.logsEnabled) {
642
968
  Log.w(LOG_TAG, "Failed to fetch resource: " + message);
643
969
  }
644
- completeLogin(providerId, accessToken, tokenType, expiresIn, expiresAt, refreshToken, idToken, scopes, null, call);
970
+ completeLogin(
971
+ providerId,
972
+ accessToken,
973
+ tokenType,
974
+ expiresIn,
975
+ expiresAt,
976
+ effectiveRefreshToken,
977
+ idToken,
978
+ scopes,
979
+ null,
980
+ call,
981
+ wrapResponse
982
+ );
645
983
  }
646
984
  }
647
985
  );
648
986
  } else {
649
- completeLogin(providerId, accessToken, tokenType, expiresIn, expiresAt, refreshToken, idToken, scopes, null, call);
987
+ completeLogin(
988
+ providerId,
989
+ accessToken,
990
+ tokenType,
991
+ expiresIn,
992
+ expiresAt,
993
+ effectiveRefreshToken,
994
+ idToken,
995
+ scopes,
996
+ null,
997
+ call,
998
+ wrapResponse
999
+ );
650
1000
  }
651
1001
  }
652
1002
 
@@ -661,7 +1011,19 @@ public class OAuth2Provider implements SocialProvider {
661
1011
  List<String> scopes,
662
1012
  JSONObject resourceData
663
1013
  ) {
664
- completeLogin(providerId, accessToken, tokenType, expiresIn, expiresAt, refreshToken, idToken, scopes, resourceData, pendingCall);
1014
+ completeLogin(
1015
+ providerId,
1016
+ accessToken,
1017
+ tokenType,
1018
+ expiresIn,
1019
+ expiresAt,
1020
+ refreshToken,
1021
+ idToken,
1022
+ scopes,
1023
+ resourceData,
1024
+ pendingCall,
1025
+ true
1026
+ );
665
1027
  cleanupPending();
666
1028
  }
667
1029
 
@@ -675,7 +1037,8 @@ public class OAuth2Provider implements SocialProvider {
675
1037
  String idToken,
676
1038
  List<String> scopes,
677
1039
  JSONObject resourceData,
678
- PluginCall call
1040
+ PluginCall call,
1041
+ boolean wrapResponse
679
1042
  ) {
680
1043
  if (call == null) {
681
1044
  return;
@@ -704,10 +1067,14 @@ public class OAuth2Provider implements SocialProvider {
704
1067
  result.put("tokenType", tokenType);
705
1068
  result.put("expiresIn", expiresIn);
706
1069
 
707
- JSObject response = new JSObject();
708
- response.put("provider", "oauth2");
709
- response.put("result", result);
710
- call.resolve(response);
1070
+ if (wrapResponse) {
1071
+ JSObject response = new JSObject();
1072
+ response.put("provider", "oauth2");
1073
+ response.put("result", result);
1074
+ call.resolve(response);
1075
+ } else {
1076
+ call.resolve(result);
1077
+ }
711
1078
  }
712
1079
 
713
1080
  private interface ResourceCallback {
@@ -816,6 +1183,21 @@ public class OAuth2Provider implements SocialProvider {
816
1183
  return map;
817
1184
  }
818
1185
 
1186
+ private static String normalizeScopeValue(Object value) {
1187
+ if (value == null || value == JSONObject.NULL) return "";
1188
+ if (value instanceof String) return (String) value;
1189
+ if (value instanceof JSONArray) {
1190
+ JSONArray arr = (JSONArray) value;
1191
+ List<String> parts = new ArrayList<>();
1192
+ for (int i = 0; i < arr.length(); i++) {
1193
+ String s = arr.optString(i, null);
1194
+ if (s != null && !s.isEmpty()) parts.add(s);
1195
+ }
1196
+ return String.join(" ", parts);
1197
+ }
1198
+ return "";
1199
+ }
1200
+
819
1201
  private static String generateCodeVerifier() {
820
1202
  SecureRandom secureRandom = new SecureRandom();
821
1203
  byte[] code = new byte[64];