diff --git a/README.md b/README.md index acfaf7f7..384964a3 100644 --- a/README.md +++ b/README.md @@ -710,13 +710,14 @@ await SocialLogin.refresh({ | `additionalParameters` | Record | No | Extra params for authorization URL | | `additionalResourceHeaders` | Record | No | Extra headers for resource request | | `logoutUrl` | string | No | URL to open on logout | +| `androidUseCustomTabs` | boolean | No | Android-only: use Chrome Custom Tabs instead of WebView (default: false). Requires a deep-linkable `redirectUrl` | | `logsEnabled` | boolean | No | Enable debug logging (default: false) | ### Platform-Specific Notes **iOS**: Uses `ASWebAuthenticationSession` for secure authentication. -**Android**: Uses a WebView-based authentication flow. +**Android**: Uses an embedded WebView by default. Set `androidUseCustomTabs: true` to use Chrome Custom Tabs instead (RFC 8252 / system browser). Custom Tabs require a custom-scheme or App Link `redirectUrl` with a matching intent filter in your app's `AndroidManifest.xml`, and `android:launchMode="singleTask"` (or `singleTop`) on the main activity so redirects arrive via `onNewIntent`. **Web**: Opens a popup window for OAuth flow. @@ -1208,34 +1209,35 @@ And in the AndroidManifest.xml file: Configuration for a single OAuth2 provider instance -| Prop | Type | Description | Default | -| ------------------------------------------ | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | -| **`appId`** | string | The OAuth 2.0 client identifier (App ID / Client ID). Note: this configuration object is only used by the plugin's built-in `oauth2` provider (i.e. `SocialLogin.initialize({ oauth2: { ... } })`). It does not affect Google/Apple/Facebook/Twitter. | | -| **`clientId`** | string | Alias for `appId` to match common OAuth/OIDC naming (`clientId`). If both are provided, `appId` takes precedence. | | -| **`issuerUrl`** | string | OpenID Connect issuer URL (enables discovery via `/.well-known/openid-configuration`). When set, you may omit explicit endpoints like `authorizationBaseUrl` and `accessTokenEndpoint`. Notes: - Explicit endpoints (authorization/token/logout) take precedence over discovered values. - Discovery is supported for `oauth2` on Web, iOS, and Android. | | -| **`authorizationBaseUrl`** | string | The base URL of the authorization endpoint | | -| **`authorizationEndpoint`** | string | Alias for `authorizationBaseUrl` (to match common OAuth/OIDC naming). | | -| **`clientSecret`** | string | OAuth 2.0 client secret for token requests (e.g., when exchanging the code). This value is sent as `client_secret` in token/refresh requests when provided. | | -| **`accessTokenEndpoint`** | string | The URL to exchange the authorization code for tokens Required for authorization code flow | | -| **`tokenEndpoint`** | string | Alias for `accessTokenEndpoint` (to match common OAuth/OIDC naming). | | -| **`redirectUrl`** | string | Redirect URL that receives the OAuth callback | | -| **`resourceUrl`** | string | Optional URL to fetch user profile/resource data after authentication The access token will be sent as Bearer token in the Authorization header | | -| **`responseType`** | 'code' \| 'token' | The OAuth response type - 'code': Authorization Code flow (recommended, requires accessTokenEndpoint) - 'token': Implicit flow (less secure, tokens returned directly) | 'code' | -| **`pkceEnabled`** | boolean | Enable PKCE (Proof Key for Code Exchange) Strongly recommended for public clients (mobile/web apps) | true | -| **`scope`** | string \| string[] | Default scopes to request during authorization | | -| **`scopes`** | string[] | Alias for `scope` using common naming (`scopes`). If both are provided, `scope` takes precedence. | | -| **`additionalParameters`** | Record<string, string> | Additional parameters to include in the authorization request | | -| **`loginHint`** | string | Convenience option for OIDC `login_hint`. Equivalent to passing `additionalParameters.login_hint`. | | -| **`prompt`** | string | Convenience option for OAuth/OIDC `prompt`. Equivalent to passing `additionalParameters.prompt`. | | -| **`additionalTokenParameters`** | Record<string, string> | Additional parameters to include in token requests (code exchange / refresh). Useful for providers that require non-standard parameters. | | -| **`additionalResourceHeaders`** | Record<string, string> | Additional headers to include when fetching the resource URL | | -| **`logoutUrl`** | string | Custom logout URL for ending the session | | -| **`endSessionEndpoint`** | string | Alias for `logoutUrl` to match OIDC naming (`endSessionEndpoint`). | | -| **`postLogoutRedirectUrl`** | string | OIDC post logout redirect URL (sent as `post_logout_redirect_uri` when building the end-session URL). | | -| **`additionalLogoutParameters`** | Record<string, string> | Additional parameters to include in logout / end-session URL. | | -| **`iosPrefersEphemeralWebBrowserSession`** | boolean | iOS-only: Whether to prefer an ephemeral browser session for ASWebAuthenticationSession. Defaults to true to match existing behavior in this plugin. | | -| **`iosPrefersEphemeralSession`** | boolean | Alias for `iosPrefersEphemeralWebBrowserSession` (to match Capawesome OAuth naming). | | -| **`logsEnabled`** | boolean | Enable debug logging | false | +| Prop | Type | Description | Default | +| ------------------------------------------ | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | +| **`appId`** | string | The OAuth 2.0 client identifier (App ID / Client ID). Note: this configuration object is only used by the plugin's built-in `oauth2` provider (i.e. `SocialLogin.initialize({ oauth2: { ... } })`). It does not affect Google/Apple/Facebook/Twitter. | | +| **`clientId`** | string | Alias for `appId` to match common OAuth/OIDC naming (`clientId`). If both are provided, `appId` takes precedence. | | +| **`issuerUrl`** | string | OpenID Connect issuer URL (enables discovery via `/.well-known/openid-configuration`). When set, you may omit explicit endpoints like `authorizationBaseUrl` and `accessTokenEndpoint`. Notes: - Explicit endpoints (authorization/token/logout) take precedence over discovered values. - Discovery is supported for `oauth2` on Web, iOS, and Android. | | +| **`authorizationBaseUrl`** | string | The base URL of the authorization endpoint | | +| **`authorizationEndpoint`** | string | Alias for `authorizationBaseUrl` (to match common OAuth/OIDC naming). | | +| **`clientSecret`** | string | OAuth 2.0 client secret for token requests (e.g., when exchanging the code). This value is sent as `client_secret` in token/refresh requests when provided. | | +| **`accessTokenEndpoint`** | string | The URL to exchange the authorization code for tokens Required for authorization code flow | | +| **`tokenEndpoint`** | string | Alias for `accessTokenEndpoint` (to match common OAuth/OIDC naming). | | +| **`redirectUrl`** | string | Redirect URL that receives the OAuth callback | | +| **`resourceUrl`** | string | Optional URL to fetch user profile/resource data after authentication The access token will be sent as Bearer token in the Authorization header | | +| **`responseType`** | 'code' \| 'token' | The OAuth response type - 'code': Authorization Code flow (recommended, requires accessTokenEndpoint) - 'token': Implicit flow (less secure, tokens returned directly) | 'code' | +| **`pkceEnabled`** | boolean | Enable PKCE (Proof Key for Code Exchange) Strongly recommended for public clients (mobile/web apps) | true | +| **`scope`** | string \| string[] | Default scopes to request during authorization | | +| **`scopes`** | string[] | Alias for `scope` using common naming (`scopes`). If both are provided, `scope` takes precedence. | | +| **`additionalParameters`** | Record<string, string> | Additional parameters to include in the authorization request | | +| **`loginHint`** | string | Convenience option for OIDC `login_hint`. Equivalent to passing `additionalParameters.login_hint`. | | +| **`prompt`** | string | Convenience option for OAuth/OIDC `prompt`. Equivalent to passing `additionalParameters.prompt`. | | +| **`additionalTokenParameters`** | Record<string, string> | Additional parameters to include in token requests (code exchange / refresh). Useful for providers that require non-standard parameters. | | +| **`additionalResourceHeaders`** | Record<string, string> | Additional headers to include when fetching the resource URL | | +| **`logoutUrl`** | string | Custom logout URL for ending the session | | +| **`endSessionEndpoint`** | string | Alias for `logoutUrl` to match OIDC naming (`endSessionEndpoint`). | | +| **`postLogoutRedirectUrl`** | string | OIDC post logout redirect URL (sent as `post_logout_redirect_uri` when building the end-session URL). | | +| **`additionalLogoutParameters`** | Record<string, string> | Additional parameters to include in logout / end-session URL. | | +| **`iosPrefersEphemeralWebBrowserSession`** | boolean | iOS-only: Whether to prefer an ephemeral browser session for ASWebAuthenticationSession. Defaults to true to match existing behavior in this plugin. | | +| **`iosPrefersEphemeralSession`** | boolean | Alias for `iosPrefersEphemeralWebBrowserSession` (to match Capawesome OAuth naming). | | +| **`androidUseCustomTabs`** | boolean | Android-only: Use Chrome Custom Tabs (system browser) instead of an embedded WebView for the authorization request. Custom Tabs follow RFC 8252 (OAuth 2.0 for Native Apps) and fix issues with brokered IdPs (Microsoft Entra Conditional Access / Authenticator, passkeys/WebAuthn, Google `disallowed_useragent`, SSO cookie sharing, password managers). Requires a custom-scheme or App Link `redirectUrl` with a matching intent filter in your app's `AndroidManifest.xml` (same setup as `openSecureWindow()` / Apple on Android). Defaults to `false` to preserve the historical WebView flow. | false | +| **`logsEnabled`** | boolean | Enable debug logging | false | #### FacebookLoginResponse diff --git a/android/src/main/java/ee/forgr/capacitor/social/login/OAuth2Provider.java b/android/src/main/java/ee/forgr/capacitor/social/login/OAuth2Provider.java index 8e4a35e3..9800ad49 100644 --- a/android/src/main/java/ee/forgr/capacitor/social/login/OAuth2Provider.java +++ b/android/src/main/java/ee/forgr/capacitor/social/login/OAuth2Provider.java @@ -7,6 +7,7 @@ import android.net.Uri; import android.util.Base64; import android.util.Log; +import androidx.browser.customtabs.CustomTabsIntent; import com.getcapacitor.JSArray; import com.getcapacitor.JSObject; import com.getcapacitor.PluginCall; @@ -42,6 +43,12 @@ public class OAuth2Provider implements SocialProvider { private static final String USER_CANCELLED_CODE = "USER_CANCELLED"; private static final String PREFS_NAME = "CapgoOAuth2ProviderPrefs"; private static final String PREFS_KEY_PREFIX = "OAuth2Tokens_"; + private static final String CUSTOM_TABS_PENDING_IN_PROGRESS = "OAuth2CustomTabsInProgress"; + private static final String CUSTOM_TABS_PENDING_PROVIDER_ID = "OAuth2CustomTabsProviderId"; + private static final String CUSTOM_TABS_PENDING_STATE = "OAuth2CustomTabsState"; + private static final String CUSTOM_TABS_PENDING_CODE_VERIFIER = "OAuth2CustomTabsCodeVerifier"; + private static final String CUSTOM_TABS_PENDING_REDIRECT = "OAuth2CustomTabsRedirect"; + private static final String CUSTOM_TABS_PENDING_SCOPE = "OAuth2CustomTabsScope"; public interface ActivityLauncher { void launchForResult(Intent intent, int requestCode); @@ -56,6 +63,7 @@ public interface ActivityLauncher { private PluginCall pendingCall; private OAuth2PendingState pendingState; + private boolean pendingUseCustomTabs; private ActivityLauncher activityLauncher; public void setActivityLauncher(ActivityLauncher launcher) { @@ -90,6 +98,7 @@ private static class OAuth2ProviderConfig { final String logoutUrl; final String postLogoutRedirectUrl; final Map additionalLogoutParameters; + final boolean androidUseCustomTabs; final boolean logsEnabled; OAuth2ProviderConfig( @@ -111,6 +120,7 @@ private static class OAuth2ProviderConfig { String logoutUrl, String postLogoutRedirectUrl, Map additionalLogoutParameters, + boolean androidUseCustomTabs, boolean logsEnabled ) { this.appId = appId; @@ -131,6 +141,7 @@ private static class OAuth2ProviderConfig { this.logoutUrl = logoutUrl; this.postLogoutRedirectUrl = postLogoutRedirectUrl; this.additionalLogoutParameters = additionalLogoutParameters; + this.androidUseCustomTabs = androidUseCustomTabs; this.logsEnabled = logsEnabled; } } @@ -241,6 +252,7 @@ public void onResponse(Call call, Response response) throws IOException { (config.logoutUrl != null && !config.logoutUrl.isEmpty()) ? config.logoutUrl : endSession, config.postLogoutRedirectUrl, config.additionalLogoutParameters, + config.androidUseCustomTabs, config.logsEnabled ); providers.put(providerId, resolved); @@ -334,6 +346,7 @@ public List initializeProviders(JSONObject configs) throws JSONException config.has("logoutUrl") ? config.optString("logoutUrl", null) : config.optString("endSessionEndpoint", null), config.optString("postLogoutRedirectUrl", null), additionalLogoutParameters, + config.optBoolean("androidUseCustomTabs", false), config.optBoolean("logsEnabled", false) ); @@ -486,22 +499,24 @@ public void onSuccess(OAuth2ProviderConfig resolved) { Log.d(LOG_TAG, "Opening authorization URL: " + builder.build().toString()); } - Intent intent = new Intent(activity, OAuth2LoginActivity.class); - intent.putExtra(OAuth2LoginActivity.EXTRA_AUTH_URL, builder.build().toString()); - intent.putExtra(OAuth2LoginActivity.EXTRA_REDIRECT_URL, finalRedirect); - - activity.runOnUiThread(() -> { - if (activityLauncher != null) { - activityLauncher.launchForResult(intent, REQUEST_CODE); - } else { - Log.w( - LOG_TAG, - "activityLauncher is null — falling back to raw startActivityForResult. " + - "Activity result routing through Capacitor will not work." - ); - activity.startActivityForResult(intent, REQUEST_CODE); + pendingUseCustomTabs = resolved.androidUseCustomTabs; + if (resolved.androidUseCustomTabs) { + if (resolved.logsEnabled) { + Log.d(LOG_TAG, "Using Custom Tabs for OAuth2 authorization"); } - }); + final String authUrl = builder.build().toString(); + activity.runOnUiThread(() -> { + if (!launchCustomTabs(authUrl)) { + // No Custom Tabs browser — fall back to embedded WebView + pendingUseCustomTabs = false; + clearPersistedCustomTabsState(); + launchWebViewActivity(authUrl, finalRedirect); + } + }); + return; + } + + activity.runOnUiThread(() -> launchWebViewActivity(builder.build().toString(), finalRedirect)); } @Override @@ -684,45 +699,257 @@ public boolean handleActivityResult(int requestCode, int resultCode, Intent data return true; } - String returnedState = data.getStringExtra("state"); + return processCallbackData(data); + } + + /** + * Handle OAuth redirect delivered via deep link / App Link after a Custom Tabs auth session. + * @return true if this URI was consumed as an OAuth2 Custom Tabs callback + */ + public boolean handleRedirectUri(Uri uri) { + if (uri == null) { + return false; + } + + // After process death, restore PKCE/state so we can still complete the exchange. + if (pendingState == null) { + restorePersistedCustomTabsState(); + } + + if (pendingState == null || !pendingUseCustomTabs) { + return false; + } + + if (!matchesRedirectUri(pendingState.redirectUri, uri)) { + return false; + } + + Intent data = callbackUriToIntent(uri); + + // Prevent handleOnResume from treating the return as cancellation while token exchange runs + pendingUseCustomTabs = false; + clearPersistedCustomTabsState(); + return processCallbackData(data); + } + + /** + * Called when the host activity resumes after Custom Tabs without receiving a redirect. + * Treat as user cancellation (same pattern as {@code openSecureWindow}). + */ + public void handleUserReturnedWithoutCallback() { + if (!pendingUseCustomTabs) { + return; + } + + // Prefer consuming a redirect already attached to the activity intent (cold start / recreate) + Intent current = activity != null ? activity.getIntent() : null; + if (current != null && Intent.ACTION_VIEW.equals(current.getAction()) && current.getData() != null) { + if (handleRedirectUri(current.getData())) { + return; + } + } + + if (pendingCall == null) { + clearPersistedCustomTabsState(); + pendingUseCustomTabs = false; + pendingState = null; + return; + } + pendingCall.reject("User cancelled", USER_CANCELLED_CODE); + cleanupPending(); + } + + /** + * Restore any persisted Custom Tabs login state (e.g. after process death) and return whether + * a Custom Tabs session was in progress. + */ + public boolean restorePersistedCustomTabsState() { + SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + String providerId = prefs.getString(CUSTOM_TABS_PENDING_PROVIDER_ID, null); + String state = prefs.getString(CUSTOM_TABS_PENDING_STATE, null); + String codeVerifier = prefs.getString(CUSTOM_TABS_PENDING_CODE_VERIFIER, null); + String redirectUri = prefs.getString(CUSTOM_TABS_PENDING_REDIRECT, null); + String scope = prefs.getString(CUSTOM_TABS_PENDING_SCOPE, null); + boolean inProgress = prefs.getBoolean(CUSTOM_TABS_PENDING_IN_PROGRESS, false); + if (!inProgress || providerId == null || state == null || redirectUri == null) { + return false; + } + pendingState = new OAuth2PendingState(providerId, state, codeVerifier, redirectUri, scope != null ? scope : ""); + pendingUseCustomTabs = true; + return true; + } + + private boolean processCallbackData(Intent data) { + if (pendingState == null) { + return true; + } + + String returnedState = data != null ? data.getStringExtra("state") : null; if (returnedState == null || !returnedState.equals(pendingState.state)) { - pendingCall.reject("State mismatch during OAuth2 login"); + if (pendingCall != null) { + pendingCall.reject("State mismatch during OAuth2 login"); + } cleanupPending(); return true; } - String error = data.getStringExtra("error"); + String error = data != null ? data.getStringExtra("error") : null; if (error != null) { String description = data.getStringExtra("error_description"); String message = description != null ? description : error; - if (isUserDeniedRedirect(error, description)) { - pendingCall.reject(message, USER_CANCELLED_CODE); - } else { - pendingCall.reject(message); + if (pendingCall != null) { + if (isUserDeniedRedirect(error, description)) { + pendingCall.reject(message, USER_CANCELLED_CODE); + } else { + pendingCall.reject(message); + } } cleanupPending(); return true; } // Check for code (authorization code flow) - String code = data.getStringExtra("code"); + String code = data != null ? data.getStringExtra("code") : null; if (code != null) { + if (pendingCall == null) { + // Process death: original JS call is gone — still exchange & store tokens. + Log.w(LOG_TAG, "Completing OAuth2 Custom Tabs callback without pending PluginCall (process death?)"); + } exchangeAuthorizationCode(code); return true; } // Check for access_token (implicit flow) - String accessToken = data.getStringExtra("access_token"); + String accessToken = data != null ? data.getStringExtra("access_token") : null; if (accessToken != null) { + if (pendingCall == null) { + Log.w(LOG_TAG, "Completing OAuth2 Custom Tabs implicit callback without pending PluginCall (process death?)"); + } handleImplicitFlowResponse(data); return true; } - pendingCall.reject("No authorization code or access token in callback"); + if (pendingCall != null) { + pendingCall.reject("No authorization code or access token in callback"); + } cleanupPending(); return true; } + private void launchWebViewActivity(String authUrl, String redirectUrl) { + Intent intent = new Intent(activity, OAuth2LoginActivity.class); + intent.putExtra(OAuth2LoginActivity.EXTRA_AUTH_URL, authUrl); + intent.putExtra(OAuth2LoginActivity.EXTRA_REDIRECT_URL, redirectUrl); + + if (activityLauncher != null) { + activityLauncher.launchForResult(intent, REQUEST_CODE); + } else { + Log.w( + LOG_TAG, + "activityLauncher is null — falling back to raw startActivityForResult. " + + "Activity result routing through Capacitor will not work." + ); + activity.startActivityForResult(intent, REQUEST_CODE); + } + } + + /** + * @return true if Custom Tabs launched successfully + */ + private boolean launchCustomTabs(String url) { + try { + persistCustomTabsState(); + CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder(); + CustomTabsIntent customTabsIntent = builder.build(); + customTabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY); + customTabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + customTabsIntent.launchUrl(activity, Uri.parse(url)); + return true; + } catch (android.content.ActivityNotFoundException e) { + Log.w(LOG_TAG, "Custom Tabs unavailable, falling back to WebView", e); + clearPersistedCustomTabsState(); + return false; + } + } + + private void persistCustomTabsState() { + if (pendingState == null) { + return; + } + context + .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putBoolean(CUSTOM_TABS_PENDING_IN_PROGRESS, true) + .putString(CUSTOM_TABS_PENDING_PROVIDER_ID, pendingState.providerId) + .putString(CUSTOM_TABS_PENDING_STATE, pendingState.state) + .putString(CUSTOM_TABS_PENDING_CODE_VERIFIER, pendingState.codeVerifier) + .putString(CUSTOM_TABS_PENDING_REDIRECT, pendingState.redirectUri) + .putString(CUSTOM_TABS_PENDING_SCOPE, pendingState.scope) + .apply(); + } + + private void clearPersistedCustomTabsState() { + context + .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .remove(CUSTOM_TABS_PENDING_IN_PROGRESS) + .remove(CUSTOM_TABS_PENDING_PROVIDER_ID) + .remove(CUSTOM_TABS_PENDING_STATE) + .remove(CUSTOM_TABS_PENDING_CODE_VERIFIER) + .remove(CUSTOM_TABS_PENDING_REDIRECT) + .remove(CUSTOM_TABS_PENDING_SCOPE) + .apply(); + } + + static boolean matchesRedirectUri(String expectedRedirect, Uri actual) { + if (expectedRedirect == null || expectedRedirect.isEmpty() || actual == null) { + return false; + } + Uri expected = Uri.parse(expectedRedirect); + if (expected.getScheme() == null || actual.getScheme() == null) { + return false; + } + if (!expected.getScheme().equalsIgnoreCase(actual.getScheme())) { + return false; + } + String expectedAuthority = expected.getAuthority(); + String actualAuthority = actual.getAuthority(); + if (expectedAuthority == null ? actualAuthority != null : !expectedAuthority.equalsIgnoreCase(actualAuthority)) { + return false; + } + String expectedPath = normalizePath(expected.getPath()); + String actualPath = normalizePath(actual.getPath()); + return expectedPath.equals(actualPath); + } + + private static String normalizePath(String path) { + if (path == null || path.isEmpty()) { + return ""; + } + if (path.length() > 1 && path.endsWith("/")) { + return path.substring(0, path.length() - 1); + } + return path; + } + + static Intent callbackUriToIntent(Uri uri) { + Intent data = new Intent(); + data.putExtra("code", uri.getQueryParameter("code")); + data.putExtra("state", uri.getQueryParameter("state")); + data.putExtra("error", uri.getQueryParameter("error")); + data.putExtra("error_description", uri.getQueryParameter("error_description")); + + // Use encoded fragment so values containing encoded '&' (%26) are not split incorrectly + String encodedFragment = uri.getEncodedFragment(); + if (encodedFragment != null && !encodedFragment.isEmpty()) { + Uri fragmentUri = Uri.parse("https://callback.invalid/?" + encodedFragment); + for (String key : fragmentUri.getQueryParameterNames()) { + data.putExtra(key, fragmentUri.getQueryParameter(key)); + } + } + return data; + } + private boolean isUserDeniedRedirect(String error, String description) { if ("access_denied".equalsIgnoreCase(error)) { return true; @@ -795,7 +1022,9 @@ private void exchangeAuthorizationCode(String code) { OAuth2ProviderConfig config = getProvider(providerId); if (config == null) { - pendingCall.reject("OAuth2 provider '" + providerId + "' not found"); + if (pendingCall != null) { + pendingCall.reject("OAuth2 provider '" + providerId + "' not found"); + } cleanupPending(); return; } @@ -809,7 +1038,9 @@ private void exchangeAuthorizationCode(String code) { @Override public void onSuccess(OAuth2ProviderConfig resolved) { if (resolved.accessTokenEndpoint == null || resolved.accessTokenEndpoint.isEmpty()) { - pendingCall.reject("No accessTokenEndpoint configured for code exchange"); + if (pendingCall != null) { + pendingCall.reject("No accessTokenEndpoint configured for code exchange"); + } cleanupPending(); return; } @@ -818,7 +1049,9 @@ public void onSuccess(OAuth2ProviderConfig resolved) { @Override public void onError(String message) { - pendingCall.reject(message); + if (pendingCall != null) { + pendingCall.reject(message); + } cleanupPending(); } } @@ -830,7 +1063,7 @@ public void onError(String message) { } private void exchangeAuthorizationCodeWithConfig(String code, OAuth2ProviderConfig config) { - if (pendingState == null || pendingCall == null) { + if (pendingState == null) { cleanupPending(); return; } @@ -1001,10 +1234,6 @@ private void handleTokenSuccess( String fallbackRefreshToken, boolean wrapResponse ) throws JSONException { - if (call == null) { - return; - } - final String accessToken = tokenPayload.getString("access_token"); final String tokenType = tokenPayload.optString("token_type", "bearer"); final int expiresIn = tokenPayload.optInt("expires_in", 3600); @@ -1117,12 +1346,12 @@ private void completeLogin( PluginCall call, boolean wrapResponse ) { + persistTokens(providerId, accessToken, refreshToken, idToken, tokenType, expiresAt, scopes); + if (call == null) { return; } - persistTokens(providerId, accessToken, refreshToken, idToken, tokenType, expiresAt, scopes); - JSObject accessTokenObject = new JSObject(); accessTokenObject.put("token", accessToken); accessTokenObject.put("tokenType", tokenType); @@ -1248,6 +1477,8 @@ private OAuth2StoredTokens loadStoredTokens(String providerId) { private void cleanupPending() { pendingCall = null; pendingState = null; + pendingUseCustomTabs = false; + clearPersistedCustomTabsState(); } private static Map jsonObjectToMap(JSONObject json) throws JSONException { diff --git a/android/src/main/java/ee/forgr/capacitor/social/login/SocialLoginPlugin.java b/android/src/main/java/ee/forgr/capacitor/social/login/SocialLoginPlugin.java index 3cef1931..22cee8a3 100644 --- a/android/src/main/java/ee/forgr/capacitor/social/login/SocialLoginPlugin.java +++ b/android/src/main/java/ee/forgr/capacitor/social/login/SocialLoginPlugin.java @@ -31,6 +31,7 @@ public class SocialLoginPlugin extends Plugin { private PluginCall openSecureWindowSavedCall; private String openSecureWindowRedirectUri; + private Uri pendingOAuth2RedirectUri; @PluginMethod public void initialize(PluginCall call) { @@ -183,6 +184,19 @@ public void initialize(PluginCall call) { Log.e(LOG_TAG, "OAuth2 activityLauncher fired but pendingCall is null, cannot route result"); } }); + // Replay a redirect that arrived before initialize (cold start / process death) + boolean handledRedirect = false; + if (pendingOAuth2RedirectUri != null) { + Uri buffered = pendingOAuth2RedirectUri; + pendingOAuth2RedirectUri = null; + handledRedirect = oauth2Provider.handleRedirectUri(buffered); + } + // If handleOnResume ran before initialize, persisted Custom Tabs state may still be + // present with no buffered redirect — restore then cancel/clear the orphaned session + // (or complete via activity.getIntent() if the redirect is attached there). + if (!handledRedirect && oauth2Provider.restorePersistedCustomTabsState()) { + oauth2Provider.handleUserReturnedWithoutCallback(); + } } catch (JSONException e) { call.reject("Failed to initialize OAuth2 provider: " + e.getMessage()); return; @@ -597,6 +611,11 @@ private void launchCustomTab(String url) { protected void handleOnResume() { super.handleOnResume(); + SocialProvider oauth2Provider = socialProviderHashMap.get("oauth2"); + if (oauth2Provider instanceof OAuth2Provider) { + ((OAuth2Provider) oauth2Provider).handleUserReturnedWithoutCallback(); + } + // If we have a saved call and user returned without callback, reject if (openSecureWindowSavedCall != null) { openSecureWindowSavedCall.reject("OAuth cancelled or no callback received"); @@ -617,6 +636,16 @@ protected void handleOnNewIntent(Intent intent) { return; } + SocialProvider oauth2Provider = socialProviderHashMap.get("oauth2"); + if (oauth2Provider instanceof OAuth2Provider) { + if (((OAuth2Provider) oauth2Provider).handleRedirectUri(uri)) { + return; + } + } else { + // Buffer until SocialLogin.initialize() registers the oauth2 provider + pendingOAuth2RedirectUri = uri; + } + if (openSecureWindowRedirectUri == null) { return; } diff --git a/docs/setup_keycloak.md b/docs/setup_keycloak.md index 66c76f6f..fc772ac1 100644 --- a/docs/setup_keycloak.md +++ b/docs/setup_keycloak.md @@ -30,6 +30,8 @@ await SocialLogin.initialize({ redirectUrl: 'myapp://oauth/keycloak', scope: 'openid profile email', pkceEnabled: true, + // Recommended on Android when brokering to Entra/Google or using passkeys + androidUseCustomTabs: true, resourceUrl: `${keycloakIssuer}/protocol/openid-connect/userinfo`, }, }, @@ -47,6 +49,13 @@ console.log(result.result.accessToken?.token); console.log(result.result.resourceData); ``` +### Android Custom Tabs notes + +When `androidUseCustomTabs: true`: + +1. Add an intent filter for your redirect scheme on the main activity (same pattern as Apple Sign-In / `openSecureWindow`). +2. Use `android:launchMode="singleTask"` (or `singleTop`) on the main activity so the OAuth redirect is delivered via `onNewIntent` instead of recreating the activity. + For refresh tokens, request `offline_access` only if your Keycloak realm and client allow refresh tokens for this app: ```typescript diff --git a/src/definitions.ts b/src/definitions.ts index 208c70e8..36a2d5b3 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -141,6 +141,23 @@ export interface OAuth2ProviderConfig { * Alias for `iosPrefersEphemeralWebBrowserSession` (to match Capawesome OAuth naming). */ iosPrefersEphemeralSession?: boolean; + /** + * Android-only: Use Chrome Custom Tabs (system browser) instead of an embedded WebView + * for the authorization request. + * + * Custom Tabs follow RFC 8252 (OAuth 2.0 for Native Apps) and fix issues with brokered + * IdPs (Microsoft Entra Conditional Access / Authenticator, passkeys/WebAuthn, Google + * `disallowed_useragent`, SSO cookie sharing, password managers). + * + * Requires a custom-scheme or App Link `redirectUrl` with a matching intent filter in + * your app's `AndroidManifest.xml` (same setup as `openSecureWindow()` / Apple on Android). + * + * Defaults to `false` to preserve the historical WebView flow. + * + * @default false + * @example true + */ + androidUseCustomTabs?: boolean; /** * Enable debug logging * @default false