Keycloak — Versola Docs
/docs
0.1.0versola.ioGitHub

Keycloak

Connect the Versola OAuth SDK to Keycloak 26.4 with a public client, with the realm settings, the SDK configuration per platform and what the tests observed.

You can sign users in with Keycloak through the Versola OAuth SDK’s OAuthClient by creating a public client in a realm and passing the realm’s issuer to the SDK. This guide is backed by the SDK’s own integration tests, which ran against quay.io/keycloak/keycloak:26.4 in development mode with a purpose-built test realm.

Read Provider guides first for the minimum contract and the redirect setup that is the same for every provider.

What to configure in Keycloak

Realm and issuer

The discovery document is at {base}/realms/{realm}/.well-known/openid-configuration and the issuer is {base}/realms/{realm}. Open the discovery document in a browser and copy its issuer member: that exact value is what you configure. In development mode Keycloak derives the issuer, every endpoint and the login form’s action from the Host of the request, so what you see depends on how you reach it. A production deployment fixes the hostname in its own configuration.

The client

Create an OpenID Connect client for the app. The fields below are what the test realm sets. The names in backticks are the realm-import attributes it uses.

SettingValueWhy
Client authenticationOff (publicClient: true)A native app cannot keep a secret, and the SDK has no secret to send.
Standard flowOn (standardFlowEnabled)The authorization code flow is the only flow the SDK uses.
Implicit flow, direct access grants, service accountsOffNot used. The test realm leaves them off.
Valid redirect URIsThe exact redirectUri string, one entry per platform if they differKeycloak matches exactly and case-sensitively. A wildcard is allowed only at the end of a pattern.
Valid post logout redirect URIsThe exact postLogoutRedirectUri (attributes."post.logout.redirect.uris")Without a registered URI Keycloak does not redirect back after sign-out.
PKCE code challenge methodS256 (attributes."pkce.code.challenge.method")Makes Keycloak refuse a request without S256. The SDK always sends it.
Web originsEmptyA native app makes no cross-origin browser requests.

Redirect URIs for Android App Links and iOS Universal Links are plain entries in that list. The test realm registers a single https URL. Prefer the https form, as everywhere (redirect setup).

Scopes and refresh tokens

Keycloak issues a refresh token for the authorization code flow by default, without any special scope: the test client asks for openid profile only and receives one. The test realm also lists offline_access as an optional client scope.

offline_access is a different thing on Keycloak: it asks for an offline token. Per the Keycloak documentation an offline token is not bound by the SSO session timeouts, stays valid after the user signs out, must be used at least every 30 days (or within the realm’s Offline Session Idle setting), and requires the user to hold the realm role offline_access. Don’t add it unless you want that behaviour. The SDK never adds it. At create() it logs one warning if scopes_supported lists offline_access and your scopes don’t. For Keycloak the warning says a refresh token may not be issued, and Keycloak issues one regardless.

Refresh token rotation

Rotation is a realm setting: Revoke Refresh Token under Realm settings, Tokens. The test realm sets revokeRefreshToken: true and refreshTokenMaxReuse: 0. With it on, reusing a rotated refresh token is 400 invalid_grant (“Maximum allowed refresh token reuse exceeded”) and revokes the session. The SDK stores each rotated token before it forgets the old one, treats invalid_grant as Reauthenticate(GrantRejected) and never retries it. Set it deliberately.

If the app is killed after Keycloak rotated the token but before the new one was stored, the next refresh is a replay and ends in GrantRejected.

Sign-out

Keycloak’s end-session endpoint is .../protocol/openid-connect/logout, and discovery advertises it. With id_token_hint, a registered post_logout_redirect_uri and state it answers a direct 302 to the post-logout URI carrying state, with no confirmation page. Without an id_token_hint Keycloak shows a confirmation page.

The revocation endpoint is .../protocol/openid-connect/revoke. A public client sends client_id together with token and token_type_hint, and nothing else.

SDK configuration

The issuer below is {base}/realms/{realm} with the realm name example. It has no trailing slash, because Keycloak announces none.

Kotlin

import versola.oauth.OAuthClient
import versola.oauth.OAuthConfiguration

val client = OAuthClient.create(
    context,
    OAuthConfiguration(
        issuer = "https://id.example.com/realms/example",
        clientId = "example-app",
        redirectUri = "https://app.example.com/oauth",
        postLogoutRedirectUri = "https://app.example.com/signed-out",
        scopes = setOf("openid", "profile"),
    ),
)

Swift

import OAuthClient

