A small, end-to-end demo of role-based access control:
- Keycloak issues tokens that carry the user's roles and permissions.
- Rbac.Api (ASP.NET Core minimal API) enforces them with ordinary
RequireRole/RequireClaimpolicies. - Rbac.Wpf signs in through the system browser (Authorization Code + PKCE, with a consent screen), then asks Keycloak Authorization Services which permissions the user holds and shows or hides UI accordingly. The server still enforces every rule.
keycloak/realm-export.json realm rbac-demo: roles, clients, mappers, test users
keycloak/consent-demo-realm.json realm consent-demo: a public client with consent required + two users with profile data
keycloak/setup-realm.ps1 imports a realm file via the Keycloak Admin REST API (-RealmFile picks which)
scripts/smoke-test.ps1 calls every endpoint as every user and checks the status codes
src/Rbac.Api the protected API -> http://localhost:5100
src/Rbac.Wpf the desktop client (realm rbac-demo)
src/Rbac.Cli console app: asks Keycloak Authorization Services for permission decisions
src/Consent.Wpf second desktop client: the consent demo (realm consent-demo, no API, no roles)
simple/ minimal stand-alone sample: realm simple-demo, Simple.Api (:5200), Simple.Console
Everything is a client role on rbac-api; the realm itself carries nothing app-specific.
Two kinds of role are told apart by name:
| Kind | Naming | Values | In the token |
|---|---|---|---|
| Who you are | plain name | admin, user |
resource_access.rbac-api.roles |
| What you may do | resource:action |
order:read, order:write, order:delete |
resource_access.rbac-api.roles |
admin and user are composite roles: admin grants all three permissions, user grants order:read.
Users do not get roles directly. They are members of a group, and the group carries the role mapping:
| Group | Client roles on rbac-api |
|---|---|
/admins |
admin |
/users |
user |
Permissions can still be assigned directly to a user on top of the group (carol has order:write in addition to
/users). The token looks exactly the same whichever path a role came from.
Note: Keycloak evaluates effective roles as the union of direct roles, group roles and composites, so you can mix the approaches. For a clean demo I'd move all three users to groups and keep carol's extra permission as a direct assignment to show both paths.
The API's KeycloakClaimsTransformer reads that one list once per request and turns plain names into standard
role claims and resource:action names into permission claims. Everything after that is vanilla ASP.NET Core
authorization (RequireRole("admin"), RequireClaim("permission", "order:read")).
| Endpoint | Requires |
|---|---|
GET /api/me |
any signed-in user |
GET /api/orders |
permission order:read |
POST /api/orders |
permission order:write |
DELETE /api/orders/{id} |
permission order:delete |
GET /api/admin/audit |
role admin |
| User | Password | Group | Extra permission | Effective access | Sees in the WPF app |
|---|---|---|---|---|---|
| alice | alice |
/admins |
order:read, order:write, order:delete, admin endpoints |
orders, new-order form, delete buttons, admin audit log | |
| carol | carol |
/users |
order:write |
order:read, order:write |
orders, new-order form |
| bob | bob |
/users |
order:read |
orders, read-only |
These users exist only in the rbac-demo realm. The Keycloak admin console (realm master) uses the admin account
from your Docker compose environment and is not touched by this project.
| Client | Type | Used by |
|---|---|---|
rbac-api |
confidential, no login flows, Authorization Services enabled | the API: owns all roles (admin, user, order:*) and is the token audience (aud). Carries the same demo resources, scopes, policies and permissions as rbac-wpf and rbac-console. |
rbac-wpf |
confidential (secret rbac-wpf-secret), PKCE S256, redirect http://127.0.0.1:7890/*, Authorization Services enabled, consent required, login theme keycloak.v2 |
the WPF app. It is confidential only because Keycloak allows Authorization Services on confidential clients alone; a secret compiled into a desktop app protects nothing, so treat it as a demo convenience. |
rbac-test-cli |
public, password grant | scripts/smoke-test.ps1 and Rbac.Api.http only. Test convenience, delete it for anything real. |
rbac-console |
confidential (secret rbac-console-secret), password grant, Authorization Services enabled |
src/Rbac.Cli. The resource server for the Authorization Services section. The fixed secret and the password grant are test conveniences, same caveat as above. |
rbac-wpf has Consent required on and Login theme set to keycloak.v2 (realm JSON: consentRequired,
attributes.login_theme, attributes.display.on.consent.screen, attributes.consent.screen.text). The first time a
user signs in through the WPF app, Keycloak shows the keycloak.v2 consent page listing the client (RBAC WPF desktop
app) and the requested scopes (user profile, email, roles). Consent is remembered per user; revoke it in the Account
console (http://localhost:8180/realms/rbac-demo/account, Applications) to see the page again. Re-importing the
realm with -Recreate resets all consents. The other clients have no login screen, so these settings only exist here.
A second, independent WPF app whose only subject is consent. It lives in the realm consent-demo
(keycloak/consent-demo-realm.json), so nothing in it touches rbac-demo:
| Piece | Setting |
|---|---|
Client consent-wpf |
public, Authorization Code + PKCE S256, loopback redirect http://127.0.0.1:7891/*, Consent required, login theme keycloak.v2, consent screen text Consent Demo desktop app |
| Default client scopes | basic, profile, email, roles (always requested; roles is what puts account into aud so the app may call the account API) |
| Optional client scopes | address, phone, offline_access (only requested when the app asks for them) |
| Users | dave / erin (password = username) with first/last name, email, phoneNumber and the address attributes the built-in phone and address mappers read |
| User profile | unmanagedAttributePolicy: ENABLED, so those extra attributes survive the import and show up in the admin console |
What the app does:
- While signed out you tick which optional scopes to request. The default scopes are listed but cannot be
un-ticked: Keycloak applies default client scopes whether or not the
scopeparameter names them. - Sign in with Keycloak opens the keycloak.v2 login page, then the consent page, which lists exactly the scopes that were requested (User profile, Email address, User roles, plus Address / Phone number / Offline access when ticked) and the client's consent text.
- After the redirect the app shows the granted scopes (the
scopeclaim of the access token) and every ID-token claim those scopes produced (name,email,phone_number, the nestedaddressobject, ...).rolesandbasicare on the consent page but never in thescopeclaim: those built-in scopes have Include in token scope switched off. - Keycloak stores the consent: signing in again does not ask, unless the app requests a scope that was not
granted before (tick
offline_accesson the second run to see the consent page come back for that one scope). - Revoke consent calls the account REST API with the user's own access token
(
DELETE /realms/consent-demo/account/applications/consent-wpf/consent), then signs out locally; the next sign-in asks again. Open account console opens/realms/consent-demo/account/#/applications, where the user can see and revoke the same consent without the app.
.\keycloak\setup-realm.ps1 -RealmFile .\keycloak\consent-demo-realm.json # once (add -Recreate to re-import)
dotnet run --project src/Consent.WpfThe app and Rbac.Wpf can run side by side: different realms, different clients, different loopback ports
(7891 vs 7890).
The API above decides on its own by reading roles out of the token. Keycloak can also make the decision for you:
a client with Authorization Services enabled becomes a resource server that knows its resources, the
scopes (actions) on them, and the policies that grant them. A caller presents a user's access token and asks
"may this user do scope on resource?"; Keycloak answers yes/no or hands back a Requesting Party Token
(RPT) whose authorization.permissions claim lists everything the user may do.
rbac-api, rbac-wpf and rbac-console are all set up this way in keycloak/realm-export.json
(authorizationSettings), with identical demo content. The policies reuse the rbac-api roles, so the two decision
layers always agree:
| Resource | Scopes | Permission | Granted by policy |
|---|---|---|---|
Orders (/api/orders/*) |
order:read |
Read orders | Has order:read (client role rbac-api/order:read) |
order:write |
Write orders | Has order:write (client role rbac-api/order:write) |
|
order:delete |
Delete orders | Has order:delete (client role rbac-api/order:delete) |
|
Audit log (/api/admin/audit) |
audit:read |
Read audit log | Is admin (client role rbac-api/admin) |
The role policies read the roles from the user's token, which is why rbac-wpf and rbac-console have
fullScopeAllowed and the roles scope: the token must carry resource_access.rbac-api.roles or every policy denies.
rbac-api has no login flow of its own; to evaluate its settings, log in through another client and pass
audience=rbac-api.
The WPF app uses this for its UI. After login (and after every token refresh) AuthService sends the access token
back to Keycloak with audience=rbac-wpf and response_mode=permissions (src/Rbac.Wpf/Auth/KeycloakAuthzClient.cs).
The answer, a list of resource#scope pairs such as Orders#order:read, fills the Permissions chips and drives
CanReadOrders, CanWriteOrders, CanDeleteOrders and IsAdmin (= Audit log#audit:read) in MainViewModel.
The token roles are still shown, but nothing in the UI is derived from them any more. A 403 access_denied from
Keycloak means "nothing granted" and the app shows a screen with no orders card and no admin card.
All of it goes through the ordinary token endpoint with the UMA ticket grant:
POST /realms/rbac-demo/protocol/openid-connect/token
Authorization: Bearer <user access token>
grant_type=urn:ietf:params:oauth:grant-type:uma-ticket
&audience=rbac-console # the resource server whose policies are evaluated
&permission=Orders#order:write # optional: ask about one resource#scope
&response_mode=decision # decision -> {"result":true} or 403 access_denied
# permissions -> JSON list of granted (resource, scopes)
# omitted -> a full RPT (JWT with an "authorization" claim)
src/Rbac.Cli does exactly that for every test user and compares the answers with what the groups and composites
should produce. It exits with 1 if any decision differs and 2 if Keycloak or the client is missing:
dotnet run --project src/Rbac.Cli| Permission | alice | carol | bob |
|---|---|---|---|
Orders#order:read |
GRANT | GRANT | GRANT |
Orders#order:write |
GRANT | GRANT | DENY |
Orders#order:delete |
GRANT | DENY | DENY |
Audit log#audit:read |
GRANT | DENY | DENY |
It also prints alice's decoded RPT so you can see the authorization.permissions claim an API would check if it
delegated the decision to Keycloak. Settings come from KEYCLOAK_URL, KEYCLOAK_REALM, KEYCLOAK_CLIENT_ID and
KEYCLOAK_CLIENT_SECRET (defaults match this repo). KEYCLOAK_AUDIENCE picks the resource server to evaluate; it
defaults to the login client, so to check the settings on rbac-api instead:
$env:KEYCLOAK_AUDIENCE = 'rbac-api'; dotnet run --project src/Rbac.Cli # the API's settings
$env:KEYCLOAK_AUDIENCE = 'rbac-wpf'; dotnet run --project src/Rbac.Cli # the WPF client's settingsExisting realms do not pick the client up automatically: run
.\keycloak\setup-realm.ps1 -Recreateonce.
-
Keycloak must be running on
http://localhost:8180(seeD:\docker\keycloak\docker-compose.yml). Always uselocalhost, not127.0.0.1, so the token issuer matches the API's authority. -
Create the realm (asks for the admin password, or set
$env:KC_ADMIN_PASSWORD):.\keycloak\setup-realm.ps1 # add -Recreate to drop and re-import .\keycloak\setup-realm.ps1 -RealmFile .\keycloak\consent-demo-realm.json # only for the consent demo
-
Run the API:
dotnet run --project src/Rbac.Api
-
Run the WPF app and click Sign in with Keycloak. Your browser opens the Keycloak login page (keycloak.v2 theme); after signing in, and on the first login a consent page, the browser redirects to the app's loopback listener on port 7890. The app then asks Keycloak Authorization Services what the user may do.
dotnet run --project src/Rbac.Wpf
-
Optional: check Keycloak's own permission decisions with the console app (see the section above):
dotnet run --project src/Rbac.Cli
-
Optional: run the consent demo (needs the
consent-demorealm from step 2, not the API):dotnet run --project src/Consent.Wpf
-
Optional: smoke-test the API without the UI:
.\scripts\smoke-test.ps1Expected result:
Endpoint no token bob carol alice GET /api/me 401 200 200 200 GET /api/orders 401 200 200 200 POST /api/orders 401 403 201 201 DELETE /api/orders/{id} 401 403 403 204 GET /api/admin/audit 401 403 403 200
- Login hangs on "Waiting for you to sign in" or the API never validates tokens: Docker Desktop publishes
8180 on both
127.0.0.1and[::1], and on some Windows hosts the IPv6 forward silently drops packets while .NET tries[::1]first forlocalhost. Both apps therefore route the Keycloak back-channel throughIpv4Http.cs, which connects over IPv4 only. If you remove it and see hangs, that is why. - 401 from the API after login: the token issuer must equal
Keycloak:Authorityinappsettings.json. Open Keycloak and the apps through the same host name (localhost). - One 401 right after
setup-realm.ps1 -Recreatewhile the API keeps running: re-creating the realm rotates its signing keys and the API still has the old ones cached. The first request with an unknown key id fails and triggers a refresh; everything after that succeeds. Restart the API or just retry. - Authorization → Evaluate shows the wrong scope per permission (e.g. for bob, Read orders says it granted
order:write): a display bug in the Keycloak 26.7 admin console, the expanded permission rows all show the scopes of one permission picked by the resource's row index (keycloak/keycloak#54141, fix in PR #54142). The resource row's Overall Results / Scopes, the Show authorization data button andsrc/Rbac.Cliread the real evaluation result and are correct. - WPF login fails with
invalid_client/unauthorized_client, or alice gets no permissions: the realm in Keycloak predates therbac-wpfchanges (confidential client with Authorization Services). Run.\keycloak\setup-realm.ps1 -Recreateand restart the API. setup-realm.ps1fails with 401 mid-way: the admin token lives 60 s; the script fetches a fresh one before the import, but a very slow Keycloak can still exceed it. Just re-run.
- Sign in as bob, then press Call admin endpoint anyway. The button is visible to everyone on purpose:
the API answers
403 Forbidden, proving the server enforces the rules even when a client ignores the UI. - In the Keycloak admin console (realm
rbac-demo→ Users → bob → Groups → Join Group) add bob to/admins, sign out and back in: the delete buttons and the admin panel appear. No role was touched, only membership. - Or assign a single permission directly (Users → bob → Role mapping → Assign role → filter by clients →
rbac-api order:write): the New order form appears while the rest stays read-only. - Add a new permission (e.g.
order:approve) as a client role onrbac-api(Clients → rbac-api → Roles), add it to theadmincomposite, then add a policy and endpoint inProgram.cs. For the WPF app, also add the scope to theOrdersresource plus a Has order:approve policy and an Approve orders permission under Clients → rbac-wpf → Authorization, and aCanApproveOrders = HasScope("order:approve")flag inMainViewModel. - Change what the UI shows without touching any role: Clients → rbac-wpf → Authorization → Permissions →
Delete orders, replace the policy Has order:delete with Has order:write. After her next sign-in carol sees
delete buttons, yet the API still answers
403to her deletes: the UI follows Keycloak's decision forrbac-wpf, the API enforces its own rules. - Add a new role (e.g.
auditor) the same way; because it has no:it becomes a role claim, usable withRequireRole("auditor"). - Open Clients → rbac-wpf (or rbac-console) → Authorization → Evaluate, pick bob and the
Ordersresource, and compare the result with the WPF app's Permissions chips or with whatsrc/Rbac.Cliprints. Then edit the Write orders permission to also apply the Is admin policy and re-run the console app: nothing changes for alice, and carol now gets DENY because the decision strategy is unanimous.