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:
+ *
+ * - A custom URL scheme (without {@code ://}, e.g. {@code "myapp"}), with a
+ * redirect like {@code myapp://callback}. No {@code Info.plist} URL scheme registration is
+ * required, since the session intercepts the redirect on its own.
+ *
+ * - A full HTTPS URL (e.g. {@code "https://example.com/callback"}), with a
+ * verified Universal Link redirect. This requires iOS 17.4 or higher and an
+ * {@code apple-app-site-association} file hosted on the domain that associates it with the app.
+ *
+ *
+ *
+ * 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"] }]
+ }
+]
+