@capgo/capacitor-social-login 8.1.1 → 8.2.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/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 +2 -1
|
@@ -0,0 +1,848 @@
|
|
|
1
|
+
package ee.forgr.capacitor.social.login;
|
|
2
|
+
|
|
3
|
+
import android.app.Activity;
|
|
4
|
+
import android.content.Context;
|
|
5
|
+
import android.content.Intent;
|
|
6
|
+
import android.content.SharedPreferences;
|
|
7
|
+
import android.net.Uri;
|
|
8
|
+
import android.util.Base64;
|
|
9
|
+
import android.util.Log;
|
|
10
|
+
import com.getcapacitor.JSArray;
|
|
11
|
+
import com.getcapacitor.JSObject;
|
|
12
|
+
import com.getcapacitor.PluginCall;
|
|
13
|
+
import ee.forgr.capacitor.social.login.helpers.SocialProvider;
|
|
14
|
+
import java.io.IOException;
|
|
15
|
+
import java.nio.charset.StandardCharsets;
|
|
16
|
+
import java.security.MessageDigest;
|
|
17
|
+
import java.security.NoSuchAlgorithmException;
|
|
18
|
+
import java.security.SecureRandom;
|
|
19
|
+
import java.util.ArrayList;
|
|
20
|
+
import java.util.Arrays;
|
|
21
|
+
import java.util.Collections;
|
|
22
|
+
import java.util.HashMap;
|
|
23
|
+
import java.util.Iterator;
|
|
24
|
+
import java.util.List;
|
|
25
|
+
import java.util.Map;
|
|
26
|
+
import java.util.UUID;
|
|
27
|
+
import java.util.concurrent.TimeUnit;
|
|
28
|
+
import okhttp3.Call;
|
|
29
|
+
import okhttp3.Callback;
|
|
30
|
+
import okhttp3.FormBody;
|
|
31
|
+
import okhttp3.OkHttpClient;
|
|
32
|
+
import okhttp3.Request;
|
|
33
|
+
import okhttp3.Response;
|
|
34
|
+
import org.json.JSONArray;
|
|
35
|
+
import org.json.JSONException;
|
|
36
|
+
import org.json.JSONObject;
|
|
37
|
+
|
|
38
|
+
public class OAuth2Provider implements SocialProvider {
|
|
39
|
+
|
|
40
|
+
public static final int REQUEST_CODE = 9402;
|
|
41
|
+
private static final String LOG_TAG = "OAuth2Provider";
|
|
42
|
+
private static final String PREFS_NAME = "CapgoOAuth2ProviderPrefs";
|
|
43
|
+
private static final String PREFS_KEY_PREFIX = "OAuth2Tokens_";
|
|
44
|
+
|
|
45
|
+
private final Activity activity;
|
|
46
|
+
private final Context context;
|
|
47
|
+
private final OkHttpClient httpClient;
|
|
48
|
+
|
|
49
|
+
// Map of providerId -> OAuth2ProviderConfig
|
|
50
|
+
private final Map<String, OAuth2ProviderConfig> providers = new HashMap<>();
|
|
51
|
+
|
|
52
|
+
private PluginCall pendingCall;
|
|
53
|
+
private OAuth2PendingState pendingState;
|
|
54
|
+
|
|
55
|
+
private static class OAuth2ProviderConfig {
|
|
56
|
+
|
|
57
|
+
final String appId;
|
|
58
|
+
final String authorizationBaseUrl;
|
|
59
|
+
final String accessTokenEndpoint;
|
|
60
|
+
final String redirectUrl;
|
|
61
|
+
final String resourceUrl;
|
|
62
|
+
final String responseType;
|
|
63
|
+
final boolean pkceEnabled;
|
|
64
|
+
final String scope;
|
|
65
|
+
final Map<String, String> additionalParameters;
|
|
66
|
+
final Map<String, String> additionalResourceHeaders;
|
|
67
|
+
final String logoutUrl;
|
|
68
|
+
final boolean logsEnabled;
|
|
69
|
+
|
|
70
|
+
OAuth2ProviderConfig(
|
|
71
|
+
String appId,
|
|
72
|
+
String authorizationBaseUrl,
|
|
73
|
+
String accessTokenEndpoint,
|
|
74
|
+
String redirectUrl,
|
|
75
|
+
String resourceUrl,
|
|
76
|
+
String responseType,
|
|
77
|
+
boolean pkceEnabled,
|
|
78
|
+
String scope,
|
|
79
|
+
Map<String, String> additionalParameters,
|
|
80
|
+
Map<String, String> additionalResourceHeaders,
|
|
81
|
+
String logoutUrl,
|
|
82
|
+
boolean logsEnabled
|
|
83
|
+
) {
|
|
84
|
+
this.appId = appId;
|
|
85
|
+
this.authorizationBaseUrl = authorizationBaseUrl;
|
|
86
|
+
this.accessTokenEndpoint = accessTokenEndpoint;
|
|
87
|
+
this.redirectUrl = redirectUrl;
|
|
88
|
+
this.resourceUrl = resourceUrl;
|
|
89
|
+
this.responseType = responseType;
|
|
90
|
+
this.pkceEnabled = pkceEnabled;
|
|
91
|
+
this.scope = scope;
|
|
92
|
+
this.additionalParameters = additionalParameters;
|
|
93
|
+
this.additionalResourceHeaders = additionalResourceHeaders;
|
|
94
|
+
this.logoutUrl = logoutUrl;
|
|
95
|
+
this.logsEnabled = logsEnabled;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private static class OAuth2PendingState {
|
|
100
|
+
|
|
101
|
+
final String providerId;
|
|
102
|
+
final String state;
|
|
103
|
+
final String codeVerifier;
|
|
104
|
+
final String redirectUri;
|
|
105
|
+
final String scope;
|
|
106
|
+
|
|
107
|
+
OAuth2PendingState(String providerId, String state, String codeVerifier, String redirectUri, String scope) {
|
|
108
|
+
this.providerId = providerId;
|
|
109
|
+
this.state = state;
|
|
110
|
+
this.codeVerifier = codeVerifier;
|
|
111
|
+
this.redirectUri = redirectUri;
|
|
112
|
+
this.scope = scope;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
public OAuth2Provider(Activity activity, Context context) {
|
|
117
|
+
this.activity = activity;
|
|
118
|
+
this.context = context;
|
|
119
|
+
this.httpClient = new OkHttpClient.Builder().connectTimeout(30, TimeUnit.SECONDS).readTimeout(30, TimeUnit.SECONDS).build();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Initialize multiple OAuth2 providers from a map of configs.
|
|
124
|
+
* @param configs Map of providerId -> config JSONObject
|
|
125
|
+
* @return List of error messages (empty if all succeeded)
|
|
126
|
+
*/
|
|
127
|
+
public List<String> initializeProviders(JSONObject configs) throws JSONException {
|
|
128
|
+
List<String> errors = new ArrayList<>();
|
|
129
|
+
|
|
130
|
+
Iterator<String> keys = configs.keys();
|
|
131
|
+
while (keys.hasNext()) {
|
|
132
|
+
String providerId = keys.next();
|
|
133
|
+
JSONObject config = configs.getJSONObject(providerId);
|
|
134
|
+
|
|
135
|
+
String appId = config.optString("appId", null);
|
|
136
|
+
String authorizationBaseUrl = config.optString("authorizationBaseUrl", null);
|
|
137
|
+
String redirectUrl = config.optString("redirectUrl", null);
|
|
138
|
+
|
|
139
|
+
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");
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (redirectUrl == null || redirectUrl.isEmpty()) {
|
|
148
|
+
errors.add("oauth2." + providerId + ".redirectUrl is required");
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
Map<String, String> additionalParameters = null;
|
|
153
|
+
if (config.has("additionalParameters")) {
|
|
154
|
+
additionalParameters = jsonObjectToMap(config.getJSONObject("additionalParameters"));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
Map<String, String> additionalResourceHeaders = null;
|
|
158
|
+
if (config.has("additionalResourceHeaders")) {
|
|
159
|
+
additionalResourceHeaders = jsonObjectToMap(config.getJSONObject("additionalResourceHeaders"));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
OAuth2ProviderConfig providerConfig = new OAuth2ProviderConfig(
|
|
163
|
+
appId,
|
|
164
|
+
authorizationBaseUrl,
|
|
165
|
+
config.optString("accessTokenEndpoint", null),
|
|
166
|
+
redirectUrl,
|
|
167
|
+
config.optString("resourceUrl", null),
|
|
168
|
+
config.optString("responseType", "code"),
|
|
169
|
+
config.optBoolean("pkceEnabled", true),
|
|
170
|
+
config.optString("scope", ""),
|
|
171
|
+
additionalParameters,
|
|
172
|
+
additionalResourceHeaders,
|
|
173
|
+
config.optString("logoutUrl", null),
|
|
174
|
+
config.optBoolean("logsEnabled", false)
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
providers.put(providerId, providerConfig);
|
|
178
|
+
|
|
179
|
+
if (providerConfig.logsEnabled) {
|
|
180
|
+
Log.d(
|
|
181
|
+
LOG_TAG,
|
|
182
|
+
"Initialized provider '" + providerId + "' with appId: " + appId + ", authorizationBaseUrl: " + authorizationBaseUrl
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return errors;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* @deprecated Use initializeProviders instead for multi-provider support
|
|
192
|
+
*/
|
|
193
|
+
@Deprecated
|
|
194
|
+
public void initialize(JSONObject config) throws JSONException {
|
|
195
|
+
// Legacy single-provider init - create a default provider
|
|
196
|
+
JSONObject wrapper = new JSONObject();
|
|
197
|
+
wrapper.put("default", config);
|
|
198
|
+
initializeProviders(wrapper);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private OAuth2ProviderConfig getProvider(String providerId) {
|
|
202
|
+
return providers.get(providerId);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private String getTokenStorageKey(String providerId) {
|
|
206
|
+
return PREFS_KEY_PREFIX + providerId;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
@Override
|
|
210
|
+
public void login(PluginCall call, JSONObject config) {
|
|
211
|
+
String providerId = config != null ? config.optString("providerId", null) : null;
|
|
212
|
+
if (providerId == null || providerId.isEmpty()) {
|
|
213
|
+
call.reject("providerId is required for oauth2 login");
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
OAuth2ProviderConfig providerConfig = getProvider(providerId);
|
|
218
|
+
if (providerConfig == null) {
|
|
219
|
+
call.reject("OAuth2 provider '" + providerId + "' is not initialized. Call initialize() first.");
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (pendingCall != null) {
|
|
224
|
+
call.reject("Another OAuth2 login is already running.");
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
String loginScope = providerConfig.scope;
|
|
229
|
+
if (config.has("scope")) {
|
|
230
|
+
loginScope = config.optString("scope", providerConfig.scope);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
String redirect = providerConfig.redirectUrl;
|
|
234
|
+
if (config.has("redirectUrl")) {
|
|
235
|
+
redirect = config.optString("redirectUrl", providerConfig.redirectUrl);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
String state = config.has("state") ? config.optString("state", UUID.randomUUID().toString()) : UUID.randomUUID().toString();
|
|
239
|
+
|
|
240
|
+
String codeVerifier = generateCodeVerifier();
|
|
241
|
+
String codeChallenge;
|
|
242
|
+
try {
|
|
243
|
+
codeChallenge = generateCodeChallenge(codeVerifier);
|
|
244
|
+
} catch (NoSuchAlgorithmException e) {
|
|
245
|
+
call.reject("Unable to generate code challenge", e);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
pendingState = new OAuth2PendingState(providerId, state, codeVerifier, redirect, loginScope);
|
|
250
|
+
pendingCall = call;
|
|
251
|
+
|
|
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);
|
|
258
|
+
|
|
259
|
+
if (!loginScope.isEmpty()) {
|
|
260
|
+
builder.appendQueryParameter("scope", loginScope);
|
|
261
|
+
}
|
|
262
|
+
|
|
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
|
+
}
|
|
268
|
+
|
|
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
|
+
}
|
|
275
|
+
|
|
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
|
+
}
|
|
289
|
+
|
|
290
|
+
if (providerConfig.logsEnabled) {
|
|
291
|
+
Log.d(LOG_TAG, "Opening authorization URL: " + builder.build().toString());
|
|
292
|
+
}
|
|
293
|
+
|
|
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);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
@Override
|
|
301
|
+
public void logout(PluginCall call) {
|
|
302
|
+
String providerId = call.getString("providerId");
|
|
303
|
+
if (providerId == null || providerId.isEmpty()) {
|
|
304
|
+
call.reject("providerId is required for oauth2 logout");
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).edit().remove(getTokenStorageKey(providerId)).apply();
|
|
309
|
+
|
|
310
|
+
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
|
+
}
|
|
315
|
+
|
|
316
|
+
call.resolve();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
@Override
|
|
320
|
+
public void getAuthorizationCode(PluginCall call) {
|
|
321
|
+
String providerId = call.getString("providerId");
|
|
322
|
+
if (providerId == null || providerId.isEmpty()) {
|
|
323
|
+
call.reject("providerId is required for oauth2 getAuthorizationCode");
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
OAuth2StoredTokens tokens = loadStoredTokens(providerId);
|
|
328
|
+
if (tokens == null) {
|
|
329
|
+
call.reject("OAuth2 access token not available for provider '" + providerId + "'");
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
JSObject response = new JSObject();
|
|
333
|
+
response.put("accessToken", tokens.accessToken);
|
|
334
|
+
if (tokens.refreshToken != null) {
|
|
335
|
+
response.put("refreshToken", tokens.refreshToken);
|
|
336
|
+
}
|
|
337
|
+
if (tokens.idToken != null) {
|
|
338
|
+
response.put("jwt", tokens.idToken);
|
|
339
|
+
}
|
|
340
|
+
response.put("tokenType", tokens.tokenType);
|
|
341
|
+
call.resolve(response);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
@Override
|
|
345
|
+
public void isLoggedIn(PluginCall call) {
|
|
346
|
+
String providerId = call.getString("providerId");
|
|
347
|
+
if (providerId == null || providerId.isEmpty()) {
|
|
348
|
+
call.reject("providerId is required for oauth2 isLoggedIn");
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
OAuth2StoredTokens tokens = loadStoredTokens(providerId);
|
|
353
|
+
boolean isValid = tokens != null && tokens.expiresAt > System.currentTimeMillis();
|
|
354
|
+
call.resolve(new JSObject().put("isLoggedIn", isValid));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
@Override
|
|
358
|
+
public void refresh(PluginCall call) {
|
|
359
|
+
JSObject options = call.getObject("options");
|
|
360
|
+
String providerId = options != null ? options.getString("providerId") : null;
|
|
361
|
+
if (providerId == null || providerId.isEmpty()) {
|
|
362
|
+
call.reject("providerId is required for oauth2 refresh");
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
OAuth2ProviderConfig config = getProvider(providerId);
|
|
367
|
+
if (config == null) {
|
|
368
|
+
call.reject("OAuth2 provider '" + providerId + "' is not initialized.");
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
OAuth2StoredTokens tokens = loadStoredTokens(providerId);
|
|
373
|
+
if (tokens == null || tokens.refreshToken == null) {
|
|
374
|
+
call.reject("OAuth2 refresh token is not available. Make sure offline_access scope is granted.");
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
refreshWithToken(call, providerId, config, tokens.refreshToken);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
public boolean handleActivityResult(int requestCode, int resultCode, Intent data) {
|
|
381
|
+
if (requestCode != REQUEST_CODE) {
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
if (pendingCall == null || pendingState == null) {
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (resultCode != Activity.RESULT_OK) {
|
|
389
|
+
String error = data != null ? data.getStringExtra("error") : "User cancelled";
|
|
390
|
+
pendingCall.reject(error != null ? error : "User cancelled");
|
|
391
|
+
cleanupPending();
|
|
392
|
+
return true;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
String returnedState = data.getStringExtra("state");
|
|
396
|
+
if (returnedState == null || !returnedState.equals(pendingState.state)) {
|
|
397
|
+
pendingCall.reject("State mismatch during OAuth2 login");
|
|
398
|
+
cleanupPending();
|
|
399
|
+
return true;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
String error = data.getStringExtra("error");
|
|
403
|
+
if (error != null) {
|
|
404
|
+
String description = data.getStringExtra("error_description");
|
|
405
|
+
pendingCall.reject(description != null ? description : error);
|
|
406
|
+
cleanupPending();
|
|
407
|
+
return true;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Check for code (authorization code flow)
|
|
411
|
+
String code = data.getStringExtra("code");
|
|
412
|
+
if (code != null) {
|
|
413
|
+
exchangeAuthorizationCode(code);
|
|
414
|
+
return true;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Check for access_token (implicit flow)
|
|
418
|
+
String accessToken = data.getStringExtra("access_token");
|
|
419
|
+
if (accessToken != null) {
|
|
420
|
+
handleImplicitFlowResponse(data);
|
|
421
|
+
return true;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
pendingCall.reject("No authorization code or access token in callback");
|
|
425
|
+
cleanupPending();
|
|
426
|
+
return true;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private void handleImplicitFlowResponse(Intent data) {
|
|
430
|
+
if (pendingState == null) {
|
|
431
|
+
if (pendingCall != null) {
|
|
432
|
+
pendingCall.reject("Internal error: missing pending state");
|
|
433
|
+
cleanupPending();
|
|
434
|
+
}
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
String providerId = pendingState.providerId;
|
|
439
|
+
OAuth2ProviderConfig config = getProvider(providerId);
|
|
440
|
+
|
|
441
|
+
String accessToken = data.getStringExtra("access_token");
|
|
442
|
+
String tokenType = data.getStringExtra("token_type");
|
|
443
|
+
String expiresInStr = data.getStringExtra("expires_in");
|
|
444
|
+
String scopeStr = data.getStringExtra("scope");
|
|
445
|
+
String idToken = data.getStringExtra("id_token");
|
|
446
|
+
|
|
447
|
+
int expiresIn = expiresInStr != null ? Integer.parseInt(expiresInStr) : 3600;
|
|
448
|
+
List<String> scopes = scopeStr != null && !scopeStr.isEmpty() ? Arrays.asList(scopeStr.split(" ")) : Collections.emptyList();
|
|
449
|
+
|
|
450
|
+
long expiresAt = System.currentTimeMillis() + (long) expiresIn * 1000L;
|
|
451
|
+
|
|
452
|
+
// Fetch resource data if configured
|
|
453
|
+
if (config != null && config.resourceUrl != null && !config.resourceUrl.isEmpty()) {
|
|
454
|
+
fetchResource(
|
|
455
|
+
config,
|
|
456
|
+
accessToken,
|
|
457
|
+
new ResourceCallback() {
|
|
458
|
+
@Override
|
|
459
|
+
public void onSuccess(JSONObject resourceData) {
|
|
460
|
+
completeLogin(providerId, accessToken, tokenType, expiresIn, expiresAt, null, idToken, scopes, resourceData);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
@Override
|
|
464
|
+
public void onError(String message) {
|
|
465
|
+
if (config.logsEnabled) {
|
|
466
|
+
Log.w(LOG_TAG, "Failed to fetch resource: " + message);
|
|
467
|
+
}
|
|
468
|
+
completeLogin(providerId, accessToken, tokenType, expiresIn, expiresAt, null, idToken, scopes, null);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
);
|
|
472
|
+
} else {
|
|
473
|
+
completeLogin(providerId, accessToken, tokenType, expiresIn, expiresAt, null, idToken, scopes, null);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private void exchangeAuthorizationCode(String code) {
|
|
478
|
+
if (pendingState == null) {
|
|
479
|
+
if (pendingCall != null) {
|
|
480
|
+
pendingCall.reject("Internal error: missing pending state");
|
|
481
|
+
cleanupPending();
|
|
482
|
+
}
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
String providerId = pendingState.providerId;
|
|
487
|
+
OAuth2ProviderConfig config = getProvider(providerId);
|
|
488
|
+
|
|
489
|
+
if (config == null) {
|
|
490
|
+
pendingCall.reject("OAuth2 provider '" + providerId + "' not found");
|
|
491
|
+
cleanupPending();
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (config.accessTokenEndpoint == null || config.accessTokenEndpoint.isEmpty()) {
|
|
496
|
+
pendingCall.reject("No accessTokenEndpoint configured for code exchange");
|
|
497
|
+
cleanupPending();
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
FormBody.Builder bodyBuilder = new FormBody.Builder()
|
|
502
|
+
.add("grant_type", "authorization_code")
|
|
503
|
+
.add("client_id", config.appId)
|
|
504
|
+
.add("code", code)
|
|
505
|
+
.add("redirect_uri", pendingState.redirectUri);
|
|
506
|
+
|
|
507
|
+
if (config.pkceEnabled) {
|
|
508
|
+
bodyBuilder.add("code_verifier", pendingState.codeVerifier);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
Request request = new Request.Builder().url(config.accessTokenEndpoint).post(bodyBuilder.build()).build();
|
|
512
|
+
|
|
513
|
+
if (config.logsEnabled) {
|
|
514
|
+
Log.d(LOG_TAG, "Exchanging code at: " + config.accessTokenEndpoint);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
httpClient
|
|
518
|
+
.newCall(request)
|
|
519
|
+
.enqueue(
|
|
520
|
+
new Callback() {
|
|
521
|
+
@Override
|
|
522
|
+
public void onFailure(Call call, IOException e) {
|
|
523
|
+
if (pendingCall != null) {
|
|
524
|
+
pendingCall.reject("OAuth2 token exchange failed", e);
|
|
525
|
+
}
|
|
526
|
+
cleanupPending();
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
@Override
|
|
530
|
+
public void onResponse(Call call, Response response) throws IOException {
|
|
531
|
+
if (!response.isSuccessful()) {
|
|
532
|
+
String errorBody = response.body() != null ? response.body().string() : "";
|
|
533
|
+
if (pendingCall != null) {
|
|
534
|
+
pendingCall.reject("OAuth2 token exchange failed: " + errorBody);
|
|
535
|
+
}
|
|
536
|
+
cleanupPending();
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
String responseBody = response.body() != null ? response.body().string() : "";
|
|
540
|
+
try {
|
|
541
|
+
JSONObject tokenPayload = new JSONObject(responseBody);
|
|
542
|
+
handleTokenSuccess(providerId, config, tokenPayload);
|
|
543
|
+
} catch (JSONException e) {
|
|
544
|
+
if (pendingCall != null) {
|
|
545
|
+
pendingCall.reject("Failed to parse OAuth2 token response", e);
|
|
546
|
+
}
|
|
547
|
+
cleanupPending();
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
private void refreshWithToken(final PluginCall pluginCall, String providerId, OAuth2ProviderConfig config, String refreshToken) {
|
|
555
|
+
if (config.accessTokenEndpoint == null || config.accessTokenEndpoint.isEmpty()) {
|
|
556
|
+
pluginCall.reject("No accessTokenEndpoint configured for refresh");
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
FormBody body = new FormBody.Builder()
|
|
561
|
+
.add("grant_type", "refresh_token")
|
|
562
|
+
.add("refresh_token", refreshToken)
|
|
563
|
+
.add("client_id", config.appId)
|
|
564
|
+
.build();
|
|
565
|
+
|
|
566
|
+
Request request = new Request.Builder().url(config.accessTokenEndpoint).post(body).build();
|
|
567
|
+
|
|
568
|
+
httpClient
|
|
569
|
+
.newCall(request)
|
|
570
|
+
.enqueue(
|
|
571
|
+
new Callback() {
|
|
572
|
+
@Override
|
|
573
|
+
public void onFailure(Call call, IOException e) {
|
|
574
|
+
pluginCall.reject("OAuth2 refresh failed", e);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
@Override
|
|
578
|
+
public void onResponse(Call call, Response response) throws IOException {
|
|
579
|
+
if (!response.isSuccessful()) {
|
|
580
|
+
String errorBody = response.body() != null ? response.body().string() : "";
|
|
581
|
+
pluginCall.reject("OAuth2 refresh failed: " + errorBody);
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
String responseBody = response.body() != null ? response.body().string() : "";
|
|
585
|
+
try {
|
|
586
|
+
JSONObject tokenPayload = new JSONObject(responseBody);
|
|
587
|
+
handleTokenSuccess(providerId, config, tokenPayload, pluginCall);
|
|
588
|
+
} catch (JSONException e) {
|
|
589
|
+
pluginCall.reject("Failed to parse OAuth2 refresh response", e);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
private void handleTokenSuccess(String providerId, OAuth2ProviderConfig config, JSONObject tokenPayload) throws JSONException {
|
|
597
|
+
handleTokenSuccess(providerId, config, tokenPayload, pendingCall);
|
|
598
|
+
cleanupPending();
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
private void handleTokenSuccess(String providerId, OAuth2ProviderConfig config, JSONObject tokenPayload, PluginCall call)
|
|
602
|
+
throws JSONException {
|
|
603
|
+
if (call == null) {
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
final String accessToken = tokenPayload.getString("access_token");
|
|
608
|
+
final String tokenType = tokenPayload.optString("token_type", "bearer");
|
|
609
|
+
final int expiresIn = tokenPayload.optInt("expires_in", 3600);
|
|
610
|
+
final String refreshToken = tokenPayload.optString("refresh_token", null);
|
|
611
|
+
final String idToken = tokenPayload.optString("id_token", null);
|
|
612
|
+
final String scopeRaw = tokenPayload.optString("scope", "");
|
|
613
|
+
final List<String> scopes = scopeRaw.isEmpty() ? Collections.emptyList() : Arrays.asList(scopeRaw.split(" "));
|
|
614
|
+
|
|
615
|
+
final long expiresAt = System.currentTimeMillis() + (long) expiresIn * 1000L;
|
|
616
|
+
|
|
617
|
+
// Fetch resource data if configured
|
|
618
|
+
if (config.resourceUrl != null && !config.resourceUrl.isEmpty()) {
|
|
619
|
+
fetchResource(
|
|
620
|
+
config,
|
|
621
|
+
accessToken,
|
|
622
|
+
new ResourceCallback() {
|
|
623
|
+
@Override
|
|
624
|
+
public void onSuccess(JSONObject resourceData) {
|
|
625
|
+
completeLogin(
|
|
626
|
+
providerId,
|
|
627
|
+
accessToken,
|
|
628
|
+
tokenType,
|
|
629
|
+
expiresIn,
|
|
630
|
+
expiresAt,
|
|
631
|
+
refreshToken,
|
|
632
|
+
idToken,
|
|
633
|
+
scopes,
|
|
634
|
+
resourceData,
|
|
635
|
+
call
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
@Override
|
|
640
|
+
public void onError(String message) {
|
|
641
|
+
if (config.logsEnabled) {
|
|
642
|
+
Log.w(LOG_TAG, "Failed to fetch resource: " + message);
|
|
643
|
+
}
|
|
644
|
+
completeLogin(providerId, accessToken, tokenType, expiresIn, expiresAt, refreshToken, idToken, scopes, null, call);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
);
|
|
648
|
+
} else {
|
|
649
|
+
completeLogin(providerId, accessToken, tokenType, expiresIn, expiresAt, refreshToken, idToken, scopes, null, call);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
private void completeLogin(
|
|
654
|
+
String providerId,
|
|
655
|
+
String accessToken,
|
|
656
|
+
String tokenType,
|
|
657
|
+
int expiresIn,
|
|
658
|
+
long expiresAt,
|
|
659
|
+
String refreshToken,
|
|
660
|
+
String idToken,
|
|
661
|
+
List<String> scopes,
|
|
662
|
+
JSONObject resourceData
|
|
663
|
+
) {
|
|
664
|
+
completeLogin(providerId, accessToken, tokenType, expiresIn, expiresAt, refreshToken, idToken, scopes, resourceData, pendingCall);
|
|
665
|
+
cleanupPending();
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
private void completeLogin(
|
|
669
|
+
String providerId,
|
|
670
|
+
String accessToken,
|
|
671
|
+
String tokenType,
|
|
672
|
+
int expiresIn,
|
|
673
|
+
long expiresAt,
|
|
674
|
+
String refreshToken,
|
|
675
|
+
String idToken,
|
|
676
|
+
List<String> scopes,
|
|
677
|
+
JSONObject resourceData,
|
|
678
|
+
PluginCall call
|
|
679
|
+
) {
|
|
680
|
+
if (call == null) {
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
persistTokens(providerId, accessToken, refreshToken, idToken, tokenType, expiresAt, scopes);
|
|
685
|
+
|
|
686
|
+
JSObject accessTokenObject = new JSObject();
|
|
687
|
+
accessTokenObject.put("token", accessToken);
|
|
688
|
+
accessTokenObject.put("tokenType", tokenType);
|
|
689
|
+
accessTokenObject.put(
|
|
690
|
+
"expires",
|
|
691
|
+
new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX").format(new java.util.Date(expiresAt))
|
|
692
|
+
);
|
|
693
|
+
if (refreshToken != null) {
|
|
694
|
+
accessTokenObject.put("refreshToken", refreshToken);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
JSObject result = new JSObject();
|
|
698
|
+
result.put("providerId", providerId);
|
|
699
|
+
result.put("accessToken", accessTokenObject);
|
|
700
|
+
result.put("idToken", idToken != null ? idToken : JSONObject.NULL);
|
|
701
|
+
result.put("refreshToken", refreshToken != null ? refreshToken : JSONObject.NULL);
|
|
702
|
+
result.put("resourceData", resourceData != null ? resourceData : JSONObject.NULL);
|
|
703
|
+
result.put("scope", new JSArray(scopes));
|
|
704
|
+
result.put("tokenType", tokenType);
|
|
705
|
+
result.put("expiresIn", expiresIn);
|
|
706
|
+
|
|
707
|
+
JSObject response = new JSObject();
|
|
708
|
+
response.put("provider", "oauth2");
|
|
709
|
+
response.put("result", result);
|
|
710
|
+
call.resolve(response);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
private interface ResourceCallback {
|
|
714
|
+
void onSuccess(JSONObject resourceData);
|
|
715
|
+
void onError(String message);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
private void fetchResource(OAuth2ProviderConfig config, String accessToken, ResourceCallback callback) {
|
|
719
|
+
Request.Builder requestBuilder = new Request.Builder().url(config.resourceUrl).addHeader("Authorization", "Bearer " + accessToken);
|
|
720
|
+
|
|
721
|
+
if (config.additionalResourceHeaders != null) {
|
|
722
|
+
for (Map.Entry<String, String> entry : config.additionalResourceHeaders.entrySet()) {
|
|
723
|
+
requestBuilder.addHeader(entry.getKey(), entry.getValue());
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
httpClient
|
|
728
|
+
.newCall(requestBuilder.build())
|
|
729
|
+
.enqueue(
|
|
730
|
+
new Callback() {
|
|
731
|
+
@Override
|
|
732
|
+
public void onFailure(Call call, IOException e) {
|
|
733
|
+
callback.onError("Failed to fetch resource: " + e.getMessage());
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
@Override
|
|
737
|
+
public void onResponse(Call call, Response response) throws IOException {
|
|
738
|
+
if (!response.isSuccessful()) {
|
|
739
|
+
String errorBody = response.body() != null ? response.body().string() : "";
|
|
740
|
+
callback.onError("Failed to fetch resource: " + errorBody);
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
String responseBody = response.body() != null ? response.body().string() : "";
|
|
744
|
+
try {
|
|
745
|
+
JSONObject data = new JSONObject(responseBody);
|
|
746
|
+
callback.onSuccess(data);
|
|
747
|
+
} catch (JSONException e) {
|
|
748
|
+
callback.onError("Failed to parse resource response");
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
private void persistTokens(
|
|
756
|
+
String providerId,
|
|
757
|
+
String accessToken,
|
|
758
|
+
String refreshToken,
|
|
759
|
+
String idToken,
|
|
760
|
+
String tokenType,
|
|
761
|
+
long expiresAt,
|
|
762
|
+
List<String> scopes
|
|
763
|
+
) {
|
|
764
|
+
try {
|
|
765
|
+
JSONObject stored = new JSONObject();
|
|
766
|
+
stored.put("accessToken", accessToken);
|
|
767
|
+
stored.put("tokenType", tokenType);
|
|
768
|
+
stored.put("expiresAt", expiresAt);
|
|
769
|
+
stored.put("refreshToken", refreshToken);
|
|
770
|
+
stored.put("idToken", idToken);
|
|
771
|
+
stored.put("scope", new JSONArray(scopes));
|
|
772
|
+
SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
|
773
|
+
prefs.edit().putString(getTokenStorageKey(providerId), stored.toString()).apply();
|
|
774
|
+
} catch (JSONException e) {
|
|
775
|
+
Log.w(LOG_TAG, "Failed to persist OAuth2 tokens", e);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
private OAuth2StoredTokens loadStoredTokens(String providerId) {
|
|
780
|
+
SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
|
781
|
+
String raw = prefs.getString(getTokenStorageKey(providerId), null);
|
|
782
|
+
if (raw == null || raw.isEmpty()) {
|
|
783
|
+
return null;
|
|
784
|
+
}
|
|
785
|
+
try {
|
|
786
|
+
JSONObject object = new JSONObject(raw);
|
|
787
|
+
String accessToken = object.optString("accessToken", null);
|
|
788
|
+
if (accessToken == null || accessToken.isEmpty()) {
|
|
789
|
+
return null;
|
|
790
|
+
}
|
|
791
|
+
return new OAuth2StoredTokens(
|
|
792
|
+
accessToken,
|
|
793
|
+
object.optString("refreshToken", null),
|
|
794
|
+
object.optString("idToken", null),
|
|
795
|
+
object.optLong("expiresAt", 0L),
|
|
796
|
+
object.optString("tokenType", "bearer")
|
|
797
|
+
);
|
|
798
|
+
} catch (JSONException e) {
|
|
799
|
+
Log.w(LOG_TAG, "Failed to parse stored OAuth2 tokens", e);
|
|
800
|
+
return null;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
private void cleanupPending() {
|
|
805
|
+
pendingCall = null;
|
|
806
|
+
pendingState = null;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
private static Map<String, String> jsonObjectToMap(JSONObject json) throws JSONException {
|
|
810
|
+
Map<String, String> map = new HashMap<>();
|
|
811
|
+
Iterator<String> keys = json.keys();
|
|
812
|
+
while (keys.hasNext()) {
|
|
813
|
+
String key = keys.next();
|
|
814
|
+
map.put(key, json.getString(key));
|
|
815
|
+
}
|
|
816
|
+
return map;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
private static String generateCodeVerifier() {
|
|
820
|
+
SecureRandom secureRandom = new SecureRandom();
|
|
821
|
+
byte[] code = new byte[64];
|
|
822
|
+
secureRandom.nextBytes(code);
|
|
823
|
+
return Base64.encodeToString(code, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
private static String generateCodeChallenge(String verifier) throws NoSuchAlgorithmException {
|
|
827
|
+
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
|
828
|
+
byte[] hash = digest.digest(verifier.getBytes(StandardCharsets.US_ASCII));
|
|
829
|
+
return Base64.encodeToString(hash, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
private static class OAuth2StoredTokens {
|
|
833
|
+
|
|
834
|
+
final String accessToken;
|
|
835
|
+
final String refreshToken;
|
|
836
|
+
final String idToken;
|
|
837
|
+
final long expiresAt;
|
|
838
|
+
final String tokenType;
|
|
839
|
+
|
|
840
|
+
OAuth2StoredTokens(String accessToken, String refreshToken, String idToken, long expiresAt, String tokenType) {
|
|
841
|
+
this.accessToken = accessToken;
|
|
842
|
+
this.refreshToken = refreshToken;
|
|
843
|
+
this.idToken = idToken;
|
|
844
|
+
this.expiresAt = expiresAt;
|
|
845
|
+
this.tokenType = tokenType;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|