Skip to content

About

Role-based access control demo: Keycloak + .NET 10 minimal API + WPF client (PKCE login, dynamic UI)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

15 Commits

Folders and files

Repository files navigation

Keycloak RBAC Demo (.NET 10 API + WPF)

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 / RequireClaim policies.
  • 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

Authorization model

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")).

Endpoints

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

Test users

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.

Keycloak clients

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.

Consent and login theme (rbac-wpf -> Login settings)

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.

Consent demo: src/Consent.Wpf in its own realm

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:

  1. 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 scope parameter names them.
  2. 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.
  3. After the redirect the app shows the granted scopes (the scope claim of the access token) and every ID-token claim those scopes produced (name, email, phone_number, the nested address object, ...). roles and basic are on the consent page but never in the scope claim: those built-in scopes have Include in token scope switched off.
  4. Keycloak stores the consent: signing in again does not ask, unless the app requests a scope that was not granted before (tick offline_access on the second run to see the consent page come back for that one scope).
  5. 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.Wpf

The app and Rbac.Wpf can run side by side: different realms, different clients, different loopback ports (7891 vs 7890).

Authorization Services (Keycloak-side permission decisions)

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 settings

Existing realms do not pick the client up automatically: run .\keycloak\setup-realm.ps1 -Recreate once.

Running it

  1. Keycloak must be running on http://localhost:8180 (see D:\docker\keycloak\docker-compose.yml). Always use localhost, not 127.0.0.1, so the token issuer matches the API's authority.

  2. 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
  3. Run the API:

    dotnet run --project src/Rbac.Api
  4. 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
  5. Optional: check Keycloak's own permission decisions with the console app (see the section above):

    dotnet run --project src/Rbac.Cli
  6. Optional: run the consent demo (needs the consent-demo realm from step 2, not the API):

    dotnet run --project src/Consent.Wpf
  7. Optional: smoke-test the API without the UI:

    .\scripts\smoke-test.ps1

    Expected 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

Troubleshooting

  • Login hangs on "Waiting for you to sign in" or the API never validates tokens: Docker Desktop publishes 8180 on both 127.0.0.1 and [::1], and on some Windows hosts the IPv6 forward silently drops packets while .NET tries [::1] first for localhost. Both apps therefore route the Keycloak back-channel through Ipv4Http.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:Authority in appsettings.json. Open Keycloak and the apps through the same host name (localhost).
  • One 401 right after setup-realm.ps1 -Recreate while 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 and src/Rbac.Cli read 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 the rbac-wpf changes (confidential client with Authorization Services). Run .\keycloak\setup-realm.ps1 -Recreate and restart the API.
  • setup-realm.ps1 fails 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.

Things to try

  • 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 on rbac-api (Clients → rbac-api → Roles), add it to the admin composite, then add a policy and endpoint in Program.cs. For the WPF app, also add the scope to the Orders resource plus a Has order:approve policy and an Approve orders permission under Clients → rbac-wpf → Authorization, and a CanApproveOrders = HasScope("order:approve") flag in MainViewModel.
  • 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 403 to her deletes: the UI follows Keycloak's decision for rbac-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 with RequireRole("auditor").
  • Open Clients → rbac-wpf (or rbac-console) → Authorization → Evaluate, pick bob and the Orders resource, and compare the result with the WPF app's Permissions chips or with what src/Rbac.Cli prints. 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.

About

Role-based access control demo: Keycloak + .NET 10 minimal API + WPF client (PKCE login, dynamic UI)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages