Skip to content

Commit d86b8cf

Browse files
committed
Create KEYCLOAK_SETUP.md
1 parent a101ab6 commit d86b8cf

1 file changed

Lines changed: 266 additions & 0 deletions

File tree

docs/KEYCLOAK_SETUP.md

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
# Keycloak Setup for OBP-MCP
2+
3+
This document is written for system administrators configuring Keycloak so that
4+
it can issue tokens accepted by the OBP-MCP server.
5+
6+
The MCP server is working with OBP-OIDC; this document covers the additional
7+
configuration needed to make Keycloak work alongside (or instead of) OBP-OIDC.
8+
9+
---
10+
11+
## TL;DR — What has to line up
12+
13+
For Keycloak to work with OBP-MCP, **all** of the following must be true. Each
14+
item below is the most frequent reason Keycloak tokens get rejected with `401
15+
Unauthorized` / `invalid_token`.
16+
17+
1. The `iss` claim in every Keycloak access token must match **exactly** what
18+
the MCP server has configured as `KEYCLOAK_REALM_URL` (character-for-character).
19+
2. The `aud` (audience) claim must contain an audience the MCP server accepts
20+
(or the MCP server must be configured with no audience requirement).
21+
3. The token must be signed with `RS256` (the MCP server's default) — or the
22+
server must be explicitly configured for the realm's signing algorithm.
23+
4. The token's `kid` (key ID) must be present in the realm's JWKS endpoint at
24+
the moment of validation.
25+
5. The client must request and receive the scopes the MCP server requires
26+
(default: `openid profile email`).
27+
6. Server clocks on Keycloak and the MCP host must be in sync (NTP).
28+
29+
Items 1–5 are Keycloak-side configuration; item 6 is infrastructure.
30+
31+
---
32+
33+
## 1. MCP server environment
34+
35+
On the MCP server host, set these in `.env`:
36+
37+
```env
38+
ENABLE_OAUTH=true
39+
40+
# If the MCP server should accept ONLY Keycloak tokens:
41+
AUTH_PROVIDER=keycloak
42+
KEYCLOAK_REALM_URL=https://<keycloak-host>/realms/<realm-name>
43+
44+
# OR — if it should accept tokens from BOTH Keycloak and OBP-OIDC:
45+
AUTH_PROVIDER=bearer-only
46+
KEYCLOAK_REALM_URL=https://<keycloak-host>/realms/<realm-name>
47+
OBP_OIDC_ISSUER_URL=https://<obp-oidc-host>/obp-oidc
48+
```
49+
50+
**Critical:** `KEYCLOAK_REALM_URL` must match the `iss` claim that Keycloak
51+
actually puts in its tokens, byte-for-byte. Pitfalls:
52+
53+
- **Trailing slash**`/realms/foo` vs `/realms/foo/` are different values.
54+
- **Hostname** — if Keycloak is accessed as `https://sso.example.com` externally
55+
but `http://keycloak:8080` internally, tokens carry whichever URL the client
56+
used to authenticate. The MCP server must be configured with the same one.
57+
- **Scheme / port**`http://` vs `https://`, and any non-default port, all
58+
count as part of the string.
59+
60+
To verify: issue a test token, paste it into https://jwt.io, and copy the `iss`
61+
claim verbatim into `KEYCLOAK_REALM_URL`.
62+
63+
The MCP server will fetch keys from
64+
`{KEYCLOAK_REALM_URL}/protocol/openid-connect/certs` — make sure that URL is
65+
reachable from the MCP host. (`curl $KEYCLOAK_REALM_URL/protocol/openid-connect/certs`
66+
should return a JSON document with a `keys` array.)
67+
68+
---
69+
70+
## 2. Keycloak realm configuration
71+
72+
### 2.1 Realm signing key
73+
74+
**Realm settings → Keys → Active**
75+
76+
Confirm the realm has an active **RS256** signing key. If the realm was built
77+
with a different default (e.g. `PS256`, `ES256`), either:
78+
79+
- switch the realm's active key to RS256, **or**
80+
- set the MCP server to the realm's algorithm (requires a code change in
81+
`auth.py` — contact the MCP maintainer if this is needed).
82+
83+
### 2.2 Realm issuer URL (frontendUrl)
84+
85+
**Realm settings → General → Frontend URL**
86+
87+
Set `frontendUrl` to the **external** URL of Keycloak — the one clients use to
88+
log in (e.g. `https://sso.example.com`). This is what appears in the `iss`
89+
claim. If frontendUrl is left blank, Keycloak uses whatever host the client hit
90+
first, which can produce mixed `iss` values and break MCP validation
91+
non-deterministically.
92+
93+
---
94+
95+
## 3. Client configuration
96+
97+
For each MCP client that will talk to OBP-MCP:
98+
99+
**Clients → [client-id] → Settings**
100+
101+
- **Client Protocol:** `openid-connect`
102+
- **Access Type:** `confidential` (for server-to-server) or `public` (for PKCE
103+
browser clients)
104+
- **Valid Redirect URIs:** whatever the MCP client needs
105+
- **Standard Flow / Direct Access Grants / Service Accounts:** enable whichever
106+
flow the client uses
107+
108+
### 3.1 Scopes (MUST be on the default list, not optional)
109+
110+
**Clients → [client-id] → Client Scopes → Setup**
111+
112+
The MCP server defaults to requiring `openid`, `profile`, and `email` in the
113+
token. Keycloak only includes a scope in the access token if it's on the
114+
client's **default** scope list — optional scopes only appear when the client
115+
explicitly requests them.
116+
117+
Move these from "Assigned optional client scopes" to **"Assigned default client
118+
scopes"**:
119+
120+
- `openid`
121+
- `profile`
122+
- `email`
123+
124+
Verify afterward: the `scope` claim in a test access token should contain all
125+
three.
126+
127+
### 3.2 Audience mapper — the most common gotcha
128+
129+
By default, Keycloak issues access tokens with `aud: "account"`, not the
130+
client's own ID. If the MCP server is deployed with an `audience` configured,
131+
validation will fail with an audience mismatch.
132+
133+
**Two options:**
134+
135+
**Option A — Don't enforce audience (simpler).**
136+
Leave the MCP server's audience unset. Tokens validate based on issuer and
137+
signature only. This is the default if you don't pass `audience=` anywhere.
138+
139+
**Option B — Add an audience mapper (stricter).**
140+
If you want the MCP server to verify `aud` contains a specific value:
141+
142+
1. **Clients → [client-id] → Client Scopes → [client-id]-dedicated → Mappers
143+
→ Add mapper → By configuration → Audience**
144+
2. **Name:** `obp-mcp-audience`
145+
3. **Included Custom Audience:** `obp-mcp` (or any string agreed with the MCP
146+
maintainer)
147+
4. **Add to ID token:** off
148+
5. **Add to access token:** **on**
149+
6. Save.
150+
151+
Then configure the MCP server to expect that same audience. (This currently
152+
requires a code change — `JWTVerifier(... audience="obp-mcp")` in `auth.py`.
153+
Contact the MCP maintainer to wire up an env var for this if needed.)
154+
155+
---
156+
157+
## 4. Dynamic Client Registration (full OAuth mode only)
158+
159+
Only relevant when `AUTH_PROVIDER=keycloak` (not `bearer-only`). In that mode
160+
the MCP server exposes a `/register` proxy so MCP clients (VS Code, Claude
161+
Desktop, etc.) can register themselves.
162+
163+
### 4.1 Enable anonymous DCR
164+
165+
**Realm settings → Client registration policies → Anonymous access policies**
166+
167+
Review which policies are applied to anonymous DCR. At minimum, the
168+
`Trusted Hosts` policy must include the MCP server's host (or be relaxed) so
169+
the MCP server can forward registration requests.
170+
171+
### 4.2 Keycloak version note — `token_endpoint_auth_method` workaround
172+
173+
Older Keycloak versions ignore the client's requested
174+
`token_endpoint_auth_method` during DCR and always return `client_secret_basic`,
175+
which breaks MCP clients that require `client_secret_post` (per RFC 9110).
176+
177+
This was fixed in Keycloak **PR #45309** (merged 12 January 2026). The MCP
178+
server includes a minimal DCR proxy that rewrites the response for older
179+
versions — no action needed from you, but upgrading Keycloak to a build that
180+
includes #45309 lets you remove the proxy in the future.
181+
182+
---
183+
184+
## 5. Verification checklist
185+
186+
Run these from the MCP server host after configuration:
187+
188+
```bash
189+
# 1. JWKS is reachable and contains a key
190+
curl -fsS "$KEYCLOAK_REALM_URL/protocol/openid-connect/certs" | jq '.keys[].kid'
191+
192+
# 2. OIDC discovery works
193+
curl -fsS "$KEYCLOAK_REALM_URL/.well-known/openid-configuration" | jq .issuer
194+
195+
# 3. Issue a test token (replace placeholders)
196+
ACCESS_TOKEN=$(curl -fsS -X POST \
197+
"$KEYCLOAK_REALM_URL/protocol/openid-connect/token" \
198+
-d "grant_type=password" \
199+
-d "client_id=<client-id>" \
200+
-d "client_secret=<client-secret>" \
201+
-d "username=<test-user>" \
202+
-d "password=<test-password>" \
203+
-d "scope=openid profile email" | jq -r .access_token)
204+
205+
# 4. Decode it (payload only, no verification) and inspect
206+
echo "$ACCESS_TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq '{iss, aud, exp, scope}'
207+
208+
# 5. Confirm iss matches KEYCLOAK_REALM_URL exactly
209+
echo "Expected iss: $KEYCLOAK_REALM_URL"
210+
```
211+
212+
A healthy token looks like:
213+
214+
```json
215+
{
216+
"iss": "https://sso.example.com/realms/obp-mcp",
217+
"aud": "account",
218+
"exp": 1745341830,
219+
"scope": "openid profile email"
220+
}
221+
```
222+
223+
---
224+
225+
## 6. Troubleshooting — reading the MCP server logs
226+
227+
When a token is rejected, the MCP server now emits a `JWT validation failed —
228+
diagnostics` block at WARNING level. Example:
229+
230+
```
231+
═══ JWT validation failed — diagnostics ═══
232+
Token header: kid='abc123', alg='RS256', typ='JWT'
233+
Token payload: iss='https://sso.example.com/realms/obp-mcp/', aud='account', ...
234+
Token timing : iat=1745341200, exp=1745344800, now=1745341500, expired=False
235+
Token scope : 'openid profile'
236+
Expected : issuer='https://sso.example.com/realms/obp-mcp', audience=None, ...
237+
✗ ISSUER MISMATCH: token iss='...realms/obp-mcp/' not in expected ['...realms/obp-mcp']
238+
JWKS https://sso.example.com/realms/obp-mcp/protocol/openid-connect/certs currently publishes kids: ['abc123', 'def456']
239+
═══ end diagnostics ═══
240+
```
241+
242+
How to read each line:
243+
244+
| Log line | What it means | Typical fix |
245+
|---|---|---|
246+
| `✗ ISSUER MISMATCH` | Token's `iss` doesn't match `KEYCLOAK_REALM_URL` | Align the two — usually a trailing-slash, scheme, or hostname difference. See §1. |
247+
| `✗ KID MISMATCH` | Token was signed with a key ID no longer in the JWKS | Key rotation — the client must re-authenticate to get a fresh token. If this is happening repeatedly, something is caching old tokens. |
248+
| `✗ AUDIENCE MISMATCH` | Token's `aud` doesn't contain the expected value | Add an audience mapper (§3.2) or remove the audience requirement on the MCP server. |
249+
| `✗ ALGORITHM MISMATCH` | Realm signs with something other than RS256 | Switch realm key to RS256 (§2.1) or reconfigure MCP server. |
250+
| `expired=True` | Token is past `exp` | Client must refresh. If clocks are in sync and tokens are immediately expired, check NTP on both hosts. |
251+
| `Token scope` missing a required value | Client didn't get the required scopes | Move scopes from Optional to Default on the client (§3.1). |
252+
253+
---
254+
255+
## 7. Summary of what Keycloak must provide
256+
257+
A single, deployable configuration checklist:
258+
259+
- [ ] Realm has an active RS256 signing key
260+
- [ ] Realm `frontendUrl` is set to the external Keycloak URL
261+
- [ ] JWKS endpoint `{realm}/protocol/openid-connect/certs` is reachable from the MCP host
262+
- [ ] Client has `openid`, `profile`, `email` on its **default** client scope list
263+
- [ ] Either no audience enforcement, or an audience mapper with a value the MCP server expects
264+
- [ ] (DCR only) Anonymous client registration policy allows the MCP server host
265+
- [ ] NTP enabled on Keycloak and MCP hosts
266+
- [ ] `KEYCLOAK_REALM_URL` in the MCP server's `.env` matches the `iss` claim exactly

0 commit comments

Comments
 (0)