Post

Keycloak can make your authorization decisions. Mine doesn't.

Once sign-in works and the token validates, the next question every Keycloak implementor asks is some version of this: should Keycloak make the authorization decisions, or should my API?

Keycloak has a whole subsystem for the first option. It’s called Authorization Services: resources, scopes, policies and permissions, edited in the admin console and evaluated centrally. My Keycloak lab provisions a complete model of it in Terraform. And the API never asks it anything. That is a decision rather than an oversight, and this post is about why, and about what the API does instead.

It is also about a bug I found in my own step-up code while writing it, which turned out to be a neat illustration of the whole problem.

An API answering three authorization questions from the token, with Keycloak's Authorization Services off the request path

What Authorization Services actually is

RBAC answers “may this user edit documents?” It can’t answer “may this user edit this document?”, because that depends on data, not on the token. Authorization Services moves that second decision into Keycloak, where it can be audited and changed without redeploying anything.

The model has four parts, and in Terraform they look like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# RESOURCE: what is being protected
resource "keycloak_openid_client_authorization_resource" "document" {
  name   = "document"
  type   = "urn:docvault:resources:document"
  uris   = ["/documents/*"]
  scopes = ["document:view", "document:edit", "document:share", "document:delete"]
  owner_managed_access = true   # lets an owner share, once each document is registered with one
  # …
}

# POLICY: a rule. "Is an editor", "is the owner", "is office hours".
resource "keycloak_openid_client_role_policy" "admin" {
  name = "is-document-admin"
  role { id = keycloak_role.api["doc.admin"].id }
  # …
}

# PERMISSION: binds scopes on a resource to policies
resource "keycloak_openid_client_authorization_permission" "document_delete" {
  name              = "document-delete-permission"
  type              = "scope"       # more on this line below
  decision_strategy = "UNANIMOUS"   # every attached policy must pass
  resources = [keycloak_openid_client_authorization_resource.document.id]
  scopes    = [keycloak_openid_client_authorization_scope.document["document:delete"].id]
  policies  = [keycloak_openid_client_role_policy.admin.id]
}

It looks like a good model. Keep an eye on that type line, and look at what the realm reports when you apply it, because this is where people get caught.

ENFORCING enforces nothing on its own

The API’s client carries this:

1
2
3
4
authorization {
  policy_enforcement_mode = "ENFORCING"
  decision_strategy       = "UNANIMOUS"
}

Read that cold and it sounds as though Keycloak is now guarding your endpoints. It isn’t. Keycloak is not on your API’s request path, and it never sees the request. The browser sends a bearer token straight to your API. Keycloak doesn’t find out that the request happened, let alone decide on it.

policy_enforcement_mode controls how Keycloak answers when it is asked. With ENFORCING, a request for a resource that has no policy attached is denied rather than allowed. Nothing gets asked unless your resource server does the asking. That means the API forwarding the caller’s access token through the UMA grant:

1
2
3
4
5
6
7
8
POST /realms/docvault/protocol/openid-connect/token
Authorization: Bearer <the user's access token>
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:uma-ticket
&audience=docvault-api
&permission=document#document:delete
&response_mode=decision

Keycloak evaluates the permission for the user the token belongs to. With response_mode=decision it answers {"result":true} or a 403 not_authorized. Without it, it returns a Requesting Party Token (an RPT) carrying the granted permissions. If you never make that call, every resource, policy and permission you clicked together is inert. It’s the same shape of failure as the rest of this series: correct-looking configuration, no error, no effect.

So I wrote that down where nobody could miss it, at the top of the Terraform file:

1
2
3
# STATUS: this model is provisioned but NOT enforced at runtime. The API does
# local RBAC on the roles in the token plus tenant scoping from the group path;
# it never asks Keycloak for a decision.

And mine was wrong, and nothing said so

Writing that down didn’t make the model right. After a reviewer asked me to show the UMA call properly, I made it for real against the lab, with alice, a doc.editor:

1
alice  document:view    -> 403 {"error":"access_denied","error_description":"not_authorized"}

An editor, refused view. Keycloak’s policy evaluator in the admin console shows why. It evaluated every permission on the resource for that one request, and document-delete-permission said no:

1
2
3
4
document with scopes [document:view]: DENY
  document-delete-permission  DENY
  document-edit-permission    PERMIT
  document-view-permission    PERMIT

