Skip to content

Commit e453a78

Browse files
Merge pull request #153 from Altinity/feat/oauth-username-claim
feat(oauth): configurable Basic username claim (#152)
2 parents da004ec + 7e7fb1f commit e453a78

6 files changed

Lines changed: 162 additions & 9 deletions

File tree

docs/oauth_authorization.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,18 @@ user-vs-claim match.
7575
WITH http SERVER 'ch_jwt_verify' SCHEME 'BASIC'`.
7676
- Forces HTTP protocol on the driver.
7777

78+
By default the Basic username is the JWT `email` claim (with a namespaced
79+
`*/email` fallback for Auth0 DCR tokens). Set **`oauth.username_claim`** to
80+
use a different claim (`username`, `preferred_username`, `sub`, …) when your
81+
ClickHouse users are provisioned from that claim. When set, MCP does a strict
82+
top-level lookup of that single claim and **fails closed** on a
83+
missing/empty/non-string value — it never silently falls back to email or
84+
another identity. An explicit `username_claim: email` is therefore strict and
85+
does *not* do the namespaced fallback; leave it unset if you rely on that.
86+
**`oauth.username_claim` must match the sidecar's `identity.username_claim`**
87+
(see below) — a mismatch fails authentication. The value is an unverified
88+
hint only; the sidecar still validates the JWT and the user-vs-claim match.
89+
7890
### Detection logic
7991

8092
On the first authenticated request to `host:port`, MCP tries Bearer. If
@@ -167,6 +179,12 @@ identity:
167179
allowed_email_domains: ["example.com"]
168180
```
169181
182+
The sidecar's `identity.username_claim` here **must match** MCP's
183+
`oauth.username_claim` (default `email`). MCP puts that claim's value in the
184+
Basic username; the sidecar re-reads the same claim from the verified token
185+
and rejects the request if they disagree. If you point the sidecar at a
186+
non-email claim, set MCP's `oauth.username_claim` to the same claim.
187+
170188
## ClickHouse `token_processors` (Bearer auth path)
171189

172190
If your ClickHouse uses `token_processors`, altinity-mcp will detect
@@ -283,6 +301,13 @@ server:
283301
role_claim: "" # e.g. "https://clickhouse/roles"
284302
role_filter: "" # optional; e.g. "^anon_" (empty = activate all claim roles)
285303

304+
# JWT claim used as the ClickHouse Basic-auth username on the sidecar path.
305+
# Empty = default `email` claim + namespaced */email fallback. When set, a
306+
# strict top-level lookup of this single claim is used and a missing/empty/
307+
# non-string value fails closed. Must match the sidecar's
308+
# identity.username_claim. Applies only to the Basic path, not Bearer.
309+
username_claim: "" # e.g. "username", "preferred_username", "sub"
310+
286311
# Token lifetimes (broker mode)
287312
access_token_ttl_seconds: 3600
288313
refresh_token_ttl_seconds: 2592000 # 30 d
@@ -313,6 +338,7 @@ server:
313338
| `upstream_offline_access` | Request `offline_access` upstream so the IdP consent screen offers long-lived sessions. Default `false`. |
314339
| `role_claim` | JWT claim holding a JSON array of ClickHouse role names to activate per request (e.g. `https://clickhouse/roles`). Empty disables. Read from the validated token's namespaced/custom claims. |
315340
| `role_filter` | **Optional** regex narrowing which `role_claim` roles are activated (e.g. `_mcp$`). When empty, **all** roles in the claim are activated (the IdP curates the set; CH re-validates the token and enforces grants). Only ever narrows; an empty *resolved* set (claim absent/empty, or filter matched nothing) fails closed — request denied, no fallback to the full grant. HTTP protocol only; applies in both Bearer and Basic/sidecar paths. **Anchor it** (`^…`/`…$`) — it's a partial match, so an unanchored `anon` would also match `not_anon_real`. |
341+
| `username_claim` | JWT claim used as the ClickHouse Basic-auth username on the `ch-jwt-verify` sidecar path. Empty (default) = `email` claim with a namespaced `*/email` fallback. When set (e.g. `username`, `preferred_username`, `sub`), a strict top-level lookup of that single claim is used and a missing/empty/non-string value **fails closed** — never falling back to another identity. An explicit `email` is strict (no namespaced fallback). Unverified hint only (CH/sidecar still validate). **Must match the sidecar's `identity.username_claim`.** Basic path only; no effect on Bearer. |
316342

317343
## Provider-specific setup
318344

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ require golang.org/x/crypto v0.53.0 // indirect
2020

2121
require (
2222
github.com/ClickHouse/ch-go v0.73.0 // indirect
23-
github.com/altinity/go-mcp-oauth-sdk v0.1.1-0.20260615173123-b30b0552560b
23+
github.com/altinity/go-mcp-oauth-sdk v0.1.1-0.20260708140922-cd8667cc7d14
2424
github.com/andybalholm/brotli v1.2.1 // indirect
2525
github.com/cespare/xxhash/v2 v2.3.0 // indirect
2626
github.com/davecgh/go-spew v1.1.1 // indirect

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ github.com/ClickHouse/ch-go v0.73.0 h1:jsHiGRbQ3sz+gekvDFJF29LWDo5dzbJm5s1h8TWVP
44
github.com/ClickHouse/ch-go v0.73.0/go.mod h1:wkFIxrqlXeRJ9cn3r5Fz5Qen9jl5aTMPuGZeuJpANNY=
55
github.com/ClickHouse/clickhouse-go/v2 v2.47.0 h1:ZDAzrnKSOPTIsm4tdUNfrii2yc8dk4SVRLC77BR7Z5Q=
66
github.com/ClickHouse/clickhouse-go/v2 v2.47.0/go.mod h1:sPj7C7UYQ2MWHcfX+4eGN6nwnCqwUKfgO6PcwKpd6K8=
7-
github.com/altinity/go-mcp-oauth-sdk v0.1.1-0.20260615173123-b30b0552560b h1:2CHdIO/kNhgq077KkhReHLonr/HliNHit8wmKq6wDcU=
8-
github.com/altinity/go-mcp-oauth-sdk v0.1.1-0.20260615173123-b30b0552560b/go.mod h1:yLgv0x586vIzPB5JaA6DkqQsSe4PLRT3PhU2iHO7qsI=
7+
github.com/altinity/go-mcp-oauth-sdk v0.1.1-0.20260708140922-cd8667cc7d14 h1:qtt8C3izTL9MYCd2fJ3r+PBycjPP3QapQVjveS1wEcA=
8+
github.com/altinity/go-mcp-oauth-sdk v0.1.1-0.20260708140922-cd8667cc7d14/go.mod h1:Ad/Ri3RM1yYmE/huozKXv/FSQWq2UEj5gmmnHeLHRk4=
99
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
1010
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
1111
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=

helm/altinity-mcp/values.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,14 @@ config:
228228
# the full grant. Anchor it (^…/…$): it is a partial match, so an
229229
# unanchored "anon" would also match "not_anon_real".
230230
role_filter: ""
231+
# -- JWT claim used as the ClickHouse Basic-auth username on the
232+
# ch-jwt-verify sidecar path (#152). Empty (default) uses the `email`
233+
# claim with a namespaced `*/email` fallback. When set (e.g. "username",
234+
# "preferred_username", "sub"), a strict top-level lookup of that single
235+
# claim is used and a missing/empty/non-string value fails closed rather
236+
# than falling back to another identity. Must match the sidecar's
237+
# identity.username_claim. Applies only to the Basic path, not Bearer.
238+
username_claim: ""
231239
# Dynamic tools generated from ClickHouse views
232240
dynamic_tools: []
233241
# - regexp: "db\\..*"

pkg/server/server_client.go

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -328,11 +328,11 @@ func (s *ClickHouseJWEServer) newClientWithOAuth(ctx context.Context, chCfg conf
328328

329329
// Bearer got an auth error — try Basic (ch-jwt-verify sidecar path).
330330
log.Debug().Str("endpoint", endpoint).Msg("oauth: Bearer rejected by CH, probing Basic")
331-
email, ok := emailFromUnverifiedJWT(token)
331+
user, ok := basicUsernameFromJWT(token, cfg.UsernameClaim)
332332
if !ok {
333-
return nil, fmt.Errorf("oauth: bearer is not a JWT with an email claim")
333+
return nil, basicUsernameError(cfg.UsernameClaim)
334334
}
335-
basicCfg := oauthApplyBasic(probeCfg, email, token)
335+
basicCfg := oauthApplyBasic(probeCfg, user, token)
336336
client, err := clickhouse.NewClient(ctx, basicCfg)
337337
if err != nil {
338338
return nil, fmt.Errorf("failed to create ClickHouse client: %w", err)
@@ -351,11 +351,11 @@ func newClientForOAuthMethod(ctx context.Context, chCfg config.ClickHouseConfig,
351351
case chOAuthMethodBearer:
352352
chCfg = oauthApplyBearer(chCfg, token, oauthCfg)
353353
case chOAuthMethodBasic:
354-
email, ok := emailFromUnverifiedJWT(token)
354+
user, ok := basicUsernameFromJWT(token, oauthCfg.UsernameClaim)
355355
if !ok {
356-
return nil, fmt.Errorf("oauth: bearer is not a JWT with an email claim")
356+
return nil, basicUsernameError(oauthCfg.UsernameClaim)
357357
}
358-
chCfg = oauthApplyBasic(chCfg, email, token)
358+
chCfg = oauthApplyBasic(chCfg, user, token)
359359
}
360360
client, err := clickhouse.NewClient(ctx, chCfg)
361361
if err != nil {
@@ -446,6 +446,33 @@ func emailFromUnverifiedJWT(token string) (string, bool) {
446446
return "", false
447447
}
448448

449+
// basicUsernameFromJWT returns the ClickHouse Basic-auth username for token.
450+
// When usernameClaim is empty it keeps the default behavior (the `email` claim
451+
// with a namespaced `*/email` fallback). When set, it does a strict top-level
452+
// lookup of that single claim via oauth.UsernameFromExtra and fails closed
453+
// (ok=false) on a missing, empty, or non-string value — never falling back to
454+
// email or any other identity. The value is an unverified hint only; CH / the
455+
// ch-jwt-verify sidecar still validate the JWT.
456+
func basicUsernameFromJWT(token, usernameClaim string) (string, bool) {
457+
if strings.TrimSpace(usernameClaim) == "" {
458+
return emailFromUnverifiedJWT(token)
459+
}
460+
raw, ok := decodeUnverifiedJWTClaims(token)
461+
if !ok {
462+
return "", false
463+
}
464+
return oauth.UsernameFromExtra(raw, usernameClaim)
465+
}
466+
467+
// basicUsernameError builds the fail-closed error returned when no usable Basic
468+
// username can be derived, naming the configured claim when one is set.
469+
func basicUsernameError(usernameClaim string) error {
470+
if c := strings.TrimSpace(usernameClaim); c != "" {
471+
return fmt.Errorf("oauth: bearer JWT has no usable Basic username (claim %q missing, empty, or non-string)", c)
472+
}
473+
return fmt.Errorf("oauth: bearer is not a JWT with an email claim")
474+
}
475+
449476
// oauthExpiryClockSkewSecs tolerates small clock differences between this
450477
// server and the IdP that signed the token so a token expiring within seconds
451478
// does not bounce clients with slightly fast clocks.

pkg/server/server_client_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,98 @@ func TestEmailFromUnverifiedJWT(t *testing.T) {
221221
})
222222
}
223223

224+
// TestBasicUsernameFromJWT covers the orchestration around the configurable
225+
// username_claim: unset preserves the email default (incl. namespaced
226+
// fallback); a set claim does a strict top-level lookup and fails closed on
227+
// missing/empty/non-string, never falling back to email.
228+
func TestBasicUsernameFromJWT(t *testing.T) {
229+
t.Parallel()
230+
231+
t.Run("unset_claim_uses_email", func(t *testing.T) {
232+
t.Parallel()
233+
tok := fakeJWT(t, map[string]interface{}{"email": "alice@example.com"})
234+
got, ok := basicUsernameFromJWT(tok, "")
235+
require.True(t, ok)
236+
require.Equal(t, "alice@example.com", got)
237+
})
238+
239+
t.Run("unset_claim_whitespace_uses_email", func(t *testing.T) {
240+
t.Parallel()
241+
tok := fakeJWT(t, map[string]interface{}{"email": "alice@example.com"})
242+
got, ok := basicUsernameFromJWT(tok, " ")
243+
require.True(t, ok)
244+
require.Equal(t, "alice@example.com", got)
245+
})
246+
247+
t.Run("unset_claim_namespaced_email_fallback", func(t *testing.T) {
248+
t.Parallel()
249+
tok := fakeJWT(t, map[string]interface{}{
250+
"https://example.com/email": "alice@example.com",
251+
})
252+
got, ok := basicUsernameFromJWT(tok, "")
253+
require.True(t, ok)
254+
require.Equal(t, "alice@example.com", got)
255+
})
256+
257+
t.Run("configured_username_claim", func(t *testing.T) {
258+
t.Parallel()
259+
tok := fakeJWT(t, map[string]interface{}{
260+
"username": "alice",
261+
"email": "alice@example.com",
262+
})
263+
got, ok := basicUsernameFromJWT(tok, "username")
264+
require.True(t, ok)
265+
require.Equal(t, "alice", got)
266+
})
267+
268+
t.Run("configured_claim_whitespace_trimmed", func(t *testing.T) {
269+
t.Parallel()
270+
tok := fakeJWT(t, map[string]interface{}{"preferred_username": " bob "})
271+
got, ok := basicUsernameFromJWT(tok, "preferred_username")
272+
require.True(t, ok)
273+
require.Equal(t, "bob", got)
274+
})
275+
276+
t.Run("configured_claim_missing_fails_closed", func(t *testing.T) {
277+
t.Parallel()
278+
// email present but the configured claim is absent — must NOT fall back.
279+
tok := fakeJWT(t, map[string]interface{}{"email": "alice@example.com"})
280+
_, ok := basicUsernameFromJWT(tok, "username")
281+
require.False(t, ok)
282+
})
283+
284+
t.Run("configured_claim_empty_fails_closed", func(t *testing.T) {
285+
t.Parallel()
286+
tok := fakeJWT(t, map[string]interface{}{"username": " "})
287+
_, ok := basicUsernameFromJWT(tok, "username")
288+
require.False(t, ok)
289+
})
290+
291+
t.Run("configured_claim_non_string_fails_closed", func(t *testing.T) {
292+
t.Parallel()
293+
tok := fakeJWT(t, map[string]interface{}{"username": 12345})
294+
_, ok := basicUsernameFromJWT(tok, "username")
295+
require.False(t, ok)
296+
})
297+
298+
t.Run("configured_email_is_strict_top_level", func(t *testing.T) {
299+
t.Parallel()
300+
// With username_claim="email" set, the namespaced */email key is NOT
301+
// consulted — explicit config means a strict single-claim lookup.
302+
tok := fakeJWT(t, map[string]interface{}{
303+
"https://example.com/email": "namespaced@example.com",
304+
})
305+
_, ok := basicUsernameFromJWT(tok, "email")
306+
require.False(t, ok)
307+
})
308+
309+
t.Run("configured_claim_not_a_jwt_fails_closed", func(t *testing.T) {
310+
t.Parallel()
311+
_, ok := basicUsernameFromJWT("only.two", "username")
312+
require.False(t, ok)
313+
})
314+
}
315+
224316
// jwtWithClaims builds an unsigned-but-well-formed three-segment JWT string
225317
// (the signature segment is a dummy) for exp-claim tests. unverifiedExp never
226318
// checks the signature, so this is sufficient.

0 commit comments

Comments
 (0)