let configuration = try OAuthConfiguration(
    issuer: "https://id.example.com/realms/example",
    clientId: "example-app",
    redirectUri: "https://app.example.com/oauth",
    postLogoutRedirectUri: "https://app.example.com/signed-out",
    scopes: ["openid", "profile"]
)
let client = try await OAuthClient.create(
    configuration: configuration,
    presentationAnchor: appKeyWindow  // from the Provider guides page
)

The Swift package product is VersolaOAuth. The module you import is OAuthClient.

Dart

import 'package:versola_oauth/versola_oauth.dart';

final client = await OAuthClient.create(const OAuthConfiguration(
  issuer: 'https://id.example.com/realms/example',
  clientId: 'example-app',
  redirectUri: 'https://app.example.com/oauth',
  postLogoutRedirectUri: 'https://app.example.com/signed-out',
  scopes: {'openid', 'profile'},
));

Notes on the values

  • scopes: openid is always added. Add profile, email and so on as the client’s scopes allow. Not offline_access, unless you want an offline token (see Scopes and refresh tokens).
  • postLogoutRedirectUri: set it. sessionEnded in the logout result is true only when the browser returns to this URI carrying the state the SDK sent. Without it the provider may end the session and the result still says false. On Android under Auth Tab an https value must be a verified App Link too.
  • endSessionEndpoint: not needed. Keycloak advertises it.
  • pushedAuthorization: leave the default. Keycloak advertises PAR, so WhenAdvertised pushes the request, and the integration tests ran that way.
  • tokenBinding: leave the default Bearer. See the DPoP row below.

What to expect

CapabilityKeycloak 26.4What the SDK does
Refresh tokenIssued without offline_accessAuthenticated with a refresh token. Rotation as the realm is set.
RevocationPresent. A public client sends client_id onlylogout() revokes, and tokenRevoked = true.
End-sessionPresent. Direct 302 with state when id_token_hint and a registered URI are sentsessionEnded = true when the redirect returns with the matching state.
UserInfoPresentuserInfo() works, and sub is checked against the ID token.
PARpushed_authorization_request_endpoint advertised, require_pushed_authorization_requests: false, public client acceptedThe default WhenAdvertised pushes the request and opens the browser with client_id and request_uri only. A rejection (for example an unregistered redirect URI) is Configuration(AuthorizationRejected) before any browser opens: Keycloak answers 400 invalid_request “Invalid parameter: redirect_uri”.
RFC 9207 iss on the redirectAdvertised and sentChecked on every redirect.
DPoPdpop_signing_alg_values_supported advertised with ES, RS and PS 256 to 512 and EdDSAThe default is Bearer. Asking for DPoP without requiring it uses DPoP, because ES256 is advertised.
ID-token algorithmRS256 by default. The discovery list also names HS256/384/512 and EdDSAThe SDK’s closed set refuses HMAC whatever discovery lists, and accepts RS256.
Date header on the token responseAbsentThe server-time skew correction doesn’t engage. iat and exp are checked against the device clock within clockSkewTolerance.
Rate limitsBrute-force detection only (password, OTP), off by default. No 429 or Retry-After in the documentationNothing to configure. A 429 or 503 with Retry-After from a proxy in front is honoured (Server errors, 429 and Retry-After)

Known limits

  • A refresh replay after a crash ends in Reauthenticate(GrantRejected) (see Refresh token rotation).
  • A realm that forces a required action, an MFA step or a consent screen shows those pages in the browser. The SDK isn’t involved.

How this was checked

The Android integration tests ran against quay.io/keycloak/keycloak:26.4 in the SDK’s CI, and all of them passed. The iOS and Flutter test drivers run the same tests against the same container. What the tests asserted:

  • Sign-in, an authenticated call to a protected origin, UserInfo and sign-out. The token request carried no client_secret, the ID token was validated before the session became Authenticated, and nothing went to a non-protected origin with an Authorization header.
  • Tampered ID tokens. A re-signed ID token with a wrong nonce, a bad signature or algorithm, and wrong iss, aud, exp, iat or auth_time was refused with the matching reason.
  • Explicit endpoints. The whole flow ran from explicit endpoints, with no request to any /.well-known/ path.
  • PAR. PAR was used when advertised, a rejected PAR surfaced as AuthorizationRejected before the browser, and the Never and Required mismatches gave ProviderUnsupported.
  • Sign-out variants. Sign-out ran with discovery’s end_session_endpoint removed, with revocation failing, and with the browser dismissed.
  • No refresh token. A client registered with refresh tokens off and ten-second access tokens ended in Reauthenticate(SessionExpired) with no token request.
  • Token scope. The token was attached to the configured origin and nowhere else.

The test realm is a throwaway: development mode, sslRequired: none, a test user.

Sources

Read on 2026-10-30. What the tests observed comes from the SDK’s integration tests, not from these pages.

Next steps