Leave out type, and the Terraform provider creates a resource permission. A resource permission ignores its scopes list and applies to every scope of the resource. The resource server’s strategy is UNANIMOUS, so viewing a document also had to pass the admin-only delete permission. Under the model as I’d written it, only an admin could do anything at all. It had been that way since the first commit, and nothing ever reported it, because nothing ever asked.

type = "scope" fixes it:

1
2
3
4
alice  document:view    -> {"result":true}
alice  document:edit    -> {"result":true}
alice  document:delete  -> 403 not_authorized
carol  document:delete  -> {"result":true}

There’s a second trap on the way to that fix. Keycloak ignores a type change on an existing permission. terraform apply reports “4 changed”, Keycloak keeps resource, and the next plan wants to make the same change again. The provider doesn’t mark type as force-new, so fixing the HCL doesn’t fix a realm that already exists. My first idea, replace_triggered_by pointing at a new terraform_data, didn’t work either, because creating the trigger doesn’t count as changing it. What does work is giving the permissions new resource addresses and new names (document-view-scope-permission). A plain terraform apply then deletes the old resource permissions and creates scope ones. The names have to change as well as the addresses, because the delete and the create run in parallel and a reused name can collide.

This is the strongest argument in the post, and I didn’t plan it. A policy model nobody calls is a policy model nobody tests. If you do adopt Authorization Services, make the decision call from a test, with a real user’s token, for a scope that user should and shouldn’t have.

The lab does that now. make assert-authz asks Keycloak’s policy evaluator for eleven user/scope decisions, every granted scope checked both ways, and runs in CI after the realm is applied. Seed the realm from the old HCL and it fails on exactly the four decisions the bug gets wrong: the editor refused view, edit and share, the reader refused view.

Why the API doesn’t ask

Calling Keycloak for decisions has a price, and it’s worth stating plainly:

  • A network call per decision, or an RPT you now have to cache and invalidate.
  • Keycloak on your request path. If the IdP is slow, every protected request is slow. If it’s down, you’re down, not just unable to sign new users in.
  • A client secret, as soon as you mean “this document”. The decision call above needs only the user’s token. But to decide per document, each one must first be registered as a resource through Keycloak’s Protection API, and that API accepts only the resource server’s own token. Try it with a user’s token and Keycloak replies “Client application [docvault-web-react] is not registered as a resource server.” So the API holds a client secret, and the realm needs allow_remote_resource_management switched on (mine has it off: “Remote management is disabled.”).
  • A confidential resource-server client. This is also why docvault-api can’t be BEARER-ONLY: Keycloak answers that combination with a bare HTTP 500.

That price buys real things:

  • Policy that changes without a deploy. Someone with admin-console access can tighten a rule on a Friday afternoon.
  • User-managed sharing. With owner_managed_access, a document’s owner grants access to another user and Keycloak remembers it. This is UMA proper, and it’s the strongest reason to use the feature. It only works per document, though: each one has to be registered through the Protection API with its owner, and the flag on a resource type is not enough (see the client-secret point above).
  • One policy shared by many services, all evaluating the same rule in the same place.
  • Central audit of who was allowed to do what.

My API has none of those needs. It has four roles, one tenant boundary and one step-up rule, and all of them change at the same speed as the code. Deciding locally means the decision costs microseconds, works when Keycloak is down, and sits in a unit test instead of an admin console.

That’s the actual rule of thumb. If your policies change faster than your deploys, or users share things with each other, Authorization Services earns its round-trip. If your policies are shaped like your code, keep them in your code.

What the API decides instead

Without Authorization Services, the API still has to answer three separate questions. Each is answered by a different part of the token, and mixing them up is the most common authorization mistake I see.

flowchart TD
    T["<b>Validated token</b>"]
    T --> R{"<b>Role</b>, if required<br/>resource_access.docvault-api.roles<br/><i>what may the USER do?</i>"}
    R -->|no| F403["403"]
    R -->|yes| S{"<b>Scope</b>, if required<br/>scope<br/><i>what did the CLIENT get consent for?</i>"}
    S -->|no| F403
    S -->|yes| G{"<b>Tenant</b><br/>groups: /acme/engineering<br/><i>WHICH data?</i>"}
    G -->|not yours| F404["404"]
    G -->|yours| OK["endpoint"]

