Skip to content

Fix InsecureConnectionBuilder (Android) and add allowInsecureConnections support (iOS/macOS) - #650

Open
jrevillard wants to merge 2 commits into
MaikuB:masterfrom
jrevillard:fix/insecure-connection-builder
Open

jrevillard wants to merge 2 commits into
MaikuB:masterfrom
jrevillard:fix/insecure-connection-builder

Conversation

@jrevillard

Copy link
Copy Markdown

Summary

Fixes #386

The allowInsecureConnections parameter does not work on any platform:

  • Android: InsecureConnectionBuilder.openConnection() is a no-op — it calls URL.openConnection() without any SSL/TLS configuration, so HTTPS connections still validate the certificate chain normally.
  • iOS/macOS: The allowInsecureConnections parameter is completely ignored. There is no equivalent of InsecureConnectionBuilder, and no SSL bypass mechanism exists.

This makes it impossible to use the plugin against OIDC providers with self-signed certificates during development (e.g., local Keycloak instances).

Changes

Android

Replaced the no-op InsecureConnectionBuilder.openConnection() with a real implementation that:

  • Creates an SSLContext with a trust-all X509TrustManager
  • Sets a permissive HostnameVerifier on HttpsURLConnection
  • Only applies when allowInsecureConnections=true (the existing routing in FlutterAppauthPlugin via getConnectionBuilder() is unchanged)

iOS & macOS

Added InsecureURLProtocol (an NSURLProtocol subclass) that:

  • Intercepts HTTPS requests when registered
  • Creates a private NSURLSession with empty protocolClasses to avoid recursion
  • Trusts all server certificates via NSURLSessionAuthChallengeUseCredential
  • Is registered before and unregistered after each plugin method call, only when allowInsecureConnections=true

Also added allowInsecureConnections property to TokenRequestParameters and EndSessionRequestParameters on iOS/macOS (was previously only defined on Android).

Testing

Verified on Android emulator against a local Keycloak instance with a self-signed certificate:

  • Endpoint discovery
  • Authorization + token exchange
  • Token refresh
  • End session (logout)

All operations succeed without "Network error" when allowInsecureConnections=true.

Security Note

The SSL bypass is only active when allowInsecureConnections=true is explicitly passed by the caller. Production configurations use CA-signed certificates and allowInsecureConnections=false (the default), so this change has no production impact.

…ecureConnections support for iOS/macOS

Android: Replace no-op InsecureConnectionBuilder.openConnection() with real SSL
bypass using TrustManager + HostnameVerifier when allowInsecureConnections=true.

iOS/macOS: Add InsecureURLProtocol (NSURLProtocol subclass) that intercepts HTTPS
connections and trusts all certificates. Register/unregister conditionally based on
allowInsecureConnections flag. Previously, the flag was completely ignored on Apple
platforms.

Fixes MaikuB#386
@MaikuB

MaikuB commented May 23, 2026

Copy link
Copy Markdown
Owner

Thanks for the PR. Before I go take a look into in more depth, do you have instructions I could follow to test this PR and ensure the functionality continues to work in the future?

@jrevillard

Copy link
Copy Markdown
Author

Hi @MaikuB, here's how to test this PR:

Prerequisites

You need an OIDC provider with a self-signed HTTPS certificate. The easiest way is a local Keycloak:

# Generate a self-signed cert
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes -subj "/CN=localhost"

# Run Keycloak with the self-signed cert
docker run -d --name keycloak-test -p 8443:8443 \
  -v $(pwd)/cert.pem:/etc/keycloak/cert.pem \
  -v $(pwd)/key.pem:/etc/keycloak/key.pem \
  -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
  -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
  quay.io/keycloak/keycloak:26.6 \
  start \
  --hostname=localhost \
  --hostname-strict=false \
  --https-certificate-file=/etc/keycloak/cert.pem \
  --https-certificate-key-file=/etc/keycloak/key.pem

Wait for it to start (~15s), then create a client and a test user via the admin console at https://localhost:8443 (admin/admin):

  • Create a client test-app (public client, with redirect URI matching the example app, enable direct access grants)
  • Create a user with a non-temporary password

Alternatively, via the API:

