diff --git a/example/capacitor/package-lock.json b/example/capacitor/package-lock.json index 612d3961..e7472450 100644 --- a/example/capacitor/package-lock.json +++ b/example/capacitor/package-lock.json @@ -52,10 +52,10 @@ }, "../../packages/authgear-capacitor": { "name": "@authgear/capacitor", - "version": "2.3.2", + "version": "2.4.1", "license": "Apache-2.0", "devDependencies": { - "@authgear/core": "2.3.2", + "@authgear/core": "2.4.1", "@capacitor/android": "^5.0.0", "@capacitor/core": "^5.0.0", "@capacitor/ios": "^5.0.0" @@ -66,10 +66,10 @@ }, "../../packages/authgear-web": { "name": "@authgear/web", - "version": "2.3.2", + "version": "2.4.1", "license": "Apache-2.0", "devDependencies": { - "@authgear/core": "2.3.2", + "@authgear/core": "2.4.1", "core-js-pure": "3.22.7" } }, diff --git a/example/capacitor/src/pages/Home.tsx b/example/capacitor/src/pages/Home.tsx index a507694e..4b25dd1a 100644 --- a/example/capacitor/src/pages/Home.tsx +++ b/example/capacitor/src/pages/Home.tsx @@ -45,14 +45,17 @@ import authgearCapacitor, { BiometricAccessConstraintIOS, BiometricLAPolicy, BiometricAccessConstraintAndroid, + WebKitWebViewUIImplementation, } from "@authgear/capacitor"; import { readClientID, readEndpoint, readIsSSOEnabled, + readUseWebKitWebView, writeClientID, writeEndpoint, writeIsSSOEnabled, + writeUseWebKitWebView, } from "../storage"; import "./Home.css"; @@ -103,6 +106,9 @@ function AuthgearDemo() { const [isSSOEnabled, setIsSSOEnabled] = useState(() => { return readIsSSOEnabled(); }); + const [useWebKitWebView, setUseWebKitWebView] = useState(() => { + return readUseWebKitWebView(); + }); const [biometricEnabled, setBiometricEnabled] = useState(false); const [sessionState, setSessionState] = useState(() => { @@ -188,6 +194,7 @@ function AuthgearDemo() { writeClientID(clientID); writeEndpoint(endpoint); writeIsSSOEnabled(isSSOEnabled); + writeUseWebKitWebView(useWebKitWebView); if (isPlatformWeb()) { await authgearWeb.configure({ @@ -203,6 +210,13 @@ function AuthgearDemo() { tokenStorage: useTransientTokenStorage ? new TransientTokenStorage() : new PersistentTokenStorage(), + uiImplementation: useWebKitWebView + ? new WebKitWebViewUIImplementation({ + ios: { + modalPresentationStyle: "fullScreen", + }, + }) + : undefined, isSSOEnabled, }); } @@ -216,6 +230,7 @@ function AuthgearDemo() { clientID, endpoint, isSSOEnabled, + useWebKitWebView, useTransientTokenStorage, postConfigure, showError, @@ -494,6 +509,13 @@ function AuthgearDemo() { [] ); + const onChangeUseWebKitWebView = useCallback( + (e: IonToggleCustomEvent>) => { + setUseWebKitWebView(e.detail.checked); + }, + [] + ); + const onClickConfigure = useCallback( (e: MouseEvent) => { e.preventDefault(); @@ -659,6 +681,13 @@ function AuthgearDemo() { > Is SSO Enabled + + Use WebKit WebView +
Session State {sessionState} diff --git a/example/capacitor/src/storage.ts b/example/capacitor/src/storage.ts index 3bbf8f99..902ca1d0 100644 --- a/example/capacitor/src/storage.ts +++ b/example/capacitor/src/storage.ts @@ -16,6 +16,14 @@ export function readIsSSOEnabled(): boolean { return false; } +export function readUseWebKitWebView(): boolean { + const str = window.localStorage.getItem("authgear.useWebKitWebView"); + if (str === "true") { + return true; + } + return false; +} + export function writeClientID(clientID: string) { window.localStorage.setItem("authgear.clientID", clientID); } @@ -27,3 +35,10 @@ export function writeEndpoint(endpoint: string) { export function writeIsSSOEnabled(isSSOEnabled: boolean) { window.localStorage.setItem("authgear.isSSOEnabled", String(isSSOEnabled)); } + +export function writeUseWebKitWebView(useWebKitWebView: boolean) { + window.localStorage.setItem( + "authgear.useWebKitWebView", + String(useWebKitWebView) + ); +} diff --git a/example/reactnative/src/screens/MainScreen.tsx b/example/reactnative/src/screens/MainScreen.tsx index 51d98285..e6075528 100644 --- a/example/reactnative/src/screens/MainScreen.tsx +++ b/example/reactnative/src/screens/MainScreen.tsx @@ -32,7 +32,7 @@ import authgear, { BiometricLAPolicy, BiometricAccessConstraintAndroid, SessionState, - PlatformWebView, + WebKitWebViewUIImplementation, } from '@authgear/react-native'; import RadioGroup, {RadioGroupItemProps} from '../RadioGroup'; @@ -362,8 +362,8 @@ const HomeScreen: React.FC = () => { tokenStorage: useTransientTokenStorage ? new TransientTokenStorage() : new PersistentTokenStorage(), - webView: useWebKitWebView - ? new PlatformWebView({ + uiImplementation: useWebKitWebView + ? new WebKitWebViewUIImplementation({ ios: { modalPresentationStyle: 'fullScreen', }, diff --git a/package-lock.json b/package-lock.json index 5593b001..87798790 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14557,10 +14557,10 @@ }, "packages/authgear-capacitor": { "name": "@authgear/capacitor", - "version": "2.3.2", + "version": "2.4.1", "license": "Apache-2.0", "devDependencies": { - "@authgear/core": "2.3.2", + "@authgear/core": "2.4.1", "@capacitor/android": "^5.0.0", "@capacitor/core": "^5.0.0", "@capacitor/ios": "^5.0.0" @@ -14571,7 +14571,7 @@ }, "packages/authgear-core": { "name": "@authgear/core", - "version": "2.3.2", + "version": "2.4.1", "license": "Apache-2.0", "devDependencies": { "base64-arraybuffer": "1.0.2", @@ -14581,10 +14581,10 @@ }, "packages/authgear-react-native": { "name": "@authgear/react-native", - "version": "2.3.2", + "version": "2.4.1", "license": "Apache-2.0", "devDependencies": { - "@authgear/core": "2.3.2", + "@authgear/core": "2.4.1", "@types/react-native": "0.69.1", "core-js-pure": "3.22.7" }, @@ -14594,10 +14594,10 @@ }, "packages/authgear-web": { "name": "@authgear/web", - "version": "2.3.2", + "version": "2.4.1", "license": "Apache-2.0", "devDependencies": { - "@authgear/core": "2.3.2", + "@authgear/core": "2.4.1", "core-js-pure": "3.22.7" } } @@ -14613,7 +14613,7 @@ "@authgear/capacitor": { "version": "file:packages/authgear-capacitor", "requires": { - "@authgear/core": "2.3.2", + "@authgear/core": "2.4.1", "@capacitor/android": "^5.0.0", "@capacitor/core": "^5.0.0", "@capacitor/ios": "^5.0.0" @@ -14630,7 +14630,7 @@ "@authgear/react-native": { "version": "file:packages/authgear-react-native", "requires": { - "@authgear/core": "2.3.2", + "@authgear/core": "2.4.1", "@types/react-native": "0.69.1", "core-js-pure": "3.22.7" } @@ -14638,7 +14638,7 @@ "@authgear/web": { "version": "file:packages/authgear-web", "requires": { - "@authgear/core": "2.3.2", + "@authgear/core": "2.4.1", "core-js-pure": "3.22.7" } }, diff --git a/packages/authgear-capacitor/android/src/main/AndroidManifest.xml b/packages/authgear-capacitor/android/src/main/AndroidManifest.xml index 64c8d2f3..5e3c9926 100644 --- a/packages/authgear-capacitor/android/src/main/AndroidManifest.xml +++ b/packages/authgear-capacitor/android/src/main/AndroidManifest.xml @@ -16,6 +16,11 @@ android:launchMode="singleTask" android:theme="@style/AuthgearTheme" android:configChanges="orientation|screenSize"/> + diff --git a/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/AuthgearPlugin.java b/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/AuthgearPlugin.java index fc3a12ee..dd695d3d 100644 --- a/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/AuthgearPlugin.java +++ b/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/AuthgearPlugin.java @@ -3,6 +3,7 @@ import android.app.Activity; import android.content.Context; import android.content.Intent; +import android.graphics.Color; import android.net.Uri; import androidx.activity.result.ActivityResult; @@ -154,6 +155,37 @@ private void handleOpenAuthorizeURL(PluginCall call, ActivityResult activityResu } } + @PluginMethod + public void openAuthorizeURLWithWebView(PluginCall call) { + Uri url = Uri.parse(call.getString("url")); + Uri redirectURI = Uri.parse(call.getString("redirectURI")); + Integer actionBarBackgroundColor = this.readColorInt(call, "actionBarBackgroundColor"); + Integer actionBarButtonTintColor = this.readColorInt(call, "actionBarButtonTintColor"); + WebKitWebViewActivity.Options webViewOptions = new WebKitWebViewActivity.Options(); + webViewOptions.url = url; + webViewOptions.redirectURI = redirectURI; + webViewOptions.actionBarBackgroundColor = actionBarBackgroundColor; + webViewOptions.actionBarButtonTintColor = actionBarButtonTintColor; + + Context ctx = this.getContext(); + Intent intent = WebKitWebViewActivity.createIntent(ctx, webViewOptions); + this.startActivityForResult(call, intent, "handleOpenAuthorizeURLWithWebView"); + } + + @ActivityCallback + private void handleOpenAuthorizeURLWithWebView(PluginCall call, ActivityResult activityResult) { + int resultCode= activityResult.getResultCode(); + if (resultCode == Activity.RESULT_CANCELED) { + this.rejectWithCancel(call); + } + if (resultCode == Activity.RESULT_OK) { + String redirectURI = activityResult.getData().getData().toString(); + JSObject ret = new JSObject(); + ret.put("redirectURI", redirectURI); + call.resolve(ret); + } + } + @PluginMethod public void openURL(PluginCall call) { String urlString = call.getString("url"); @@ -398,6 +430,19 @@ private String errorCodeToString(int errorCode) { } } + private Integer readColorInt(PluginCall call, String key) { + if (call.hasOption(key)) { + String s = call.getString(key); + long l = Long.parseLong(s, 16); + int a = (int) ((l >> 24) & 0xff); + int r = (int) ((l >> 16) & 0xff); + int g = (int) ((l >> 8) &0xff); + int b = (int) (l & 0xff); + return Color.argb(a, r, g, b); + } + return null; + } + private void reject(PluginCall call, Exception e) { call.reject(e.getMessage(), e.getClass().getName(), e); } diff --git a/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/WebKitWebViewActivity.java b/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/WebKitWebViewActivity.java new file mode 100644 index 00000000..c631ae54 --- /dev/null +++ b/packages/authgear-capacitor/android/src/main/java/com/authgear/capacitor/WebKitWebViewActivity.java @@ -0,0 +1,277 @@ +package com.authgear.capacitor; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuItem; +import android.webkit.ValueCallback; +import android.webkit.WebChromeClient; +import android.webkit.WebResourceRequest; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; + +import androidx.annotation.ColorInt; +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.res.ResourcesCompat; +import androidx.core.graphics.drawable.DrawableCompat; + +public class WebKitWebViewActivity extends AppCompatActivity { + + private static final String KEY_OPTIONS = "KEY_OPTIONS"; + private static final int MENU_ID_CANCEL = 1; + private static final int TAG_FILE_CHOOSER = 1; + + private WebView mWebView; + private Uri result; + private StartActivityHandles> handles = new StartActivityHandles<>(); + + public static class Options { + public Uri url; + public Uri redirectURI; + + public Integer actionBarBackgroundColor; + public Integer actionBarButtonTintColor; + + public Options() {} + + private Options(Bundle bundle) { + this.url = bundle.getParcelable("url"); + this.redirectURI = bundle.getParcelable("redirectURI"); + if (bundle.containsKey("actionBarBackgroundColor")) { + this.actionBarBackgroundColor = bundle.getInt("actionBarBackgroundColor"); + } + if (bundle.containsKey("actionBarButtonTintColor")) { + this.actionBarButtonTintColor = bundle.getInt("actionBarButtonTintColor"); + } + } + + public Bundle toBundle() { + Bundle bundle = new Bundle(); + bundle.putParcelable("url", this.url); + bundle.putParcelable("redirectURI", this.redirectURI); + if (this.actionBarBackgroundColor != null) { + bundle.putInt("actionBarBackgroundColor", this.actionBarBackgroundColor); + } + if (this.actionBarButtonTintColor != null) { + bundle.putInt("actionBarButtonTintColor", this.actionBarButtonTintColor); + } + return bundle; + } + } + + private static class MyWebViewClient extends WebViewClient { + + private WebKitWebViewActivity activity; + + private MyWebViewClient(WebKitWebViewActivity activity) { + this.activity = activity; + } + + @TargetApi(Build.VERSION_CODES.N) + @Override + public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { + Uri uri = request.getUrl(); + if (this.shouldOverrideUrlLoading(uri)) { + return true; + } + return super.shouldOverrideUrlLoading(view, request); + } + + @SuppressWarnings("deprecation") + @Override + public boolean shouldOverrideUrlLoading(WebView view, String url) { + Uri uri = Uri.parse(url); + if (this.shouldOverrideUrlLoading(uri)) { + return true; + } + return super.shouldOverrideUrlLoading(view, url); + } + + private boolean shouldOverrideUrlLoading(Uri uri) { + if (this.checkRedirectURI(uri)) { + return true; + } + return false; + } + + private boolean checkRedirectURI(Uri uri) { + Uri redirectURI = this.activity.getOptions().redirectURI; + Uri withoutQuery = this.removeQueryAndFragment(uri); + if (withoutQuery.toString().equals(redirectURI.toString())) { + this.activity.result = uri; + this.activity.callSetResult(); + this.activity.finish(); + return true; + } + return false; + } + + private Uri removeQueryAndFragment(Uri uri) { + return uri.buildUpon().query(null).fragment(null).build(); + } + } + + private static class MyWebChromeClient extends WebChromeClient { + + private WebKitWebViewActivity activity; + + private MyWebChromeClient(WebKitWebViewActivity activity) { + this.activity = activity; + } + @Override + public boolean onShowFileChooser(WebView webView, ValueCallback filePathCallback, FileChooserParams fileChooserParams) { + StartActivityHandle> handle = new StartActivityHandle<>(TAG_FILE_CHOOSER, filePathCallback); + int requestCode = this.activity.handles.push(handle); + Intent intent = fileChooserParams.createIntent(); + this.activity.startActivityForResult(intent, requestCode); + return true; + } + } + + public static Intent createIntent(Context ctx, Options options) { + Intent intent = new Intent(ctx, WebKitWebViewActivity.class); + intent.putExtra(KEY_OPTIONS, options.toBundle()); + return intent; + } + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + Options options = this.getOptions(); + + // Do not show title. + getSupportActionBar().setDisplayShowTitleEnabled(false); + + // Configure navigation bar background color. + if (options.actionBarBackgroundColor != null) { + getSupportActionBar().setBackgroundDrawable(new ColorDrawable(options.actionBarBackgroundColor)); + } + + // Show back button. + getSupportActionBar().setDisplayOptions(ActionBar.DISPLAY_SHOW_HOME | ActionBar.DISPLAY_HOME_AS_UP); + + // Configure the back button. + Drawable backButtonDrawable = this.getDrawableCompat(R.drawable.ic_arrow_back); + if (options.actionBarButtonTintColor != null) { + backButtonDrawable = this.tintDrawable(backButtonDrawable, options.actionBarButtonTintColor); + } + getSupportActionBar().setHomeAsUpIndicator(backButtonDrawable); + + // Configure web view. + this.mWebView = new WebView(this); + this.setContentView(this.mWebView); + this.mWebView.setWebViewClient(new MyWebViewClient(this)); + this.mWebView.setWebChromeClient(new MyWebChromeClient(this)); + WebSettings webSettings = this.mWebView.getSettings(); + webSettings.setJavaScriptEnabled(true); + + this.mWebView.loadUrl(options.url.toString()); + } + + @Override + public void onBackPressed() { + if (this.mWebView.canGoBack()) { + this.mWebView.goBack(); + } else { + this.callSetResult(); + super.onBackPressed(); + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + Options options = this.getOptions(); + + // Configure the close button. + Drawable drawable = this.getDrawableCompat(R.drawable.ic_close); + if (options.actionBarButtonTintColor != null) { + drawable = this.tintDrawable(drawable, options.actionBarButtonTintColor); + } + + menu.add(Menu.NONE, MENU_ID_CANCEL, Menu.NONE, android.R.string.cancel) + .setIcon(drawable) + .setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); + return super.onCreateOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(@NonNull MenuItem item) { + if (item.getItemId() == android.R.id.home) { + this.onBackPressed(); + return true; + } + if (item.getItemId() == MENU_ID_CANCEL) { + this.callSetResult(); + this.finish(); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + super.onActivityResult(requestCode, resultCode, data); + + StartActivityHandle> handle = this.handles.pop(requestCode); + if (handle == null) { + return; + } + + switch (handle.tag) { + case TAG_FILE_CHOOSER: + switch (resultCode) { + case Activity.RESULT_CANCELED: + handle.value.onReceiveValue(null); + break; + case Activity.RESULT_OK: + if (data != null && data.getData() != null) { + handle.value.onReceiveValue(new Uri[]{data.getData()}); + } else { + handle.value.onReceiveValue(null); + } + break; + } + break; + } + } + + private Options getOptions() { + Bundle bundle = this.getIntent().getParcelableExtra(KEY_OPTIONS); + Options options = new Options(bundle); + return options; + } + + private void callSetResult() { + if (this.result == null) { + this.setResult(Activity.RESULT_CANCELED); + } else { + Intent intent = new Intent(); + intent.setData(this.result); + this.setResult(Activity.RESULT_OK, intent); + } + } + + private Drawable getDrawableCompat(@DrawableRes int id) { + Drawable drawable = ResourcesCompat.getDrawable(this.getResources(), id, null); + return drawable; + } + + private Drawable tintDrawable(Drawable drawable, @ColorInt int color) { + Drawable newDrawable = DrawableCompat.wrap(drawable).getConstantState().newDrawable().mutate(); + DrawableCompat.setTint(newDrawable, color); + return newDrawable; + } +} diff --git a/packages/authgear-capacitor/android/src/main/res/drawable/ic_arrow_back.xml b/packages/authgear-capacitor/android/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 00000000..0e2e8635 --- /dev/null +++ b/packages/authgear-capacitor/android/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,11 @@ + + + diff --git a/packages/authgear-capacitor/android/src/main/res/drawable/ic_close.xml b/packages/authgear-capacitor/android/src/main/res/drawable/ic_close.xml new file mode 100644 index 00000000..7a0ff35d --- /dev/null +++ b/packages/authgear-capacitor/android/src/main/res/drawable/ic_close.xml @@ -0,0 +1,10 @@ + + + diff --git a/packages/authgear-capacitor/ios/Plugin.xcodeproj/project.pbxproj b/packages/authgear-capacitor/ios/Plugin.xcodeproj/project.pbxproj index b38f2bce..71c44efd 100644 --- a/packages/authgear-capacitor/ios/Plugin.xcodeproj/project.pbxproj +++ b/packages/authgear-capacitor/ios/Plugin.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 50ADFFA42020D75100D50D53 /* Capacitor.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 50ADFFA52020D75100D50D53 /* Capacitor.framework */; }; 50ADFFA82020EE4F00D50D53 /* AuthgearPlugin.m in Sources */ = {isa = PBXBuildFile; fileRef = 50ADFFA72020EE4F00D50D53 /* AuthgearPlugin.m */; }; 50E1A94820377CB70090CE1A /* AuthgearPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E1A94720377CB70090CE1A /* AuthgearPlugin.swift */; }; + 775CFBB72B70B618002938DF /* AGWKWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 775CFBB62B70B618002938DF /* AGWKWebViewController.swift */; }; 7774CCB12B302A7F007420F2 /* AuthgearPluginImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7774CCB02B302A7F007420F2 /* AuthgearPluginImpl.swift */; }; 77F8B4232B4FDFB700A6F088 /* Asn1IntegerConversion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77F8B4222B4FDFB700A6F088 /* Asn1IntegerConversion.swift */; }; 77F8B4252B4FE08700A6F088 /* ASN1DERParsing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77F8B4242B4FE08700A6F088 /* ASN1DERParsing.swift */; }; @@ -40,6 +41,7 @@ 50ADFFA72020EE4F00D50D53 /* AuthgearPlugin.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AuthgearPlugin.m; sourceTree = ""; }; 50E1A94720377CB70090CE1A /* AuthgearPlugin.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthgearPlugin.swift; sourceTree = ""; }; 5E23F77F099397094342571A /* Pods-Plugin.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Plugin.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Plugin/Pods-Plugin.debug.xcconfig"; sourceTree = ""; }; + 775CFBB62B70B618002938DF /* AGWKWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AGWKWebViewController.swift; sourceTree = ""; }; 7774CCB02B302A7F007420F2 /* AuthgearPluginImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthgearPluginImpl.swift; sourceTree = ""; }; 77F8B4222B4FDFB700A6F088 /* Asn1IntegerConversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Asn1IntegerConversion.swift; sourceTree = ""; }; 77F8B4242B4FE08700A6F088 /* ASN1DERParsing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ASN1DERParsing.swift; sourceTree = ""; }; @@ -98,6 +100,7 @@ 50ADFF8B201F53D600D50D53 /* AuthgearPlugin.h */, 50ADFFA72020EE4F00D50D53 /* AuthgearPlugin.m */, 7774CCB02B302A7F007420F2 /* AuthgearPluginImpl.swift */, + 775CFBB62B70B618002938DF /* AGWKWebViewController.swift */, 50ADFF8C201F53D600D50D53 /* Info.plist */, 77F8B4222B4FDFB700A6F088 /* Asn1IntegerConversion.swift */, 77F8B4242B4FE08700A6F088 /* ASN1DERParsing.swift */, @@ -314,6 +317,7 @@ 77F8B4232B4FDFB700A6F088 /* Asn1IntegerConversion.swift in Sources */, 50ADFFA82020EE4F00D50D53 /* AuthgearPlugin.m in Sources */, 7774CCB12B302A7F007420F2 /* AuthgearPluginImpl.swift in Sources */, + 775CFBB72B70B618002938DF /* AGWKWebViewController.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/packages/authgear-capacitor/ios/Plugin/AGWKWebViewController.swift b/packages/authgear-capacitor/ios/Plugin/AGWKWebViewController.swift new file mode 100644 index 00000000..ca3d09a0 --- /dev/null +++ b/packages/authgear-capacitor/ios/Plugin/AGWKWebViewController.swift @@ -0,0 +1,202 @@ +import Foundation +import UIKit +import WebKit + +let AGWKWebViewControllerErrorDomain: String = "AGWKWebViewController" +let AGWKWebViewControllerErrorCodeCanceledLogin: Int = 1 + +protocol AGWKWebViewControllerPresentationContextProviding: AnyObject { + func presentationAnchor(for: AGWKWebViewController) -> UIWindow +} + +class AGWKWebViewController: UIViewController, WKNavigationDelegate { + typealias CompletionHandler = (URL?, Error?) -> Void + + weak var presentationContextProvider: AGWKWebViewControllerPresentationContextProviding? + var backgroundColor: UIColor? + var navigationBarBackgroundColor: UIColor? + var navigationBarButtonTintColor: UIColor? + + private let url: URL + private let redirectURI: URL + private var completionHandler: CompletionHandler? + private let webView: WKWebView + private var result: URL? + + private var defaultBackgroundColor: UIColor { + get { + if #available(iOS 12.0, *) { + switch (self.traitCollection.userInterfaceStyle) { + case .dark: + return UIColor.black + default: + return UIColor.white + } + } + return UIColor.white + } + } + + init(url: URL, redirectURI: URL, completionHandler: @escaping CompletionHandler) { + self.url = url + self.redirectURI = redirectURI + self.completionHandler = completionHandler + + let configuration = WKWebViewConfiguration() + self.webView = WKWebView(frame: .zero, configuration: configuration) + self.webView.translatesAutoresizingMaskIntoConstraints = false + self.webView.allowsBackForwardNavigationGestures = true + + super.init(nibName: nil, bundle: nil) + + self.webView.navigationDelegate = self + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + // Configure background color + if let backgroundColor = self.backgroundColor { + self.view.backgroundColor = backgroundColor + } else { + self.view.backgroundColor = self.defaultBackgroundColor + } + + // Configure layout + self.view.addSubview(self.webView) + if #available(iOS 11.0, *) { + // Extend the web view to the top edge of the screen. + // WKWebView magically offset the content so that the content is not covered by the navigation bar initially. + self.webView.topAnchor.constraint(equalTo: self.view.topAnchor).isActive = true + self.webView.leadingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.leadingAnchor).isActive = true + self.webView.trailingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.trailingAnchor).isActive = true + // Extend the web view to the bottom edge of the screen. + self.webView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor).isActive = true + } + + // Configure the bounce behavior + self.webView.scrollView.bounces = false + self.webView.scrollView.alwaysBounceVertical = false + self.webView.scrollView.alwaysBounceHorizontal = false + + // Configure navigation bar appearance + if #available(iOS 13.0, *) { + let appearance = UINavigationBarAppearance() + appearance.configureWithTransparentBackground() + if let navigationBarBackgroundColor = self.navigationBarBackgroundColor { + appearance.backgroundColor = navigationBarBackgroundColor + } + self.navigationItem.standardAppearance = appearance + self.navigationItem.compactAppearance = appearance + self.navigationItem.scrollEdgeAppearance = appearance + if #available(iOS 15.0, *) { + self.navigationItem.compactScrollEdgeAppearance = appearance + } + } + + // Configure back button + self.navigationItem.hidesBackButton = true + var backButton: UIBarButtonItem + if #available(iOS 13.0, *) { + let backButtonImage = UIImage(systemName: "chevron.backward") + backButton = UIBarButtonItem(image: backButtonImage, style: .plain, target: self, action: #selector(onTapBackButton)) + } else { + backButton = UIBarButtonItem(title: "<", style: .plain, target: self, action: #selector(onTapBackButton)) + } + if let navigationBarButtonTintColor = self.navigationBarButtonTintColor { + backButton.tintColor = navigationBarButtonTintColor + } + self.navigationItem.leftBarButtonItem = backButton + + // Configure close button + var closeButton: UIBarButtonItem + if #available(iOS 13.0, *) { + let closeButtonImage = UIImage(systemName: "xmark") + closeButton = UIBarButtonItem(image: closeButtonImage, style: .plain, target: self, action: #selector(onTapCloseButton)) + } else { + closeButton = UIBarButtonItem(title: "X", style: .plain, target: self, action: #selector(onTapCloseButton)) + } + if let navigationBarButtonTintColor = self.navigationBarButtonTintColor { + closeButton.tintColor = navigationBarButtonTintColor + } + self.navigationItem.rightBarButtonItem = closeButton + + let request = URLRequest(url: self.url) + self.webView.load(request) + } + + override func viewDidDisappear(_ animated: Bool) { + // We only call completion handler here because + // The view controller could be swiped to dismiss. + // viewDidDisappear is the most rebust way to detect whether the view controller is dismissed. + if let result = self.result { + self.completionHandler?(result, nil) + } else { + let err = NSError(domain: AGWKWebViewControllerErrorDomain, code: AGWKWebViewControllerErrorCodeCanceledLogin) + self.completionHandler?(nil, err) + } + self.completionHandler = nil + } + + @objc private func onTapBackButton() { + if (self.webView.canGoBack) { + _ = self.webView.goBack() + } else { + self.cancel() + } + } + + @objc private func onTapCloseButton() { + self.cancel() + } + + func cancel() { + self.dismissSelf() + } + + func start() { + if let presentationAnchor = self.presentationContextProvider?.presentationAnchor(for: self) { + let navigationController = UINavigationController(rootViewController: self) + // Use the configured modal presentation style. + navigationController.modalPresentationStyle = self.modalPresentationStyle + presentationAnchor.rootViewController?.present(navigationController, animated: true) + } + } + + private func dismissSelf() { + self.navigationController?.presentingViewController?.dismiss(animated: true) + } + + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + if let navigationURL = navigationAction.request.url { + var parts = URLComponents(url: navigationURL, resolvingAgainstBaseURL: false) + parts?.query = nil + parts?.fragment = nil + if let partsString = parts?.string { + if partsString == self.redirectURI.absoluteString { + decisionHandler(.cancel) + self.result = navigationURL + self.dismissSelf() + return + } + } + } + + if #available(iOS 14.5, *) { + if navigationAction.shouldPerformDownload { + decisionHandler(.download) + return + } else { + decisionHandler(.allow) + return + } + } else { + decisionHandler(.allow) + return + } + } +} diff --git a/packages/authgear-capacitor/ios/Plugin/AuthgearPlugin.m b/packages/authgear-capacitor/ios/Plugin/AuthgearPlugin.m index d46e4f63..39c4e451 100644 --- a/packages/authgear-capacitor/ios/Plugin/AuthgearPlugin.m +++ b/packages/authgear-capacitor/ios/Plugin/AuthgearPlugin.m @@ -12,6 +12,7 @@ CAP_PLUGIN_METHOD(getDeviceInfo, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(generateUUID, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(openAuthorizeURL, CAPPluginReturnPromise); + CAP_PLUGIN_METHOD(openAuthorizeURLWithWebView, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(openURL, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(checkBiometricSupported, CAPPluginReturnPromise); CAP_PLUGIN_METHOD(createBiometricPrivateKey, CAPPluginReturnPromise); diff --git a/packages/authgear-capacitor/ios/Plugin/AuthgearPlugin.swift b/packages/authgear-capacitor/ios/Plugin/AuthgearPlugin.swift index a70aab10..e620a046 100644 --- a/packages/authgear-capacitor/ios/Plugin/AuthgearPlugin.swift +++ b/packages/authgear-capacitor/ios/Plugin/AuthgearPlugin.swift @@ -103,6 +103,36 @@ public class AuthgearPlugin: CAPPlugin { } } + @objc func openAuthorizeURLWithWebView(_ call: CAPPluginCall) { + let url = URL(string: call.getString("url")!)! + let redirectURI = URL(string: call.getString("redirectURI")!)! + let modalPresentationStyle = call.getString("modalPresentationStyle") + let backgroundColor = call.getString("backgroundColor") + let navigationBarBackgroundColor = call.getString("navigationBarBackgroundColor") + let navigationBarButtonTintColor = call.getString("navigationBarButtonTintColor") + + DispatchQueue.main.async { + self.impl.openAuthorizeURLWithWebView( + window: (self.bridge?.webView?.window)!, + url: url, + redirectURI: redirectURI, + modalPresentationStyleString: modalPresentationStyle, + backgroundColorString: backgroundColor, + navigationBarBackgroundColorString: navigationBarBackgroundColor, + navigationBarButtonTintColorString: navigationBarButtonTintColor + ) { (redirectURI, error) in + if let error = error { + error.reject(call) + } + if let redirectURI = redirectURI { + call.resolve([ + "redirectURI": redirectURI + ]) + } + } + } + } + @objc func openURL(_ call: CAPPluginCall) { let url = URL(string: call.getString("url")!)! diff --git a/packages/authgear-capacitor/ios/Plugin/AuthgearPluginImpl.swift b/packages/authgear-capacitor/ios/Plugin/AuthgearPluginImpl.swift index b0f8da14..d9c1a3d0 100644 --- a/packages/authgear-capacitor/ios/Plugin/AuthgearPluginImpl.swift +++ b/packages/authgear-capacitor/ios/Plugin/AuthgearPluginImpl.swift @@ -5,8 +5,9 @@ import AuthenticationServices import LocalAuthentication import Capacitor -@objc class AuthgearPluginImpl: NSObject, ASWebAuthenticationPresentationContextProviding { +@objc class AuthgearPluginImpl: NSObject, ASWebAuthenticationPresentationContextProviding, AGWKWebViewControllerPresentationContextProviding { private var asWebAuthenticationSessionHandles: [ASWebAuthenticationSession: UIWindow] = [:] + private var agWKWebViewControllerHandles: [AGWKWebViewController: UIWindow] = [:] @objc func storageGetItem(key: String) throws -> String { let query: [String: Any] = [ @@ -216,6 +217,40 @@ import Capacitor } } + func openAuthorizeURLWithWebView( + window: UIWindow, + url: URL, + redirectURI: URL, + modalPresentationStyleString: String?, + backgroundColorString: String?, + navigationBarBackgroundColorString: String?, + navigationBarButtonTintColorString: String?, + completion: @escaping (String?, Error?) -> Void + ) { + var controller: AGWKWebViewController? + controller = AGWKWebViewController(url: url, redirectURI: redirectURI) { result, error in + self.agWKWebViewControllerHandles.removeValue(forKey: controller!) + if let error = error { + let nsError = error as NSError + if (nsError.domain == AGWKWebViewControllerErrorDomain && nsError.code == AGWKWebViewControllerErrorCodeCanceledLogin) { + completion(nil, NSError.makeCancel(error: error)) + } else { + completion(nil, error) + } + } + if let result = result { + completion(result.absoluteString, nil) + } + } + self.agWKWebViewControllerHandles[controller!] = window + controller?.backgroundColor = UIColor(argb: backgroundColorString) + controller?.navigationBarBackgroundColor = UIColor(argb: navigationBarBackgroundColorString) + controller?.navigationBarButtonTintColor = UIColor(argb: navigationBarButtonTintColorString) + controller?.modalPresentationStyle = UIModalPresentationStyle.from(string: modalPresentationStyleString) + controller?.presentationContextProvider = self + controller?.start() + } + @objc func openURL(window: UIWindow, url: URL, completion: @escaping (Error?) -> Void) { if #available(iOS 12.0, *) { let scheme = "nocallback" @@ -335,6 +370,11 @@ import Capacitor return window } + func presentationAnchor(for controller: AGWKWebViewController) -> UIWindow { + let window = self.agWKWebViewControllerHandles[controller]! + return window + } + private func makeLAContext(policy: LAPolicy) -> LAContext { let ctx = LAContext() if policy == LAPolicy.deviceOwnerAuthenticationWithBiometrics { @@ -682,3 +722,37 @@ private extension LAPolicy { } } } + +private extension UIColor { + convenience init?(argb: String?) { + guard let argb = argb else { + return nil + } + let argbInt = UInt32(argb, radix: 16)! + let a = CGFloat((argbInt >> 24) & 0xFF) / 255.0 + let r = CGFloat((argbInt >> 16) & 0xFF) / 255.0 + let g = CGFloat((argbInt >> 8) & 0xFF) / 255.0 + let b = CGFloat(argbInt & 0xFF) / 255.0 + self.init(red: r, green: g, blue: b, alpha: a) + } +} + +private extension UIModalPresentationStyle { + static func from(string: String?) -> UIModalPresentationStyle { + if let string = string { + switch string { + case "fullScreen": + return .fullScreen + case "pageSheet": + return .pageSheet + default: + break + } + } + if #available(iOS 13.0, *) { + return .automatic + } else { + return .fullScreen + } + } +} diff --git a/packages/authgear-capacitor/src/index.ts b/packages/authgear-capacitor/src/index.ts index 193c6788..3199d724 100644 --- a/packages/authgear-capacitor/src/index.ts +++ b/packages/authgear-capacitor/src/index.ts @@ -1,4 +1,5 @@ /* global window, Request */ +import { Capacitor } from "@capacitor/core"; import { type ContainerOptions, type TokenStorage, @@ -20,13 +21,16 @@ import { generateCodeVerifier, computeCodeChallenge } from "./pkce"; import { generateUUID, getDeviceInfo, - openAuthorizeURL, openURL, createBiometricPrivateKey, checkBiometricSupported, removeBiometricPrivateKey, signWithBiometricPrivateKey, } from "./plugin"; +import { + UIImplementation, + DeviceBrowserUIImplementation, +} from "./ui_implementation"; import { type CapacitorContainerDelegate, type AuthenticateOptions, @@ -37,11 +41,11 @@ import { type BiometricOptions, } from "./types"; import { BiometricPrivateKeyNotFoundError } from "./error"; -import { Capacitor } from "@capacitor/core"; export * from "@authgear/core"; export * from "./types"; export * from "./storage"; +export * from "./ui_implementation"; export { BiometricPrivateKeyNotFoundError, BiometricNotSupportedOrPermissionDeniedError, @@ -88,6 +92,11 @@ export interface ConfigureOptions { * @defaultValue false */ isSSOEnabled?: boolean; + + /* + * The UIImplementation. + */ + uiImplementation?: UIImplementation; } /** @@ -132,6 +141,11 @@ export class CapacitorContainer { */ tokenStorage: TokenStorage; + /** + * @internal + */ + uiImplementation: UIImplementation; + /** * @public */ @@ -210,6 +224,7 @@ export class CapacitorContainer { this.storage = new PersistentContainerStorage(); this.tokenStorage = new PersistentTokenStorage(); + this.uiImplementation = new DeviceBrowserUIImplementation(); } /** @@ -288,6 +303,11 @@ export class CapacitorContainer { } else { this.tokenStorage = new PersistentTokenStorage(); } + if (options.uiImplementation != null) { + this.uiImplementation = options.uiImplementation; + } else { + this.uiImplementation = new DeviceBrowserUIImplementation(); + } // TODO: verify if we need to support configure for second time // and guard if initialized const refreshToken = await this.tokenStorage.getRefreshToken(this.name); @@ -389,11 +409,10 @@ export class CapacitorContainer { ...options, platform, }); - const redirectURL = await openAuthorizeURL({ + const redirectURL = await this.uiImplementation.openAuthorizationURL({ url: authorizeURL, - callbackURL: options.redirectURI, - prefersEphemeralWebBrowserSession: - this._shouldPrefersEphemeralWebBrowserSession(), + redirectURI: options.redirectURI, + shareCookiesWithDeviceBrowser: this._shareCookiesWithDeviceBrowser(), }); const xDeviceInfo = await getXDeviceInfo(); const result = await this.baseContainer._finishAuthentication( @@ -443,11 +462,10 @@ export class CapacitorContainer { scope: ["openid", "https://authgear.com/scopes/full-access"], }); - const redirectURL = await openAuthorizeURL({ + const redirectURL = await this.uiImplementation.openAuthorizationURL({ url: endpoint, - callbackURL: options.redirectURI, - prefersEphemeralWebBrowserSession: - this._shouldPrefersEphemeralWebBrowserSession(), + redirectURI: options.redirectURI, + shareCookiesWithDeviceBrowser: this._shareCookiesWithDeviceBrowser(), }); const xDeviceInfo = await getXDeviceInfo(); const result = await this.baseContainer._finishReauthentication( @@ -555,11 +573,11 @@ export class CapacitorContainer { /** * @internal */ - _shouldPrefersEphemeralWebBrowserSession(): boolean { + _shareCookiesWithDeviceBrowser(): boolean { if (this.isSSOEnabled) { - return false; + return true; } - return true; + return false; } /** diff --git a/packages/authgear-capacitor/src/plugin.ts b/packages/authgear-capacitor/src/plugin.ts index 4361973c..5193a963 100644 --- a/packages/authgear-capacitor/src/plugin.ts +++ b/packages/authgear-capacitor/src/plugin.ts @@ -15,6 +15,16 @@ export interface AuthgearPlugin { callbackURL: string; prefersEphemeralWebBrowserSession: boolean; }): Promise<{ redirectURI: string }>; + openAuthorizeURLWithWebView(options: { + url: string; + redirectURI: string; + modalPresentationStyle?: string; + backgroundColor?: string; + navigationBarBackgroundColor?: string; + navigationBarButtonTintColor?: string; + actionBarBackgroundColor?: string; + actionBarButtonTintColor?: string; + }): Promise<{ redirectURI: string }>; openURL(options: { url: string }): Promise; checkBiometricSupported(options: BiometricOptions): Promise; createBiometricPrivateKey( @@ -105,6 +115,24 @@ export async function openAuthorizeURL(options: { } } +export async function openAuthorizeURLWithWebView(options: { + url: string; + redirectURI: string; + modalPresentationStyle?: string; + backgroundColor?: string; + navigationBarBackgroundColor?: string; + navigationBarButtonTintColor?: string; + actionBarBackgroundColor?: string; + actionBarButtonTintColor?: string; +}): Promise { + try { + const { redirectURI } = await Authgear.openAuthorizeURLWithWebView(options); + return redirectURI; + } catch (e: unknown) { + throw _wrapError(e); + } +} + export async function openURL(options: { url: string }): Promise { try { await Authgear.openURL(options); diff --git a/packages/authgear-capacitor/src/ui_implementation.ts b/packages/authgear-capacitor/src/ui_implementation.ts new file mode 100644 index 00000000..12d7fe58 --- /dev/null +++ b/packages/authgear-capacitor/src/ui_implementation.ts @@ -0,0 +1,119 @@ +import { openAuthorizeURL, openAuthorizeURLWithWebView } from "./plugin"; + +/** + * @public + */ +export interface OpenAuthorizationURLOptions { + /* + * The URL to open. + */ + url: string; + /* + * The URL to detect. + */ + redirectURI: string; + /* + * A flag to some implementations that can share cookies with the device browser. + */ + shareCookiesWithDeviceBrowser: boolean; +} + +/** + * UIImplementation can open an URL and close itself when a redirect URI is detected. + * + * @public + */ +export interface UIImplementation { + /** + * openAuthorizationURL must open options.url. When redirectURI is detected, + * the implementation must close itself and return the redirectURI with query. + * If the end-user closes it, then openAuthorizationURL must reject the promise with + * CancelError. + * + * @public + */ + openAuthorizationURL(options: OpenAuthorizationURLOptions): Promise; +} + +/** + * DeviceBrowserUIImplementation is ASWebAuthenticationSession on iOS, and Custom Tabs on Android. + * + * @public + */ +export class DeviceBrowserUIImplementation implements UIImplementation { + // eslint-disable-next-line class-methods-use-this + async openAuthorizationURL( + options: OpenAuthorizationURLOptions + ): Promise { + const prefersEphemeralWebBrowserSession = + !options.shareCookiesWithDeviceBrowser; + return openAuthorizeURL({ + url: options.url, + callbackURL: options.redirectURI, + prefersEphemeralWebBrowserSession, + }); + } +} + +/** + * Color is an integer according to this encoding https://developer.android.com/reference/android/graphics/Color#encoding + * + * @public + */ +export interface WebKitWebViewUIImplementationOptionsIOS { + backgroundColor?: number; + navigationBarBackgroundColor?: number; + navigationBarButtonTintColor?: number; + modalPresentationStyle?: "automatic" | "fullScreen" | "pageSheet"; +} + +/** + * Color is an integer according to this encoding https://developer.android.com/reference/android/graphics/Color#encoding + * + * @public + */ +export interface WebKitWebViewUIImplementationOptionsAndroid { + actionBarBackgroundColor?: number; + actionBarButtonTintColor?: number; +} + +/** + * @public + */ +export interface WebKitWebViewUIImplementationOptions { + ios?: WebKitWebViewUIImplementationOptionsIOS; + android?: WebKitWebViewUIImplementationOptionsAndroid; +} + +/** + * WebKitWebViewUIImplementation is WKWebView on iOS, android.webkit.WebView on Android. + * + * @public + */ +export class WebKitWebViewUIImplementation implements UIImplementation { + private options?: WebKitWebViewUIImplementationOptions; + + constructor(options?: WebKitWebViewUIImplementationOptions) { + this.options = options; + } + + // eslint-disable-next-line class-methods-use-this + async openAuthorizationURL( + options: OpenAuthorizationURLOptions + ): Promise { + return openAuthorizeURLWithWebView({ + url: options.url, + redirectURI: options.redirectURI, + backgroundColor: this.options?.ios?.backgroundColor?.toString(16), + navigationBarBackgroundColor: + this.options?.ios?.navigationBarBackgroundColor?.toString(16), + navigationBarButtonTintColor: + this.options?.ios?.navigationBarButtonTintColor?.toString(16), + modalPresentationStyle: this.options?.ios?.modalPresentationStyle, + actionBarBackgroundColor: + this.options?.android?.actionBarBackgroundColor?.toString(16), + actionBarButtonTintColor: + this.options?.android?.actionBarButtonTintColor?.toString(16), + }); + } +} diff --git a/packages/authgear-react-native/android/src/main/java/com/authgear/reactnative/AuthgearReactNativeModule.java b/packages/authgear-react-native/android/src/main/java/com/authgear/reactnative/AuthgearReactNativeModule.java index 5a53d860..e2bff088 100644 --- a/packages/authgear-react-native/android/src/main/java/com/authgear/reactnative/AuthgearReactNativeModule.java +++ b/packages/authgear-react-native/android/src/main/java/com/authgear/reactnative/AuthgearReactNativeModule.java @@ -739,8 +739,8 @@ public void openAuthorizeURLWithWebView(ReadableMap options, Promise promise) { private Integer readColorInt(ReadableMap map, String key) { if (map.hasKey(key)) { - double d = map.getDouble(key); - long l = Double.valueOf(d).longValue(); + String s = map.getString(key); + long l = Long.parseLong(s, 16); int a = (int) ((l >> 24) & 0xff); int r = (int) ((l >> 16) & 0xff); int g = (int) ((l >> 8) &0xff); diff --git a/packages/authgear-react-native/ios/AGAuthgearReactNative.m b/packages/authgear-react-native/ios/AGAuthgearReactNative.m index e2dbf263..a4163d20 100644 --- a/packages/authgear-react-native/ios/AGAuthgearReactNative.m +++ b/packages/authgear-react-native/ios/AGAuthgearReactNative.m @@ -282,9 +282,9 @@ + (BOOL)application:(UIApplication *)application NSString *url = options[@"url"]; NSString *redirectURI = options[@"redirectURI"]; UIModalPresentationStyle modalPresentationStyle = [self modalPresentationStyleFromString:options[@"modalPresentationStyle"]]; - UIColor *backgroundColor = [self uiColorFromNSNumber:options[@"backgroundColor"]]; - UIColor *navigationBarBackgroundColor = [self uiColorFromNSNumber:options[@"navigationBarBackgroundColor"]]; - UIColor *navigationBarButtonTintColor = [self uiColorFromNSNumber:options[@"navigationBarButtonTintColor"]]; + UIColor *backgroundColor = [self uiColorFromNSString:options[@"backgroundColor"]]; + UIColor *navigationBarBackgroundColor = [self uiColorFromNSString:options[@"navigationBarBackgroundColor"]]; + UIColor *navigationBarButtonTintColor = [self uiColorFromNSString:options[@"navigationBarButtonTintColor"]]; AGWKWebViewController *controller = [[AGWKWebViewController alloc] initWithURL:[[NSURL alloc] initWithString:url] redirectURI:[[NSURL alloc] initWithString:redirectURI] completionHandler:^(NSURL *url, NSError *error) { @@ -933,12 +933,14 @@ -(UIModalPresentationStyle)modalPresentationStyleFromString:(NSString *)str } } --(UIColor *)uiColorFromNSNumber:(NSNumber *)num +-(UIColor *)uiColorFromNSString:(NSString *)inHex { - if (num == nil) { + if (inHex == nil) { return nil; } - NSUInteger argb = num.unsignedIntegerValue; + NSScanner *scanner = [NSScanner scannerWithString:inHex]; + unsigned long long argb = 0; + [scanner scanHexLongLong:&argb]; CGFloat a = ((argb >> 24) & 0xFF) / 255.0; CGFloat r = ((argb >> 16) & 0xFF) / 255.0; CGFloat g = ((argb >> 8) & 0xFF) / 255.0; diff --git a/packages/authgear-react-native/ios/AGWKWebViewController.m b/packages/authgear-react-native/ios/AGWKWebViewController.m index 0be3d689..498ca7e8 100644 --- a/packages/authgear-react-native/ios/AGWKWebViewController.m +++ b/packages/authgear-react-native/ios/AGWKWebViewController.m @@ -33,6 +33,8 @@ - (instancetype)initWithURL:(NSURL *)url redirectURI:(NSURL *)redirectURI comple - (void)viewDidLoad { + [super viewDidLoad]; + // Configure background color if (self.backgroundColor != nil) { self.view.backgroundColor = self.backgroundColor; @@ -43,10 +45,13 @@ - (void)viewDidLoad // Configure layout [self.view addSubview: self.webView]; if (@available(iOS 11.0, *)) { - [self.webView.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor].active = YES; + // Extend the web view to the top edge of the screen. + // WKWebView magically offset the content so that the content is not covered by the navigation bar initially. + [self.webView.topAnchor constraintEqualToAnchor:self.view.topAnchor].active = YES; [self.webView.leadingAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.leadingAnchor].active = YES; [self.webView.trailingAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.trailingAnchor].active = YES; - [self.webView.bottomAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.bottomAnchor].active = YES; + // Extend the web view to the bottom edge of the screen. + [self.webView.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor].active = YES; } // Configure the bounce behavior @@ -103,13 +108,17 @@ - (void)viewDidLoad - (void)viewDidDisappear:(BOOL)animated { + [super viewDidDisappear:animated]; + // We only call completion handler here because // The view controller could be swiped to dismiss. // viewDidDisappear is the most rebust way to detect whether the view controller is dismissed. - if (self.result != nil) { - self.completionHandler(self.result, nil); - } else { - self.completionHandler(nil, [[NSError alloc] initWithDomain:AGWKWebViewControllerErrorDomain code:AGWKWebViewControllerErrorCodeCanceledLogin userInfo:nil]); + if (self.completionHandler != nil) { + if (self.result != nil) { + self.completionHandler(self.result, nil); + } else { + self.completionHandler(nil, [[NSError alloc] initWithDomain:AGWKWebViewControllerErrorDomain code:AGWKWebViewControllerErrorCodeCanceledLogin userInfo:nil]); + } } self.completionHandler = nil; } diff --git a/packages/authgear-react-native/src/index.ts b/packages/authgear-react-native/src/index.ts index 4687d3ec..d5504b8a 100644 --- a/packages/authgear-react-native/src/index.ts +++ b/packages/authgear-react-native/src/index.ts @@ -41,7 +41,10 @@ import { } from "./types"; import { getAnonymousJWK, signAnonymousJWT } from "./jwt"; import { BiometricPrivateKeyNotFoundError } from "./error"; -import { WebView, DefaultWebView } from "./webview"; +import { + UIImplementation, + DeviceBrowserUIImplementation, +} from "./ui_implementation"; import EventEmitter from "./eventEmitter"; export * from "@authgear/core"; export * from "./types"; @@ -53,7 +56,7 @@ export { BiometricNoEnrollmentError, BiometricLockoutError, } from "./error"; -export * from "./webview"; +export * from "./ui_implementation"; /** * @public @@ -83,9 +86,9 @@ export interface ConfigureOptions { isSSOEnabled?: boolean; /* - * An implementation of WebView. + * The UIImplementation. */ - webView?: WebView; + uiImplementation?: UIImplementation; } /** @@ -135,7 +138,7 @@ export class ReactNativeContainer { /** * @internal */ - webView: WebView; + uiImplementation: UIImplementation; /** * @internal @@ -228,7 +231,7 @@ export class ReactNativeContainer { this.storage = new PersistentContainerStorage(); this.tokenStorage = new PersistentTokenStorage(); - this.webView = new DefaultWebView(); + this.uiImplementation = new DeviceBrowserUIImplementation(); this.wechatRedirectDeepLinkListener = (url: string) => { this._sendWechatRedirectURIToDelegate(url); @@ -326,10 +329,10 @@ export class ReactNativeContainer { } else { this.tokenStorage = new PersistentTokenStorage(); } - if (options.webView != null) { - this.webView = options.webView; + if (options.uiImplementation != null) { + this.uiImplementation = options.uiImplementation; } else { - this.webView = new DefaultWebView(); + this.uiImplementation = new DeviceBrowserUIImplementation(); } // TODO: verify if we need to support configure for second time @@ -385,7 +388,7 @@ export class ReactNativeContainer { if (options.wechatRedirectURI != null) { await registerWechatRedirectURI(options.wechatRedirectURI); } - const redirectURL = await this.webView.openAuthorizationURL({ + const redirectURL = await this.uiImplementation.openAuthorizationURL({ url: authorizeURL, redirectURI: options.redirectURI, shareCookiesWithDeviceBrowser: this._shareCookiesWithDeviceBrowser(), @@ -442,7 +445,7 @@ export class ReactNativeContainer { await registerWechatRedirectURI(options.wechatRedirectURI); } - const redirectURL = await this.webView.openAuthorizationURL({ + const redirectURL = await this.uiImplementation.openAuthorizationURL({ url: endpoint, redirectURI: options.redirectURI, shareCookiesWithDeviceBrowser: this._shareCookiesWithDeviceBrowser(), @@ -641,7 +644,7 @@ export class ReactNativeContainer { if (options.wechatRedirectURI != null) { await registerWechatRedirectURI(options.wechatRedirectURI); } - const redirectURL = await this.webView.openAuthorizationURL({ + const redirectURL = await this.uiImplementation.openAuthorizationURL({ url: authorizeURL, redirectURI: options.redirectURI, shareCookiesWithDeviceBrowser: this._shareCookiesWithDeviceBrowser(), diff --git a/packages/authgear-react-native/src/nativemodule.ts b/packages/authgear-react-native/src/nativemodule.ts index c9644925..a4d98a81 100644 --- a/packages/authgear-react-native/src/nativemodule.ts +++ b/packages/authgear-react-native/src/nativemodule.ts @@ -76,13 +76,13 @@ export async function openAuthorizeURLWithWebView(options: { url: string; redirectURI: string; - backgroundColor?: number; - navigationBarBackgroundColor?: number; - navigationBarButtonTintColor?: number; + backgroundColor?: string; + navigationBarBackgroundColor?: string; + navigationBarButtonTintColor?: string; modalPresentationStyle?: string; - actionBarBackgroundColor?: number; - actionBarButtonTintColor?: number; + actionBarBackgroundColor?: string; + actionBarButtonTintColor?: string; }): Promise { const redirectURIWithQuery: string = await _wrapPromise( AuthgearReactNative.openAuthorizeURLWithWebView(options) diff --git a/packages/authgear-react-native/src/webview.ts b/packages/authgear-react-native/src/ui_implementation.ts similarity index 54% rename from packages/authgear-react-native/src/webview.ts rename to packages/authgear-react-native/src/ui_implementation.ts index 02d84986..dc55a5e5 100644 --- a/packages/authgear-react-native/src/webview.ts +++ b/packages/authgear-react-native/src/ui_implementation.ts @@ -5,43 +5,42 @@ import { openAuthorizeURL, openAuthorizeURLWithWebView } from "./nativemodule"; */ export interface OpenAuthorizationURLOptions { /* - * The URL the web view must open. + * The URL to open. */ url: string; /* - * The URL the web view must detect. + * The URL to detect. */ redirectURI: string; /* - * A flag to some web view implementation that can share cookies with the device browser. - * Web view implementations that are based on WKWebView or android.webkit.WebView should ignore this flag. + * A flag to some implementations that can share cookies with the device browser. */ shareCookiesWithDeviceBrowser: boolean; } /** - * WebView represents a web view that can open an URL and close itself when a redirect URI is detected. - * DefaultWebView is a default implementation that comes with the SDK. + * UIImplementation can open an URL and close itself when a redirect URI is detected. * * @public */ -export interface WebView { +export interface UIImplementation { /** * openAuthorizationURL must open options.url. When redirectURI is detected, - * the web view must close itself and return the redirectURI with query. - * If the end-user close the web view, then openAuthorizationURL must reject the promise with + * the implementation must close itself and return the redirectURI with query. + * If the end-user closes it, then openAuthorizationURL must reject the promise with * CancelError. + * * @public */ openAuthorizationURL(options: OpenAuthorizationURLOptions): Promise; } /** - * DefaultWebView is ASWebAuthenticationSession on iOS, and Custom Tabs on Android. + * DeviceBrowserUIImplementation is ASWebAuthenticationSession on iOS, and Custom Tabs on Android. * * @public */ -export class DefaultWebView implements WebView { +export class DeviceBrowserUIImplementation implements UIImplementation { // eslint-disable-next-line class-methods-use-this async openAuthorizationURL( options: OpenAuthorizationURLOptions @@ -61,7 +60,7 @@ export class DefaultWebView implements WebView { * * @public */ -export interface PlatformWebViewOptionsIOS { +export interface WebKitWebViewUIImplementationOptionsIOS { backgroundColor?: number; navigationBarBackgroundColor?: number; navigationBarButtonTintColor?: number; @@ -73,7 +72,7 @@ export interface PlatformWebViewOptionsIOS { * * @public */ -export interface PlatformWebViewOptionsAndroid { +export interface WebKitWebViewUIImplementationOptionsAndroid { actionBarBackgroundColor?: number; actionBarButtonTintColor?: number; } @@ -81,20 +80,20 @@ export interface PlatformWebViewOptionsAndroid { /** * @public */ -export interface PlatformWebViewOptions { - ios?: PlatformWebViewOptionsIOS; - android?: PlatformWebViewOptionsAndroid; +export interface WebKitWebViewUIImplementationOptions { + ios?: WebKitWebViewUIImplementationOptionsIOS; + android?: WebKitWebViewUIImplementationOptionsAndroid; } /** - * PlatformWebView is WKWebView on iOS, android.webkit.WebView on Android. + * WebKitWebViewUIImplementation is WKWebView on iOS, android.webkit.WebView on Android. * * @public */ -export class PlatformWebView implements WebView { - private options?: PlatformWebViewOptions; +export class WebKitWebViewUIImplementation implements UIImplementation { + private options?: WebKitWebViewUIImplementationOptions; - constructor(options?: PlatformWebViewOptions) { + constructor(options?: WebKitWebViewUIImplementationOptions) { this.options = options; } @@ -105,14 +104,16 @@ export class PlatformWebView implements WebView { return openAuthorizeURLWithWebView({ url: options.url, redirectURI: options.redirectURI, - backgroundColor: this.options?.ios?.backgroundColor, + backgroundColor: this.options?.ios?.backgroundColor?.toString(16), navigationBarBackgroundColor: - this.options?.ios?.navigationBarBackgroundColor, + this.options?.ios?.navigationBarBackgroundColor?.toString(16), navigationBarButtonTintColor: - this.options?.ios?.navigationBarButtonTintColor, + this.options?.ios?.navigationBarButtonTintColor?.toString(16), modalPresentationStyle: this.options?.ios?.modalPresentationStyle, - actionBarBackgroundColor: this.options?.android?.actionBarBackgroundColor, - actionBarButtonTintColor: this.options?.android?.actionBarButtonTintColor, + actionBarBackgroundColor: + this.options?.android?.actionBarBackgroundColor?.toString(16), + actionBarButtonTintColor: + this.options?.android?.actionBarButtonTintColor?.toString(16), }); } }