It’s an AND, not a menu. An endpoint applies the checks it needs, and every check it applies must pass. The document endpoints check role and tenant. The analytics endpoint checks scope instead of a role, which is the point of section 2.

1. Role: what may this user do?

The roles live in resource_access["docvault-api"].roles. An IClaimsTransformation flattens them into the role claims ASP.NET Core reads (which the 401 post covers), and after that it’s ordinary policy code:

1
2
3
4
5
6
7
.AddPolicy(Policies.ReadDocuments,  p => p.RequireRole("doc.reader", "doc.editor", "doc.admin"))
.AddPolicy(Policies.WriteDocuments, p => p.RequireRole("doc.editor", "doc.admin"))
.AddPolicy(Policies.AdminDocuments, p => p.RequireRole("doc.admin"))

// A realm role rather than a client role: platform operators are not
// specific to this API.
.AddPolicy(Policies.PlatformAdmin,  p => p.RequireRole("platform-admin"))

The client-role/realm-role split matters more than it looks. doc.admin is a role of this API. platform-admin is a role in the realm, meaning an operator of the whole platform. Once both have been flattened into the same claim type they could collide, so there’s a test that a doc.admin token must not reach the platform endpoint. For the same reason, the transformation imports realm roles and only this client’s entry in resource_access, never roles that belong to any other client in the realm.

Roles describe what the user may do. Scopes describe what the client was authorised to ask for. They are different questions, and a user with every role in the realm should still be refused if the app they’re using never got consent for the data:

1
2
3
4
5
6
// Scope-gated rather than role-gated. Scopes describe what the CLIENT was
// authorised to ask for; roles describe what the USER may do.
.AddPolicy(Policies.Analytics, p => p.RequireAssertion(ctx =>
    ctx.User.FindFirst("scope")?.Value
        .Split(' ', StringSplitOptions.RemoveEmptyEntries)
        .Contains("analytics:read") == true));

The test proves exactly that: a doc.admin without analytics:read gets 403, and a lowly doc.reader with it gets 200.

3. Tenant: which data?

This is the question Authorization Services exists for (this document, not documents in general), and the one the API has to answer locally anyway. The tenant is in the token as a group path, groups: ["/acme/engineering"]. The Terraform post explains why a path rather than an attribute. Every tenant-scoped endpoint reads it from the token:

1
2
3
// The tenant comes from the TOKEN, never from the request body. Trusting a
// client-supplied tenant id is how cross-tenant writes happen.
var document = store.Add(tenant.Tenant, request.Title, user.Identity?.Name ?? "unknown", isClassified: false);

404, and meaning it

The first two questions fail with 403. For why that is 403 and not 401, and what dave the role-less demo user proves, see the first section of the 401 post. The tenant question fails differently:

1
2
3
4
5
6
// Return 404, not 403, for a document in another tenant. A 403 would confirm
// the document exists, leaking information across the tenant boundary.
if (document is null || document.Tenant != tenant.Tenant)
{
    return Results.NotFound();
}

Most write-ups stop at “return 404”. I only trusted it once I had written the test that says why: a 404 only helps if it is indistinguishable from a document that never existed.

1
2
3
4
5
6
7
8
9
10
11
12
[Fact]
public async Task Another_tenants_document_is_indistinguishable_from_one_that_never_existed()
{
    // …Acme creates a document; a Globex admin tries to delete it…
    var existsElsewhere = await client.DeleteAsync($"/documents/{acmeDocument.Id}");
    var neverExisted    = await client.DeleteAsync($"/documents/{Guid.NewGuid()}");

    Assert.Equal(neverExisted.StatusCode, existsElsewhere.StatusCode);
    Assert.Equal(
        await neverExisted.Content.ReadAsStringAsync(),
        await existsElsewhere.Content.ReadAsStringAsync());
}

The moment the two responses differ, whether in status, body or a helpful "title": "Wrong tenant", the endpoint becomes an oracle for enumerating other tenants’ ids. Note also that the Globex caller holds doc.admin. Without that, the role check would refuse them first, and the test would be asserting the policy rather than the boundary.

The bug: offering a fix that couldn’t work