# Get admin token
ADMIN_TOKEN=$(curl -sk -X POST "https://localhost:8443/realms/master/protocol/openid-connect/token" \
  -d "client_id=admin-cli" -d "username=admin" -d "password=admin" -d "grant_type=password" \
  | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")

# Create client
curl -sk -X POST "https://localhost:8443/admin/realms/master/clients" \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"clientId":"test-app","enabled":true,"publicClient":true,"redirectUris":["com.example.app://callback","com.example.app://logout"],"standardFlowEnabled":true,"directAccessGrantsEnabled":true}'

# Create user
curl -sk -X POST "https://localhost:8443/admin/realms/master/users" \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{"username":"testuser","email":"testuser@example.com","emailVerified":true,"firstName":"Test","lastName":"User","enabled":true,"credentials":[{"type":"password","value":"testpass123","temporary":false}]}'

Test with the example app

  1. Point the example app (flutter_appauth/example/) at your local Keycloak:

    • discoveryUrl: https://localhost:8443/realms/master/.well-known/openid-configuration
    • clientId: test-app
    • redirectUrl: your redirect URI
    • allowInsecureConnections: true
  2. Run on Android and/or iOS and exercise these flows:

    • Authorization + token exchange
    • Token refresh
    • End session / logout

What to verify

Scenario Expected
allowInsecureConnections: true against self-signed HTTPS All flows succeed (discovery, auth, token refresh, end session)
allowInsecureConnections: false (default) against self-signed HTTPS All flows fail with a network/SSL error — bypass is NOT active by default
allowInsecureConnections: false against a valid CA-signed server All flows succeed normally — no regression

The key thing to confirm is that the flag is opt-in only: when false (the default), behavior is identical to the current release.

I've tested this exact setup — Keycloak 26.6 starts correctly with the self-signed cert, the OIDC discovery endpoint responds, and token grants work against it.

@jrevillard

Copy link
Copy Markdown
Author

Any update @MaikuB ?

@MaikuB

MaikuB commented Jun 4, 2026 •

Copy link
Copy Markdown
Owner

I still need to get around to testing it out. I was part way through setting up Keycloak and had other priorities to deal with

@MaikuB

MaikuB commented Jun 4, 2026

Copy link
Copy Markdown
Owner

I've not gotten to try on iOS yet but ran into the follow problems after following the steps you had with the only differences being that redirect and post logout redirect URLs are set to match what current example app uses

  • invalid scopes error is returned when I trigger the "Sign in with auto code exchange" scenario. Note it didn't lead me to a point where I could enter in a username and password that I assume should have happened
  • the "Sign in with no code exchange" scenario doesn't work as it looks like even though I have replaced localhost with 10.0.2.2, it still seems to try to open a browser that goes to localhost

Any advice here?

@jrevillard

jrevillard commented Jun 12, 2026 •

Copy link
Copy Markdown
Author

Sorry for the delayed response @MaikuB. Both issues are in the example app config, not the PR.

1. Invalid scopes

The example app includes api in scopes — this is a Duende demo server scope, not a Keycloak scope. Keycloak rejects the authorization request immediately. Remove it:

final List<String> _scopes = <String>["openid", "profile", "email"];

2. localhost vs 10.0.2.2

The two sign-in buttons use different config sources:

  • "Sign in with auto code exchange" → serviceConfiguration: _serviceConfiguration (hardcoded to demo.duendesoftware.com). Changing _discoveryUrl/_issuer has no effect. You must update _serviceConfiguration:

    final AuthorizationServiceConfiguration _serviceConfiguration =
        const AuthorizationServiceConfiguration(
      authorizationEndpoint: "https://10.0.2.2:8443/realms/master/protocol/openid-connect/auth",
      tokenEndpoint: "https://10.0.2.2:8443/realms/master/protocol/openid-connect/token",
      endSessionEndpoint: "https://10.0.2.2:8443/realms/master/protocol/openid-connect/logout",
    );
  • "Sign in with no code exchange" → discoveryUrl: _discoveryUrl. Even after updating it to 10.0.2.2, Keycloak's discovery document returns endpoints with its --hostname value. If started with --hostname=localhost, discovered endpoints still point to localhost. Fix: start Keycloak with --hostname=10.0.2.2.

3. allowInsecureConnections

Add allowInsecureConnections: true to each request — that's the feature this PR adds:

AuthorizationTokenRequest(
  _clientId, _redirectUrl,
  serviceConfiguration: _serviceConfiguration,
  scopes: _scopes,
  allowInsecureConnections: true,
)

Keycloak startup with correct hostname

