User Agent Filter Authenticator

TL;DR

We created an authenticator that filters the user-agent header for Keycloak to exclude embedded webviews, ensuring compliance with specification requirements. The component reduces the attack surface; however, it has limitations due to the ease with which the user-agent can be manipulated on the client side. Nevertheless, this is still useful to educate development teams, partners, and users.

All sources are available here : https://github.com/please-openit/keycloak-authenticator-limit-user-agents

The specification

During our various tests, we observed that Google was filtering out all embedded webviews from logging into user accounts. After verifying the specifications, we found that this behavior was indeed foreseen by the protocol requirements. However, there is no such functionality in Keycloak. So, we set out on a mission to address this deficiency.

For reference, the specification about this feature can be found here:
https://datatracker.ietf.org/doc/html/rfc8252#section-8.12

The implementation

It is implemented as a standard authenticator, simply matching the user-agent string against a regular expression configured through the Keycloak interface for the component.

    @Override
    public void authenticate(AuthenticationFlowContext context) {
        AuthenticatorConfigModel authenticatorConfig = context.getAuthenticatorConfig();

        // Get user-agent from HTTP headers
        List<String> headersUserAgent = context.getHttpRequest().getHttpHeaders().getRequestHeader("User-Agent");
        List<String> allowedHeaders = new ArrayList<>();

        // Get regexp from configuration or default value
        if (authenticatorConfig == null) {
            allowedHeaders.add(LimitUserAgentAuthenticatorFactory.DEFAULT_VALUE);
        } else {
            allowedHeaders = Arrays.asList(authenticatorConfig.getConfig().getOrDefault("Allowed user-agents", LimitUserAgentAuthenticatorFactory.DEFAULT_VALUE).split("##"));
        }

        // Allow authentication flow to continue on first matching pattern (positive, whitelist policy)
        for (String headerUserAgent : headersUserAgent) {
            for (String allowedHeader : allowedHeaders) {
                Pattern pattern = Pattern.compile(allowedHeader);
                Matcher matcher = pattern.matcher(headerUserAgent);
                if (matcher.find()) {
                    context.success();
                    return;
                }
            }
        }

        // Forbid authentication flow if there aren't any matches
        context.failure(AuthenticationFlowError.ACCESS_DENIED, Response.status(400).build(), "User agent not allowed", "Bad user agent");
        return;
    }

This implementation, although simple, is extremely effective. It requires little resource and allows you to reduce the exposure area without too much impact on performance.

https://github.com/please-openit/keycloak-authenticator-limit-user-agents

Usage

Add this authenticator at the begining of the flow, then click on the parameters button an fill the right filter you want.

alt text

By default, it uses :

(?i)(Firefox|Chrome|Safari|Edge|Opera)

For all common browsers.

Limitations

Matching the user-agent against a whitelist is far from being enough to prevent bad usages of your login service by malicious people. Indeed, a malicious application can quite arbitrarily modify the user-agent.

It was very easy for us to demonstrate this with simple proofs of concept on iOS and Android.

IOS

import SwiftUI
import WebKit


struct WebView: UIViewRepresentable {
    let url: URL
    var userAgent: String? = nil  // Optional custom user agent

    func makeUIView(context: Context) -> WKWebView {
        let webView = WKWebView()

        // Set the custom user agent if provided
        if let userAgent = userAgent {
            webView.customUserAgent = userAgent
        }

        return webView
    }

    func updateUIView(_ uiView: WKWebView, context: Context) {
        let request = URLRequest(url: url)
        uiView.load(request)
    }
}


struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundStyle(.tint)
            Text("Hello, world!")
            
            WebView(
                url: URL(string: "https://www.whatismybrowser.com/")!,
                userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36"
            )

        }
        .padding()
    }
}

#Preview {
    ContentView()
}

Webview test iOS

Android

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        setSupportActionBar(binding.toolbar)

        val navController = findNavController(R.id.nav_host_fragment_content_main)
        appBarConfiguration = AppBarConfiguration(navController.graph)
        setupActionBarWithNavController(navController, appBarConfiguration)


        val webView = findViewById<WebView>(R.id.myWebview)
        val webSettings = webView.settings

// Change the User-Agent
        webSettings.userAgentString = "Mozilla/5.0 (iPhone; CPU iPhone OS 9_3_5 like Mac OS X) AppleWebKit/601.1 (KHTML, like Gecko) CriOS/53.0.2785.109 Mobile/13G36 Safari/601.1.46"

// Load a URL
        webView.loadUrl("https://www.whatismybrowser.com/")

        binding.fab.setOnClickListener { view ->
            Snackbar.make(view, "Replace with your own action", Snackbar.LENGTH_LONG)
                .setAction("Action", null)
                .setAnchorView(R.id.fab).show()
        }
    }

Webview test android

Conclusion: The Spirit of the Specification

It is obviously unwise to rely on software running on a user device to ensure security. If the device is compromised, there will inevitably be consequences that you will need to address. That is why such a filtering tool should be considered a best-practice helper rather than a true security component. However, educating users and integrators must also be a part of your security policies.

This part of the specification is therefore more about promoting good practices rather than providing security itself. While it may marginally reduce the exposure surface for malicious actors, its primary purpose is to educate developers and partners in good practices by prohibiting logins in webviews. Ultimately, this helps users recognize webviews as an illegitimate context for entering their passwords, thereby improving your system’s security by raising awareness among users and integrators.

Moreover, spreading such implicit knowledge tends to improve the overall security culture on the web, including within your system.