Step-up is the fourth kind of decision. Reading classified documents needs doc.admin and a token issued after an OTP (acr=silver). When the ACR is too low, the API sends the RFC 9470 challenge telling the client how to recover:

1
2
3
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="insufficient_user_authentication",
  error_description="A higher authentication level is required", acr_values="silver"

The whole point of that challenge is to tell “you need to step up” apart from “you will never be allowed”. The handler behind it looked like this:

1
2
3
4
5
6
7
8
var stepUp = authorizeResult.AuthorizationFailure?.FailedRequirements
    .OfType<StepUpAcrRequirement>()
    .FirstOrDefault();

if (stepUp is not null && context.User.Identity?.IsAuthenticated == true)
{
    // … issue the challenge
}

It reads as “if the ACR requirement failed, challenge”. The catch is that FailedRequirements doesn’t hold the failed requirement. It holds every requirement nobody satisfied. So think of alice, a doc.editor signed in with a password. She fails the role requirement and the ACR requirement. She got the challenge, the SPA offered her “Verify with OTP”, she dutifully did it, and then she got a plain 403. The code sent her through MFA to reach a dead end, the exact case the handler exists to prevent.

It passed every test, because every test had either the right role or the right ACR. None had neither.

The fix is one condition: challenge only when stepping up is the whole fix.

1
2
3
4
5
6
var failed = authorizeResult.AuthorizationFailure?.FailedRequirements.ToList() ?? [];
var stepUp = failed.OfType<StepUpAcrRequirement>().FirstOrDefault();

if (stepUp is not null
    && failed.All(r => r is StepUpAcrRequirement)
    && context.User.Identity?.IsAuthenticated == true)

There’s one more ASP.NET Core detail tangled up in this, which I confirmed with a ten-line probe rather than trusting the docs. If any handler calls context.Fail(), FailedRequirements comes back empty, and all you get is FailCalled = true. That’s why the ACR handler deliberately never calls Fail() and just leaves its requirement unmet. The obvious “tidy-up” of adding a Fail() would make the step-up challenge impossible to detect, and every user would get a bare 403 again.

And the status code

Fixing that turned up something more embarrassing. In the 401 post I argued at length that the step-up challenge is a 401, as in both of RFC 9470’s examples: the authentication event was insufficient, not the permissions. The argument was right. My API was returning 403. The post and the code had disagreed since the day it went up.

It now returns 401, and that changes one more thing for clients. A lot of SPAs treat any 401 as “session expired, go and sign in again”. This one must be recognised first, from the header, and answered with acr_values. So every client in the lab reads WWW-Authenticate before it decides what a 401 means:

1
2
3
4
5
6
// RFC 9470 sends the step-up challenge as a 401, so the header must be read BEFORE
// any "401 means the session expired" handling gets a chance to swallow it.
if (response.status === 401 || response.status === 403) {
  const requiredAcr = parseStepUpChallenge(response.headers.get('WWW-Authenticate'));
  if (requiredAcr) throw new StepUpRequiredError(requiredAcr);
}

After the fix I checked it against a real Keycloak in a real browser. carol (admin, password only) gets a 401 with the challenge and is offered OTP. alice gets a 403 with no challenge and no offer.

The short version

QuestionAnswered byFails with
Is this a valid token for this API?signature, iss, aud, exp401
May this user do this?client role in resource_access403
Did this client get consent?scope403
Is this their data?tenant from groups, never the body404, indistinguishable from missing
Did they authenticate strongly enough?acr401 + challenge, only if stepping up would help
Should Keycloak decide instead?Authorization Services + UMAonly if policy changes faster than your code

Try it, then break it

1
2
3
git clone https://github.com/MagnusJohansson/keycloak-poc
cd keycloak-poc
make test

No Docker, no network: 87 tests. Then open StepUpAuthorization.cs, delete the failed.All(...) line, and run it again:

1
2
3
Failed DocVault.Api.Tests.Security.StepUpTests
       .No_step_up_is_offered_when_stepping_up_would_not_help
Failed: 1, Passed: 31

That test didn’t exist until I started writing this post. The bug did.

Earlier in this series: why your realm belongs in Terraform, the misconfigurations that never produce an error, OAuth beyond the SPA, Keycloak on Azure Container Apps, and your auth tests are testing your test double.

This post is licensed under CC BY 4.0 by the author.