|
| 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