diff --git a/gradle/native-build.gradle b/gradle/native-build.gradle index 068c1c4b..0085c980 100644 --- a/gradle/native-build.gradle +++ b/gradle/native-build.gradle @@ -314,6 +314,7 @@ ext.nativeBuildLib = { buildDir, projectDir, name, os -> "-framework", "OpenGLES", "-framework", "StoreKit", "-framework", "UserNotifications", + "-framework", "AuthenticationServices", "-arch_only", arch, "-syslibroot", sdkPath(platform), "-L${sdkPath(platform)}/usr/lib", diff --git a/modules/browser/src/main/java/com/gluonhq/attach/browser/BrowserService.java b/modules/browser/src/main/java/com/gluonhq/attach/browser/BrowserService.java index 87fb0bfc..5a2ba8e6 100644 --- a/modules/browser/src/main/java/com/gluonhq/attach/browser/BrowserService.java +++ b/modules/browser/src/main/java/com/gluonhq/attach/browser/BrowserService.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2016, 2019 Gluon + * Copyright (c) 2016, 2026, Gluon * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -32,10 +32,11 @@ import java.io.IOException; import java.net.URISyntaxException; import java.util.Optional; +import java.util.function.Consumer; /** * Launches the default browser of the platform as a separate application process. The browser - * will be opened with the provided URL. + * will be opened with the provided URL by means of {@link #launchExternalBrowser(String)}. * *

Example

*
@@ -43,6 +44,25 @@
  *      service.launchExternalBrowser("https://gluonhq.com/");
  *  });}
* + *

The service can also be used to perform secure user authentication against a web service + * (for instance an OAuth 2.0 / OpenID Connect provider), using an embedded browser, by means of + * {@link #launchWebAuthentication(String, String, Consumer)}.

+ * + *

Example

+ *
+ * {@code BrowserService.create().ifPresent(service -> {
+ *      service.launchWebAuthentication(
+ *              "https://my-auth-provider.com/authorize?response_type=token&redirect_uri=myapp://callback",
+ *              "myapp",
+ *              callbackUrl -> {
+ *                  if (callbackUrl != null) {
+ *                      System.out.println("Authenticated, callback: " + callbackUrl);
+ *                  } else {
+ *                      System.out.println("Authentication cancelled or failed");
+ *                  }
+ *              });
+ *  });}
+ * *

Android Configuration: none

*

iOS Configuration: none

* @@ -66,4 +86,46 @@ static Optional create() { * @throws java.net.URISyntaxException If it is not a valid URL string */ void launchExternalBrowser(String url) throws IOException, URISyntaxException; + + /** + * Starts a web authentication session that lets the user authenticate against a web + * service, and delivers the redirect (callback) URL back to the app once the + * authentication flow completes. + * + *

On iOS this is implemented with a secure, dedicated native web view on top of the app. + * When the web service redirects to a URL that matches {@code callbackUrlScheme}, + * the session is automatically dismissed and the full callback URL with the authorization code is + * passed to {@code callback}. The redirect is secure and never travels through the system URL dispatch.

+ * + *

The {@code callbackUrlScheme} can be provided in two forms:

+ * + * + *

On Android and Desktop the default implementation simply opens the URL in the + * external browser (see {@link #launchExternalBrowser(String)}). On Android the redirect is + * caught by the system through an HTTPS or custom-scheme intent filter declared in the + * {@code AndroidManifest.xml}, and the resulting URL can be read with the + * {@code RuntimeArgsService}.

+ * + * @param url the authentication URL to load, including the {@code redirect_uri} expected by the + * web service. + * @param callbackUrlScheme either a custom URL scheme (without {@code ://}, e.g. {@code "myapp"}) + * or a full HTTPS URL (e.g. {@code "https://example.com/callback"}) that + * the web service uses for its redirect. + * @param callback a consumer that receives the full callback URL on success, or {@code null} if + * the user canceled the flow or an error occurred. + * @throws java.io.IOException If the URL can't be opened + * @throws java.net.URISyntaxException If it is not a valid URL string + * @since 4.0.25 + */ + void launchWebAuthentication(String url, String callbackUrlScheme, Consumer callback) + throws IOException, URISyntaxException; } diff --git a/modules/browser/src/main/java/com/gluonhq/attach/browser/impl/AndroidBrowserService.java b/modules/browser/src/main/java/com/gluonhq/attach/browser/impl/AndroidBrowserService.java index 7d79426f..d8ad8877 100644 --- a/modules/browser/src/main/java/com/gluonhq/attach/browser/impl/AndroidBrowserService.java +++ b/modules/browser/src/main/java/com/gluonhq/attach/browser/impl/AndroidBrowserService.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2016, 2020 Gluon + * Copyright (c) 2016, 2026, Gluon * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -31,6 +31,8 @@ import com.gluonhq.attach.util.Util; import java.io.IOException; +import java.net.URISyntaxException; +import java.util.function.Consumer; import java.util.logging.Logger; public class AndroidBrowserService implements BrowserService { @@ -58,6 +60,12 @@ public void launchExternalBrowser(String url) throws IOException { } } + @Override + public void launchWebAuthentication(String url, String callbackUrlScheme, Consumer callback) + throws IOException, URISyntaxException { + launchExternalBrowser(url); + } + // native private native boolean launchURL(String url); } \ No newline at end of file diff --git a/modules/browser/src/main/java/com/gluonhq/attach/browser/impl/DesktopBrowserService.java b/modules/browser/src/main/java/com/gluonhq/attach/browser/impl/DesktopBrowserService.java index 3fd927e0..d9540d15 100644 --- a/modules/browser/src/main/java/com/gluonhq/attach/browser/impl/DesktopBrowserService.java +++ b/modules/browser/src/main/java/com/gluonhq/attach/browser/impl/DesktopBrowserService.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021, Gluon + * Copyright (c) 2021, 2026, Gluon * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -31,8 +31,10 @@ import com.gluonhq.attach.util.Util; import java.io.IOException; +import java.net.URISyntaxException; import java.util.List; import java.util.Locale; +import java.util.function.Consumer; import java.util.logging.Logger; public class DesktopBrowserService implements BrowserService { @@ -63,4 +65,10 @@ public void launchExternalBrowser(String url) throws IOException { throw new IOException("Error launching url " + url); } } + + @Override + public void launchWebAuthentication(String url, String callbackUrlScheme, Consumer callback) + throws IOException, URISyntaxException { + launchExternalBrowser(url); + } } \ No newline at end of file diff --git a/modules/browser/src/main/java/com/gluonhq/attach/browser/impl/IOSBrowserService.java b/modules/browser/src/main/java/com/gluonhq/attach/browser/impl/IOSBrowserService.java index aa66132c..e034a087 100644 --- a/modules/browser/src/main/java/com/gluonhq/attach/browser/impl/IOSBrowserService.java +++ b/modules/browser/src/main/java/com/gluonhq/attach/browser/impl/IOSBrowserService.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2016, 2019, Gluon + * Copyright (c) 2016, 2026, Gluon * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -29,13 +29,21 @@ import com.gluonhq.attach.browser.BrowserService; +import javafx.application.Platform; import java.io.IOException; +import java.util.function.Consumer; +import java.util.logging.Logger; public class IOSBrowserService implements BrowserService { + private static final Logger LOG = Logger.getLogger(IOSBrowserService.class.getName()); + + private static Consumer authCallback; + static { System.loadLibrary("Browser"); + initBrowser(); } @Override @@ -44,7 +52,33 @@ public void launchExternalBrowser(String url) throws IOException { throw new IOException("Error launching url " + url); } } - + + @Override + public void launchWebAuthentication(String url, String callbackUrlScheme, Consumer callback) throws IOException { + if (url == null || url.isEmpty()) { + throw new IOException("Authentication url cannot be null or empty"); + } + if (callbackUrlScheme == null || callbackUrlScheme.isEmpty()) { + throw new IOException("Callback url scheme cannot be null or empty"); + } + authCallback = callback; + startWebAuthentication(url, callbackUrlScheme); + } + + // native private native boolean launchURL(String url); - + private static native void initBrowser(); + private static native void startWebAuthentication(String url, String callbackUrlScheme); + + // callback + public static void setAuthResult(String callbackUrl) { + final Consumer callback = authCallback; + authCallback = null; + if (callback == null) { + LOG.warning("No callback registered for web authentication result"); + return; + } + Platform.runLater(() -> callback.accept(callbackUrl)); + } + } diff --git a/modules/browser/src/main/java/module-info.java b/modules/browser/src/main/java/module-info.java index 8d37b334..d66b6559 100644 --- a/modules/browser/src/main/java/module-info.java +++ b/modules/browser/src/main/java/module-info.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019, Gluon + * Copyright (c) 2019, 2026, Gluon * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -26,7 +26,7 @@ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ module com.gluonhq.attach.browser { - + requires javafx.graphics; requires com.gluonhq.attach.util; exports com.gluonhq.attach.browser; diff --git a/modules/browser/src/main/native/ios/Browser.m b/modules/browser/src/main/native/ios/Browser.m index 67f31fa6..43ab3ce3 100644 --- a/modules/browser/src/main/native/ios/Browser.m +++ b/modules/browser/src/main/native/ios/Browser.m @@ -1,5 +1,5 @@ /* - * Copyright (c) 2016, 2019 Gluon + * Copyright (c) 2016, 2026, Gluon * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -26,11 +26,33 @@ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ #import +#import #include "jni.h" #include "AttachMacros.h" JNIEnv *env; +static int BrowserInited = 0; +jclass mat_jBrowserServiceClass; +jmethodID mat_jBrowserService_setAuthResult = 0; + +API_AVAILABLE(ios(12.0)) +static ASWebAuthenticationSession *_authSession; + +API_AVAILABLE(ios(13.0)) +@interface AttachAuthContextProvider : NSObject +@end + +API_AVAILABLE(ios(13.0)) +@implementation AttachAuthContextProvider +- (ASPresentationAnchor)presentationAnchorForWebAuthenticationSession:(ASWebAuthenticationSession *)session { + return [[UIApplication sharedApplication] keyWindow]; +} +@end + +API_AVAILABLE(ios(13.0)) +static AttachAuthContextProvider *_authContextProvider; + JNIEXPORT jint JNICALL JNI_OnLoad_Browser(JavaVM *vm, void *reserved) { @@ -45,6 +67,32 @@ #endif } +JNIEXPORT void JNICALL Java_com_gluonhq_attach_browser_impl_IOSBrowserService_initBrowser +(JNIEnv *env, jclass jClass) +{ + if (BrowserInited) + { + return; + } + BrowserInited = 1; + + mat_jBrowserServiceClass = (*env)->NewGlobalRef(env, (*env)->FindClass(env, "com/gluonhq/attach/browser/impl/IOSBrowserService")); + mat_jBrowserService_setAuthResult = (*env)->GetStaticMethodID(env, mat_jBrowserServiceClass, "setAuthResult", "(Ljava/lang/String;)V"); +} + +void sendAuthResult(NSString *callbackUrl) { + jstring arg = NULL; + if (callbackUrl != nil) { + const char *callbackChars = [callbackUrl UTF8String]; + arg = (*env)->NewStringUTF(env, callbackChars); + } + (*env)->CallStaticVoidMethod(env, mat_jBrowserServiceClass, mat_jBrowserService_setAuthResult, arg); + if (arg != NULL) { + (*env)->DeleteLocalRef(env, arg); + } + AttachLog(@"Finished sending web authentication result"); +} + JNIEXPORT jboolean JNICALL Java_com_gluonhq_attach_browser_impl_IOSBrowserService_launchURL (JNIEnv *env, jclass jClass, jstring jUrl) { @@ -79,3 +127,75 @@ } } +JNIEXPORT void JNICALL Java_com_gluonhq_attach_browser_impl_IOSBrowserService_startWebAuthentication +(JNIEnv *env, jclass jClass, jstring jUrl, jstring jScheme) +{ + const jchar *charsUrl = (*env)->GetStringChars(env, jUrl, NULL); + NSString *url = [NSString stringWithCharacters:(UniChar *)charsUrl length:(*env)->GetStringLength(env, jUrl)]; + (*env)->ReleaseStringChars(env, jUrl, charsUrl); + + const jchar *charsScheme = (*env)->GetStringChars(env, jScheme, NULL); + NSString *scheme = [NSString stringWithCharacters:(UniChar *)charsScheme length:(*env)->GetStringLength(env, jScheme)]; + (*env)->ReleaseStringChars(env, jScheme, charsScheme); + + NSURL *nsUrl = [NSURL URLWithString:url]; + + if (@available(iOS 12.0, *)) { + void (^completionHandler)(NSURL * _Nullable, NSError * _Nullable) = + ^(NSURL * _Nullable callbackURL, NSError * _Nullable error) { + if (error != nil || callbackURL == nil) { + AttachLog(@"Web authentication finished without a callback url: %@", error); + sendAuthResult(nil); + } else { + AttachLog(@"Web authentication succeeded with callback url: %@", callbackURL); + sendAuthResult([callbackURL absoluteString]); + } + _authSession = nil; + }; + + BOOL isHttps = [[scheme lowercaseString] hasPrefix:@"https"]; + if (isHttps) { + // Verified Universal Link (https://example.com/callback): requires iOS 17.4+ and an apple-app-site-association + // file associating the domain with the app. + if (@available(iOS 17.4, *)) { + NSURLComponents *components = [NSURLComponents componentsWithString:scheme]; + NSString *host = components.host; + NSString *path = (components.path != nil && [components.path length] > 0) ? components.path : @"/"; + if (host == nil || [host length] == 0) { + AttachLog(@"Invalid https callback, a host is required: %@", scheme); + sendAuthResult(nil); + return; + } + ASWebAuthenticationSessionCallback *httpsCallback = + [ASWebAuthenticationSessionCallback callbackWithHTTPSHost:host path:path]; + _authSession = [[ASWebAuthenticationSession alloc] initWithURL:nsUrl + callback:httpsCallback completionHandler:completionHandler]; + } else { + AttachLog(@"https callback URLs require iOS 17.4 or higher"); + sendAuthResult(nil); + return; + } + } else { + // Custom scheme callback ("myapp") + _authSession = [[ASWebAuthenticationSession alloc] initWithURL:nsUrl + callbackURLScheme:scheme completionHandler:completionHandler]; + } + + if (@available(iOS 13.0, *)) { + _authContextProvider = [[AttachAuthContextProvider alloc] init]; + _authSession.presentationContextProvider = _authContextProvider; + _authSession.prefersEphemeralWebBrowserSession = NO; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + if (![_authSession start]) { + AttachLog(@"Failed to start the web authentication session"); + sendAuthResult(nil); + _authSession = nil; + } + }); + } else { + AttachLog(@"ASWebAuthenticationSession requires iOS 12.0 or higher"); + sendAuthResult(nil); + } +} diff --git a/modules/browser/src/main/resources/META-INF/substrate/config/jniconfig-arm64-ios.json b/modules/browser/src/main/resources/META-INF/substrate/config/jniconfig-arm64-ios.json new file mode 100644 index 00000000..a548e9e6 --- /dev/null +++ b/modules/browser/src/main/resources/META-INF/substrate/config/jniconfig-arm64-ios.json @@ -0,0 +1,7 @@ +[ + { + "name" : "com.gluonhq.attach.browser.impl.IOSBrowserService", + "methods":[{"name":"setAuthResult","parameterTypes":["java.lang.String"] }] + } +] + diff --git a/modules/browser/src/main/resources/META-INF/substrate/config/jniconfig-x86_64-ios.json b/modules/browser/src/main/resources/META-INF/substrate/config/jniconfig-x86_64-ios.json new file mode 100644 index 00000000..a548e9e6 --- /dev/null +++ b/modules/browser/src/main/resources/META-INF/substrate/config/jniconfig-x86_64-ios.json @@ -0,0 +1,7 @@ +[ + { + "name" : "com.gluonhq.attach.browser.impl.IOSBrowserService", + "methods":[{"name":"setAuthResult","parameterTypes":["java.lang.String"] }] + } +] +