Configuring BookStack (group-aware pattern)
Pattern: group-aware. BookStack authenticates users through Keycloak and reads their group membership from the token, mapping Keycloak groups onto BookStack roles so that access is driven entirely by the IdP. This is the pattern for any app that should grant permissions based on what groups a user belongs to. It is also where the interesting failures live, because now a claim has to travel from Keycloak into the app in exactly the right form.
Keycloak side
The client
| Setting | Value | Why |
|---|---|---|
| Client ID | bookstack |
Matches OIDC_CLIENT_ID in the app. |
| Client authentication | On (confidential) | Back-channel code exchange with a client secret. |
| Standard flow | Enabled | Authorization-code flow. |
| PKCE method | S256 | Matches the app. |
| Valid redirect URI | https://bookstack.example.org/oidc/callback |
App callback. |
| Valid post-logout redirect URI | https://bookstack.example.org/ |
Where the user lands after single logout. |
BookStack accepts only RS256 for token signing. Keycloak signs with RS256 by default, so this usually needs no change, but it is worth confirming: if the realm or client is ever set to a different algorithm, BookStack rejects the tokens as invalid even though everything else is correct. Verify the signing algorithm per app rather than assuming.
The group scope and mapper — the part that trips everyone
BookStack requests a scope named groups and reads a claim named groups. Getting those to exist and arrive correctly is a two-step that is easy to get half-right.
Step 1 — create a client scope, not just a mapper. In Keycloak, create a client scope named groups (type: Default), then add a Group Membership mapper to that scope:
| Mapper setting | Value |
|---|---|
| Mapper type | Group Membership |
| Name | groups |
| Token Claim Name | groups |
| Full group path | On |
| Add to ID token | On |
| Add to access token | On |
| Add to userinfo | On |
Then assign the groups scope as a default scope on the bookstack client.
Step 2 — create the groups themselves: bookstack/admin, bookstack/editor, bookstack/viewer. A user's membership in these drives their BookStack role.
BookStack side
BookStack is configured through environment variables (here, in its docker-compose.yml). The OIDC-relevant ones, secrets redacted:
environment:
- AUTH_METHOD=oidc
- AUTH_AUTO_INITIATE=false # show the login page; don't force-redirect to Keycloak
- OIDC_NAME=LandisFam SSO
- OIDC_DISPLAY_NAME_CLAIMS=name
- OIDC_CLIENT_ID=bookstack
- OIDC_CLIENT_SECRET=REDACTED
- OIDC_ISSUER=https://auth.example.org/realms/landisfam
- OIDC_ISSUER_DISCOVER=true # resolve endpoints from the discovery document
- OIDC_ADDITIONAL_SCOPES=groups # request the 'groups' scope (must exist in Keycloak)
- OIDC_GROUPS_CLAIM=groups # read groups from the 'groups' claim
- OIDC_USER_TO_GROUPS=true # map incoming groups onto BookStack roles
- OIDC_END_SESSION_ENDPOINT=true # enable single logout
For group mapping to take effect, BookStack roles must exist whose names match the Keycloak group names it receives (admin, editor, viewer). BookStack matches the incoming group to a role of the same name.
A note on
OIDC_REMOVE_FROM_GROUPS: this option makes Keycloak the sole authority on group membership, removing a user from BookStack roles they are no longer in on the IdP. It is powerful and correct in principle, but enable it only after group mapping is confirmed working, or a misconfigured claim can strip everyone's access on their next login.
Gotchas, in context
These are the three failures worth internalizing, because each is an instance of the governing principle: the claim has to reach the exact token the app reads, under the exact name it expects.
1. A mapper is not a scope
If you create the group-membership mapper but never create a client scope named groups, login fails outright:
error_description: Invalid scopes: openid profile email groups
The app requested a groups scope that Keycloak does not recognize, because a mapper describes how a claim is built while a client scope is the named thing a client is allowed to request. Requesting a scope that doesn't exist is rejected before any mapping happens. Fix: create the client scope named groups, attach the mapper to it, assign it as a default scope on the client. The scope the app requests and the claim the app reads are two separate things with two separate names, and both must line up.
2. The claim has to be in the token the app actually reads
The subtler, more expensive failure: login succeeds, but BookStack behaves as though the user is in no groups, with no error to explain why. Everything looks correct in Keycloak.
The cause is where the claim was written. Keycloak lets you place a mapped claim in the ID token, the access token, and/or the UserInfo response independently. BookStack reads groups from the ID token. If the mapper's Add to ID token setting is off, the claim exists everywhere except where BookStack looks, and the app silently sees nothing.
Fix: ensure Add to ID token: On on the group-membership mapper. And the transferable lesson, the thing to check first on any group-aware integration: find out which token the consumer reads groups from before touching anything else. ID token, access token, or a UserInfo call — a claim in the wrong one is invisible in exactly the way that produces a silent, no-error failure.
3. Signing algorithm
BookStack accepts only RS256. If tokens are signed with anything else they are valid but useless to the app. Confirm the client and realm agree.
The APP_KEY aside
Not strictly OIDC, but it bites people setting BookStack up for the first time: BookStack's APP_KEY must carry the base64: prefix and decode to exactly 32 bytes. A malformed key produces cipher errors that look unrelated to auth. Generate it correctly and store it in the password manager alongside the client secret; do not change it after content exists, because it is the encryption key for encrypted database fields.
Adapt this for…
Any application that maps IdP groups to internal roles: wikis, dashboards, file platforms, ticketing systems. The shape is always: create a real client scope (not just a mapper) whose name matches what the app requests, put the group-membership mapper on it, and — the step everyone misses — confirm the claim lands in the specific token the app reads. When a group-aware integration "logs in fine but sees no groups," it is almost always gotcha #2.