diff --git a/README.md b/README.md index 576bc218..389fecbb 100644 --- a/README.md +++ b/README.md @@ -354,7 +354,7 @@ It receives three parameters: ```javascript window.kmpJsBridge.callNative = function (methodName, params, callback) { - ... + // implementation omitted } ``` @@ -420,6 +420,54 @@ val navigator = ) ``` +## HTTP Authentication (Basic and Digest) + +This library allows you to handle HTTP authentication challenges by providing a `BasicAuthInterceptor`. + +When a website issues an HTTP authentication challenge (Basic or Digest), the platform WebView will call into your interceptor so you can supply credentials (or cancel). The library then forwards those credentials back to the platform to complete the authentication flow. The underlying platform (Android/iOS) negotiates the exact scheme with the server. + +### Usage + +1) Create an implementation of `BasicAuthInterceptor` and prompt/fetch credentials as needed: + +```kotlin +class MyBasicAuthInterceptor : BasicAuthInterceptor { + override fun onHttpAuthRequest( + challenge: BasicAuthChallenge, + handler: BasicAuthHandler, + navigator: WebViewNavigator, + ): Boolean { + // Decide if you want to handle this challenge (host/realm-based, etc.) + // Return true if you will handle it, false to let default platform handling proceed. + + // Example: Provide stored or prompted credentials + val username = "user" + val password = "pass" + + // Exactly one of proceed or cancel should be called. + handler.proceed(username, password) + return true + } +} +``` + +2) Provide the interceptor when creating your `WebViewNavigator`: + +```kotlin +@Composable +fun MyScreen() { + val navigator = rememberWebViewNavigator( + basicAuthInterceptor = MyBasicAuthInterceptor() + ) + // pass navigator to your WebView as you normally do +} +``` + +Notes: +- Call `handler.proceed(username, password)` to continue with the provided credentials or `handler.cancel()` to abort. +- You can use `challenge.host`, `challenge.realm`, and `challenge.previousFailureCount` to decide how to respond (e.g., avoid repeated prompts on failures). +- The platform handles the specifics of Basic vs Digest; your interceptor only supplies credentials or cancels. + ## WebSettings Starting from version 1.3.0, this library allows users to customize web settings. diff --git a/webview/src/androidMain/kotlin/com/multiplatform/webview/web/AccompanistWebView.kt b/webview/src/androidMain/kotlin/com/multiplatform/webview/web/AccompanistWebView.kt index 2ac8cc58..c6ceb372 100644 --- a/webview/src/androidMain/kotlin/com/multiplatform/webview/web/AccompanistWebView.kt +++ b/webview/src/androidMain/kotlin/com/multiplatform/webview/web/AccompanistWebView.kt @@ -29,6 +29,8 @@ import androidx.core.graphics.createBitmap import androidx.webkit.WebSettingsCompat import androidx.webkit.WebViewAssetLoader import androidx.webkit.WebViewFeature +import com.multiplatform.webview.basicauth.BasicAuthChallenge +import com.multiplatform.webview.basicauth.BasicAuthHandler import com.multiplatform.webview.jsbridge.ConsoleBridge import com.multiplatform.webview.jsbridge.WebViewJsBridge import com.multiplatform.webview.request.WebRequest @@ -432,6 +434,44 @@ open class AccompanistWebViewClient : WebViewClient() { } } } + + override fun onReceivedHttpAuthRequest( + view: WebView?, + handler: android.webkit.HttpAuthHandler?, + host: String?, + realm: String? + ) { + val interceptor = navigator.basicAuthInterceptor + if (interceptor == null || handler == null || host == null) { + super.onReceivedHttpAuthRequest(view, handler, host, realm) + return + } + val challenge = BasicAuthChallenge( + host = host, + realm = realm, + isProxy = false, + previousFailureCount = try { + handler.useHttpAuthUsernamePassword(); 0 + } catch (_: Throwable) { + 0 + } + ) + val wrapped = object : BasicAuthHandler { + private var used = false + override fun proceed(username: String, password: String) { + if (used) return; used = true + handler.proceed(username, password) + } + override fun cancel() { + if (used) return; used = true + handler.cancel() + } + } + val stop = try { interceptor.onHttpAuthRequest(challenge, wrapped, navigator) } catch (_: Throwable) { false } + if (!stop) { + super.onReceivedHttpAuthRequest(view, handler, host, realm) + } + } } /** diff --git a/webview/src/commonMain/kotlin/com/multiplatform/webview/basicauth/BasicAuthInterceptor.kt b/webview/src/commonMain/kotlin/com/multiplatform/webview/basicauth/BasicAuthInterceptor.kt new file mode 100644 index 00000000..a2acf21b --- /dev/null +++ b/webview/src/commonMain/kotlin/com/multiplatform/webview/basicauth/BasicAuthInterceptor.kt @@ -0,0 +1,26 @@ +package com.multiplatform.webview.basicauth + +import com.multiplatform.webview.web.WebViewNavigator + +data class BasicAuthChallenge( + val host: String, + val realm: String? = null, + val isProxy: Boolean = false, + val previousFailureCount: Int = 0, +) + +/** + * Exactly one of proceed or cancel should be called. + */ +interface BasicAuthHandler { + fun proceed(username: String, password: String) + fun cancel() +} + +interface BasicAuthInterceptor { + fun onHttpAuthRequest( + challenge: BasicAuthChallenge, + handler: BasicAuthHandler, + navigator: WebViewNavigator, + ): Boolean +} diff --git a/webview/src/commonMain/kotlin/com/multiplatform/webview/web/WebViewNavigator.kt b/webview/src/commonMain/kotlin/com/multiplatform/webview/web/WebViewNavigator.kt index ddf0b405..fc86268d 100644 --- a/webview/src/commonMain/kotlin/com/multiplatform/webview/web/WebViewNavigator.kt +++ b/webview/src/commonMain/kotlin/com/multiplatform/webview/web/WebViewNavigator.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import com.multiplatform.webview.basicauth.BasicAuthInterceptor import com.multiplatform.webview.request.RequestInterceptor import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -28,6 +29,7 @@ import kotlinx.coroutines.withContext class WebViewNavigator( val coroutineScope: CoroutineScope, val requestInterceptor: RequestInterceptor? = null, + val basicAuthInterceptor: BasicAuthInterceptor? = null, ) { /** * Sealed class for constraining possible navigation events. @@ -310,4 +312,5 @@ class WebViewNavigator( fun rememberWebViewNavigator( coroutineScope: CoroutineScope = rememberCoroutineScope(), requestInterceptor: RequestInterceptor? = null, -): WebViewNavigator = remember(coroutineScope) { WebViewNavigator(coroutineScope, requestInterceptor) } + basicAuthInterceptor: BasicAuthInterceptor? = null, +): WebViewNavigator = remember(coroutineScope) { WebViewNavigator(coroutineScope, requestInterceptor, basicAuthInterceptor) } diff --git a/webview/src/iosMain/kotlin/com/multiplatform/webview/web/WKNavigationDelegate.kt b/webview/src/iosMain/kotlin/com/multiplatform/webview/web/WKNavigationDelegate.kt index 78b2e6bb..41efa395 100644 --- a/webview/src/iosMain/kotlin/com/multiplatform/webview/web/WKNavigationDelegate.kt +++ b/webview/src/iosMain/kotlin/com/multiplatform/webview/web/WKNavigationDelegate.kt @@ -1,5 +1,7 @@ package com.multiplatform.webview.web +import com.multiplatform.webview.basicauth.BasicAuthChallenge +import com.multiplatform.webview.basicauth.BasicAuthHandler import com.multiplatform.webview.request.WebRequest import com.multiplatform.webview.request.WebRequestInterceptResult import com.multiplatform.webview.util.KLogger @@ -10,7 +12,10 @@ import kotlinx.cinterop.ObjCSignatureOverride import platform.CoreGraphics.CGPointMake import platform.Foundation.HTTPMethod import platform.Foundation.NSError +import platform.Foundation.NSURLCredentialPersistence +import platform.Foundation.NSURLSessionAuthChallengeUseCredential import platform.Foundation.allHTTPHeaderFields +import platform.Foundation.credentialWithUser import platform.WebKit.WKNavigation import platform.WebKit.WKNavigationAction import platform.WebKit.WKNavigationActionPolicy @@ -180,4 +185,55 @@ class WKNavigationDelegate( decisionHandler(WKNavigationActionPolicy.WKNavigationActionPolicyAllow) } } + + // HTTP Auth (Basic/Digest) handling forwarded to interceptor if provided + @ObjCSignatureOverride + override fun webView( + webView: WKWebView, + didReceiveAuthenticationChallenge: platform.Foundation.NSURLAuthenticationChallenge, + completionHandler: (platform.Foundation.NSURLSessionAuthChallengeDisposition, platform.Foundation.NSURLCredential?) -> Unit, + ) { + val interceptor = navigator.basicAuthInterceptor + val protectionSpace = didReceiveAuthenticationChallenge.protectionSpace + val method = protectionSpace.authenticationMethod + // Handle both HTTP Basic and HTTP Digest challenges. + val isSupported = method == platform.Foundation.NSURLAuthenticationMethodHTTPBasic || + method == platform.Foundation.NSURLAuthenticationMethodHTTPDigest + if (interceptor == null || !isSupported) { + completionHandler(platform.Foundation.NSURLSessionAuthChallengePerformDefaultHandling, null) + return + } + val host = protectionSpace.host + val realm = protectionSpace.realm + val challenge = BasicAuthChallenge( + host = host, + realm = realm, + isProxy = protectionSpace.isProxy() + ) + val handled = try { + interceptor.onHttpAuthRequest( + challenge, + object : BasicAuthHandler { + private var used = false + override fun proceed(username: String, password: String) { + if (used) return; used = true + val cred = platform.Foundation.NSURLCredential.credentialWithUser( + user = username, + password = password, + persistence = NSURLCredentialPersistence.NSURLCredentialPersistenceForSession, + ) + completionHandler(NSURLSessionAuthChallengeUseCredential, cred) + } + override fun cancel() { + if (used) return; used = true + completionHandler(platform.Foundation.NSURLSessionAuthChallengeCancelAuthenticationChallenge, null) + } + }, + navigator, + ) + } catch (_: Throwable) { false } + if (!handled) { + completionHandler(platform.Foundation.NSURLSessionAuthChallengePerformDefaultHandling, null) + } + } }