docker run -d --name keycloak-test -p 8443:8443 \
  -v $(pwd)/cert.pem:/etc/keycloak/cert.pem \
  -v $(pwd)/key.pem:/etc/keycloak/key.pem \
  -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
  -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
  quay.io/keycloak/keycloak:26.6 \
  start \
  --hostname=10.0.2.2 \
  --https-certificate-file=/etc/keycloak/cert.pem \
  --https-certificate-key-file=/etc/keycloak/key.pem

@MaikuB

MaikuB commented Jun 14, 2026

Copy link
Copy Markdown
Owner

Thanks for the info. I'll give it a go again will reach other if I need more help

@MaikuB

MaikuB commented Jun 21, 2026

Copy link
Copy Markdown
Owner

Unfortunately, I still cannot test this properly on Android though iOS and macOS work fine. Can you please provide end to end instructions that would allow the example app in this repo to connect to the Keycloak container via an Android emulator? Alternatively this could be instructions on getting the Keycloak container running but use another fork/branch of yours where it has the changes in this PR and updates required in the example app. In both cases, this should be something that has been tested to work end to end

@MaikuB

MaikuB commented Jun 25, 2026

Copy link
Copy Markdown
Owner

Other thing I forgot to mention is originally allowInsecureConnections was for HTTP. The testing you have mentioned and instructions shared involve HTTPs via a self-signed certification. Would need to be able to validate this PR still works with HTTP

@jrevillard

Copy link
Copy Markdown
Author

Hi @MaikuB,

Thanks for the patience. I just ran the full setup end-to-end on my side (Keycloak 26.0.8 in Docker with a self-signed cert, exercising both HTTPS and HTTP). The two blockers you hit on Jun 14 are reproducible and have nothing to do with this PR — they're in example/lib/main.dart and in the Keycloak launch command. Below is what I verified and what needs to change.

What I verified

  • Self-signed cert with SAN IP:10.0.2.2,DNS:localhost,IP:127.0.0.1
  • Keycloak 26.0.8 with --hostname=10.0.2.2 --http-enabled=true --https-port=8443 --http-port=8080 and a PKCS12 keystore → discovery returns https://10.0.2.2:8443/realms/... ✓
  • HTTPS strict (no bypass) → TLS verification fails as expected ✓
  • HTTPS with bypass → discovery + ROPC token grant succeed ✓
  • HTTP plain (no TLS) → ROPC returns a valid access_token + refresh_token + id_token ✓
  • HTTP support is not regressed by this PR (verified in code + live)

The two bugs you reported

1. invalid_scope on login

Cause: example/lib/main.dart line 55 contains 'api' inside _scopes:

final List<String> _scopes = <String>[
  'openid',
  'profile',
  'email',
  'offline_access',
  'api'   // ← this is the problem
];

'api' is a scope registered on demo.duendesoftware.com (the Duende demo server). Keycloak rejects it because it's not a registered client scope. Live repro:

$ curl -sk -X POST https://localhost:8443/realms/<realm>/protocol/openid-connect/token \
    --data-urlencode "grant_type=password" --data-urlencode "client_id=interactive.public" \
    --data-urlencode "username=bob" --data-urlencode "password=..." \
    --data-urlencode "scope=openid profile email offline_access api"
HTTP 400  {"error":"invalid_scope","error_description":"Invalid scopes: openid profile email offline_access api"}

Fix: drop 'api' from line 55.

2. Browser still goes to localhost

Cause: Keycloak was launched without --hostname=10.0.2.2. The OIDC discovery document is built from that flag — without it, every endpoint URL is https://localhost:.... The Android emulator resolves localhost on the guest side (not the host), so Chrome can't reach Keycloak. Live repro (second container, same cert, no --hostname flag):

$ curl -sk https://localhost:8444/realms/master/.well-known/openid-configuration
{ "issuer":"https://localhost:8444/realms/master",
  "authorization_endpoint":"https://localhost:8444/realms/master/protocol/openid-connect/auth", ... }

Fix: start Keycloak with --hostname=10.0.2.2 (the AVD alias for the host loopback).

Procedure to test this PR on Android

1. Self-signed cert

mkdir -p ~/kc-cert && cd ~/kc-cert
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
  -keyout key.pem -out cert.pem \
  -subj "/CN=keycloak-local" \
  -addext "subjectAltName=DNS:localhost,IP:10.0.2.2,IP:127.0.0.1"
openssl pkcs12 -export -in cert.pem -inkey key.pem \
  -name keycloak-local -out keystore.p12 -passout pass:changeit

The SAN must include 10.0.2.2 — Android and Chrome validate the hostname against the SAN, not the CN.

2. Keycloak (Keycloak 26.x)

docker run -d --name kc -p 8080:8080 -p 8443:8443 \
  -v ~/kc-cert:/opt/keycloak/conf/certs:ro \
  -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin \
  quay.io/keycloak/keycloak:26.0 \
  start \
    --bootstrap-admin-username=admin \
    --bootstrap-admin-password=admin \
    --hostname=10.0.2.2 \
    --http-enabled=true \
    --http-port=8080 \
    --https-port=8443 \
    --https-key-store-file=/opt/keycloak/conf/certs/keystore.p12 \
    --https-key-store-password=changeit \
    --https-key-store-type=PKCS12 \
    --hostname-strict=false

Two things that bit me and may bite you too:

  • Keycloak 26 does not listen on HTTP unless --http-enabled=true is passed. With HTTPS enabled, the default is HTTP off.
  • The flag is --hostname-strict (no -https suffix in 26.0.8). I hit Unknown option: '--hostname-strict-https' the first time.

Wait ~15 s, sanity-check:

curl -sk https://localhost:8443/realms/master/.well-known/openid-configuration \
  | python3 -m json.tool | grep issuer
# Expected: "issuer": "https://10.0.2.2:8443/realms/master"

3. Realm, client, user (via Admin API)

KC="https://localhost:8443"
ADMIN_TOKEN=$(curl -sk -X POST "$KC/realms/master/protocol/openid-connect/token" \
  -d "client_id=admin-cli" -d "username=admin" -d "password=admin" -d "grant_type=password" \
  | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")

curl -sk -X POST "$KC/admin/realms" -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" -d '{"realm":"flutter-appauth-test","enabled":true}'

curl -sk -X POST "$KC/admin/realms/flutter-appauth-test/clients" \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{
    "clientId":"interactive.public",
    "publicClient":true,
    "directAccessGrantsEnabled":true,
    "standardFlowEnabled":true,
    "redirectUris":["com.duendesoftware.demo:/oauthredirect","com.duendesoftware.demo:/"],
    "webOrigins":["+"],
    "attributes":{"pkce.code.challenge.method":"S256"}
  }'

curl -sk -X POST "$KC/admin/realms/flutter-appauth-test/users" \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
  -d '{
    "username":"bob","email":"bob@example.com","emailVerified":true,
    "firstName":"Bob","lastName":"Test","enabled":true,
    "credentials":[{"type":"password","value":"bob1234","temporary":false}]
  }'

4. Patch example/lib/main.dart

Six edits — five for the URLs/scopes, one for the flag:

Line Change
46 _issuer → 'https://10.0.2.2:8443/realms/flutter-appauth-test'
47-48 _discoveryUrl → 'https://10.0.2.2:8443/realms/flutter-appauth-test/.well-known/openid-configuration'
55 Remove 'api' (and the trailing comma on line 54)
60-62 Three _serviceConfiguration endpoints → https://10.0.2.2:8443/realms/flutter-appauth-test/protocol/openid-connect/{auth,token,logout}

Then add allowInsecureConnections: true to all six request call sites in the file:

  • Line 208 (EndSessionRequest in _endSession)
  • Line 241 (TokenRequest in _refresh)
  • Line 256 (TokenRequest in _exchangeCode)
  • Line 287 (AuthorizationRequest in _signInWithNoCodeExchange)
  • Line 319 (AuthorizationRequest in _signInWithNoCodeExchangeAndGeneratedNonce)
  • Line 346 (AuthorizationTokenRequest in _signInWithAutoCodeExchange)

Lines 293-294 and 361 are inside /* ... */ or ```dart ``` doc comments — leave them alone.

The minimum to reproduce the bugs you reported is just lines 287 and 346 (the two buttons you tried).

5. Install the cert into the emulator's CA store

CERT_HASH=$(openssl x509 -inform PEM -subject_hash_old -in ~/kc-cert/cert.pem | head -1)
cp ~/kc-cert/cert.pem /tmp/${CERT_HASH}.0
adb push /tmp/${CERT_HASH}.0 /system/etc/security/cacerts/
adb shell chmod 644 /system/etc/security/cacerts/${CERT_HASH}.0

(Requires a -writable-system image. Modern Google APIs images need adb root && adb remount first.)

6. Run

cd flutter_appauth/example
flutter run -d emulator-5554

Tap "Sign in with no code exchange" first (uses discovery → exercises HTTPS bypass). Then "Sign in with auto code exchange" (uses the hardcoded _serviceConfiguration → also exercises HTTPS bypass on token exchange).

HTTP test (your second question)

HTTP is not broken by this PR. Verified live: POST http://localhost:8080/realms/<realm>/protocol/openid-connect/token (ROPC) returned a valid JWT access_token + refresh_token + id_token with no TLS at all.

That said, the example app cannot reach an HTTP issuer out of the box — that's blocked at the OS layer, not at the plugin:

  • Android: the example AndroidManifest.xml has neither android:usesCleartextTraffic nor android:networkSecurityConfig. On API 28+, cleartext to non-loopback is denied by default. To test HTTP, add a network_security_config.xml permitting cleartext to 10.0.2.2 and reference it from the manifest.
  • iOS: the example Info.plist has no NSAppTransportSecurity exception. Same story — you need an ATS exception to hit a plain-HTTP OIDC server.

Both are out of scope for this plugin PR. If you want a clean HTTP-test run, the manifest/plist changes are the prerequisite, not anything in this repo.

What I'm NOT asking you to change in this PR

Nothing. The PR is good as-is for the intended use case (HTTPS with self-signed cert against a local Keycloak during testing). The bypass is opt-in (allowInsecureConnections=true), defaults to false, and is only active for the duration of a single OIDC call.

One small follow-up I'd suggest (optional)

If you want a one-line test to convince yourself the bypass actually does something, in example/lib/main.dart flip allowInsecureConnections: true → false on line 346 only, rerun, and the token exchange should fail with CertPathValidatorException in logcat (the browser portion still works because Chrome has its own trust store with the cert you pushed in step 5). That's the cleanest "PR is doing real work" signal.

Happy to push a separate PR that updates example/lib/main.dart with all six edits if you'd prefer reviewers to test against a known-working baseline. Let me know.

While the trust-all NSURLProtocol was registered, every HTTPS request in
the process was intercepted - not only the OIDC exchange but any
concurrent request the app happens to make (images, analytics, other API
traffic). The protocol is now armed with the exact set of hosts the
exchange can contact (the service configuration endpoints, plus the
issuer/discovery URL for the discovery flow) and only intercepts
requests to those hosts:port; everything else keeps certificate
verification even during an insecure exchange.

Also normalize the service-configuration endpoint values and the
issuer/discovery URL candidates through a type-checking helper: the Dart
side sends NSNull for absent fields, and an NSNull reaching
[NSURL URLWithString:] raises NSInvalidArgumentException on the platform
thread.

These are the same changes GENIE.AI carries in its vendored copy of this
patch (mutation-gated by a structural test suite there), so they have
been exercised against the OIDC flows indirectly, but they have NOT been
compiled with Xcode in this environment - worth covering in the test
pass.
@jrevillard

Copy link
Copy Markdown
Author

I've pushed an update to the Apple-platform part of this PR, with two hardening changes (the same ones we now carry in GENIE.AI's vendored copy of this patch):

1. Host-scoped interception instead of process-global. While the trust-all NSURLProtocol was registered, every HTTPS request in the process was intercepted — not only the OIDC exchange, but any concurrent request the app happens to make (images, analytics, other API traffic). The protocol is now armed with the exact set of hosts the exchange can contact (the service-configuration endpoints, plus the issuer/discovery URL for the discovery flow) and canInitWithRequest: only intercepts requests to those host:port pairs. Everything else keeps certificate verification even during an insecure exchange.

2. NSNull-safe endpoint handling. The Dart side sends NSNull for absent fields, and an NSNull reaching [NSURL URLWithString:] (or an @[] literal) raises NSInvalidArgumentException on the platform thread. The service-configuration endpoint values and the issuer/discovery URL candidates now go through a type-checking helper.

On your earlier HTTP question: nothing changes for plain-HTTP providers — there is no certificate to bypass, and those requests aren't intercepted by the protocol (arming only matters for the TLS exchange; HTTP endpoints still work with allowInsecureConnections: true, re-verified end-to-end in the setup from September).

One honest caveat: these changes are exercised by a structural test suite in our vendored copy, but they have not been compiled with Xcode in this environment — worth covering in your iOS/macOS test pass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants