Skip to content

Commit de926ba

Browse files
craig[bot]shriramters
andcommitted
Merge #147318
147318: jwtauthccl, securityccl: sync SQL roles from JWT groups claim r=pritesh-lahoti,souravcrl a=shriramters Previously, JWT login authenticated users but did not mirror group membership into SQL roles. This was inadequate because one would like to also authorize the user based on group membership information present in the JWT. A similar role-sync is already in place in the LDAP auth flow. To address this, this patch adds an authorizer that grants/revokes roles listed in the token’s "groups" claim, or any other claim as configured. If the JWT does not contain a field with groups, the authorizer tries to fetch the groups from the IdP's userinfo endpoint. If the fetched groups list is empty, meaning the claim exists but contains `[]` or the userinfo response yields no groups, the server: 1. revokes all existing SQL role memberships for the user, and 2. rejects the login with `JWT authorization: empty group list`. The feature is disabled by default and gated by `server.jwt_authentication.authorization.enabled`. Additionally, the field names of the JWT and userinfo JSONs where cockroach looks for the groups list can be customized using `server.jwt_authentication.group_claim` and `server.jwt_authentication.userinfo_group_key` respectively. This commit also provides a new package `securityccl/jwthelper` that provides `ExtractGroups()`, a small helper that accepts a JWT and extracts and normalizes group information. This helper is now used by jwtauthccl and can be reused by oidcccl for supporting authorization in the OIDC flow. Fixes: #104671 Epic: [CRDB-48763](https://cockroachlabs.atlassian.net/browse/CRDB-48763) Release note (security update): CockroachDB can now synchronise SQL role membership from the groups claim contained in a JWT when `server.jwt_authentication.authorization.enabled = true`. The claim name and the fallback *userinfo* JSON key are configurable by `server.jwt_authentication.group_claim` and `server.jwt_authentication.userinfo_group_key` respectively. Behaviour matches the existing LDAP role-sync feature. Common IdP examples ------------------- ### Okta (default mapping) ```sql SET CLUSTER SETTING server.jwt_authentication.group_claim = 'groups'; SET CLUSTER SETTING server.jwt_authentication.userinfo_group_key = 'groups'; ``` ### Keycloak If you map Keycloak Groups to a JWT claim (default): - `group_claim = 'groups' -- e.g. "groups": ["team-a","team-b"]` If you prefer Keycloak’s built-in realm roles, CockroachDB does not follow nested paths like "realm_access.roles". Add a Keycloak protocol mapper that flattens roles into a top-level claim (for example "roles") and then configure: - `group_claim = 'roles'; -- e.g. "roles": ["team-a","team-b"]` The userinfo endpoint usually returns the same JSON keys, so the same values work for `userinfo_group_key` as well. Co-authored-by: Shriram Ravindranathan <[email protected]>
2 parents 975861f + 0b8a1d6 commit de926ba

File tree

15 files changed

+996
-14
lines changed

15 files changed

+996
-14
lines changed

docs/generated/settings/settings-for-tenants.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,13 +125,16 @@ server.hsts.enabled boolean false if true, HSTS headers will be sent along with
125125
server.http.base_path string / path to redirect the user to upon succcessful login application
126126
server.identity_map.configuration string system-identity to database-username mappings application
127127
server.jwt_authentication.audience string sets accepted audience values for JWT logins over the SQL interface application
128+
server.jwt_authentication.authorization.enabled boolean false enables role synchronisation based on group claims in JWTs application
128129
server.jwt_authentication.claim string sets the JWT claim that is parsed to get the username application
129130
server.jwt_authentication.client.timeout duration 15s sets the client timeout for external calls made during JWT authentication (e.g. fetching JWKS, etc.) application
130131
server.jwt_authentication.enabled boolean false enables or disables JWT login for the SQL interface application
132+
server.jwt_authentication.group_claim string groups sets the name of the JWT claim that contains groups used for role mapping application
131133
server.jwt_authentication.issuers.configuration (alias: server.jwt_authentication.issuers) string sets accepted issuer values for JWT logins over the SQL interface which can be a single issuer URL string or a JSON string containing an array of issuer URLs or a JSON object containing map of issuer URLS to JWKS URIs application
132134
server.jwt_authentication.issuers.custom_ca string sets the PEM encoded custom root CA for verifying certificates while fetching JWKS application
133135
server.jwt_authentication.jwks string "{""keys"":[]}" sets the public key set for JWT logins over the SQL interface (JWKS format) application
134136
server.jwt_authentication.jwks_auto_fetch.enabled boolean false enables or disables automatic fetching of JWKS from the issuer's well-known endpoint or JWKS URI set in JWTAuthIssuersConfig. If this is enabled, the server.jwt_authentication.jwks will be ignored. application
137+
server.jwt_authentication.userinfo_group_key string groups sets the field name to look for in userinfo JSON that lists groups when groups claim is absent from JWT application
135138
server.ldap_authentication.client.tls_certificate string sets the client certificate PEM for establishing mTLS connection with LDAP server application
136139
server.ldap_authentication.client.tls_key string sets the client key PEM for establishing mTLS connection with LDAP server application
137140
server.ldap_authentication.domain.custom_ca string sets the PEM encoded custom root CA for verifying domain certificates when establishing connection with LDAP server application

docs/generated/settings/settings.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,13 +157,16 @@
157157
<tr><td><div id="setting-server-http-base-path" class="anchored"><code>server.http.base_path</code></div></td><td>string</td><td><code>/</code></td><td>path to redirect the user to upon succcessful login</td><td>Serverless/Dedicated/Self-Hosted</td></tr>
158158
<tr><td><div id="setting-server-identity-map-configuration" class="anchored"><code>server.identity_map.configuration</code></div></td><td>string</td><td><code></code></td><td>system-identity to database-username mappings</td><td>Serverless/Dedicated/Self-Hosted</td></tr>
159159
<tr><td><div id="setting-server-jwt-authentication-audience" class="anchored"><code>server.jwt_authentication.audience</code></div></td><td>string</td><td><code></code></td><td>sets accepted audience values for JWT logins over the SQL interface</td><td>Serverless/Dedicated/Self-Hosted</td></tr>
160+
<tr><td><div id="setting-server-jwt-authentication-authorization-enabled" class="anchored"><code>server.jwt_authentication.authorization.enabled</code></div></td><td>boolean</td><td><code>false</code></td><td>enables role synchronisation based on group claims in JWTs</td><td>Serverless/Dedicated/Self-Hosted</td></tr>
160161
<tr><td><div id="setting-server-jwt-authentication-claim" class="anchored"><code>server.jwt_authentication.claim</code></div></td><td>string</td><td><code></code></td><td>sets the JWT claim that is parsed to get the username</td><td>Serverless/Dedicated/Self-Hosted</td></tr>
161162
<tr><td><div id="setting-server-jwt-authentication-client-timeout" class="anchored"><code>server.jwt_authentication.client.timeout</code></div></td><td>duration</td><td><code>15s</code></td><td>sets the client timeout for external calls made during JWT authentication (e.g. fetching JWKS, etc.)</td><td>Serverless/Dedicated/Self-Hosted</td></tr>
162163
<tr><td><div id="setting-server-jwt-authentication-enabled" class="anchored"><code>server.jwt_authentication.enabled</code></div></td><td>boolean</td><td><code>false</code></td><td>enables or disables JWT login for the SQL interface</td><td>Serverless/Dedicated/Self-Hosted</td></tr>
164+
<tr><td><div id="setting-server-jwt-authentication-group-claim" class="anchored"><code>server.jwt_authentication.group_claim</code></div></td><td>string</td><td><code>groups</code></td><td>sets the name of the JWT claim that contains groups used for role mapping</td><td>Serverless/Dedicated/Self-Hosted</td></tr>
163165
<tr><td><div id="setting-server-jwt-authentication-issuers" class="anchored"><code>server.jwt_authentication.issuers.configuration<br />(alias: server.jwt_authentication.issuers)</code></div></td><td>string</td><td><code></code></td><td>sets accepted issuer values for JWT logins over the SQL interface which can be a single issuer URL string or a JSON string containing an array of issuer URLs or a JSON object containing map of issuer URLS to JWKS URIs</td><td>Serverless/Dedicated/Self-Hosted</td></tr>
164166
<tr><td><div id="setting-server-jwt-authentication-issuers-custom-ca" class="anchored"><code>server.jwt_authentication.issuers.custom_ca</code></div></td><td>string</td><td><code></code></td><td>sets the PEM encoded custom root CA for verifying certificates while fetching JWKS</td><td>Serverless/Dedicated/Self-Hosted</td></tr>
165167
<tr><td><div id="setting-server-jwt-authentication-jwks" class="anchored"><code>server.jwt_authentication.jwks</code></div></td><td>string</td><td><code>{"keys":[]}</code></td><td>sets the public key set for JWT logins over the SQL interface (JWKS format)</td><td>Serverless/Dedicated/Self-Hosted</td></tr>
166168
<tr><td><div id="setting-server-jwt-authentication-jwks-auto-fetch-enabled" class="anchored"><code>server.jwt_authentication.jwks_auto_fetch.enabled</code></div></td><td>boolean</td><td><code>false</code></td><td>enables or disables automatic fetching of JWKS from the issuer&#39;s well-known endpoint or JWKS URI set in JWTAuthIssuersConfig. If this is enabled, the server.jwt_authentication.jwks will be ignored.</td><td>Serverless/Dedicated/Self-Hosted</td></tr>
169+
<tr><td><div id="setting-server-jwt-authentication-userinfo-group-key" class="anchored"><code>server.jwt_authentication.userinfo_group_key</code></div></td><td>string</td><td><code>groups</code></td><td>sets the field name to look for in userinfo JSON that lists groups when groups claim is absent from JWT</td><td>Serverless/Dedicated/Self-Hosted</td></tr>
167170
<tr><td><div id="setting-server-ldap-authentication-client-tls-certificate" class="anchored"><code>server.ldap_authentication.client.tls_certificate</code></div></td><td>string</td><td><code></code></td><td>sets the client certificate PEM for establishing mTLS connection with LDAP server</td><td>Serverless/Dedicated/Self-Hosted</td></tr>
168171
<tr><td><div id="setting-server-ldap-authentication-client-tls-key" class="anchored"><code>server.ldap_authentication.client.tls_key</code></div></td><td>string</td><td><code></code></td><td>sets the client key PEM for establishing mTLS connection with LDAP server</td><td>Serverless/Dedicated/Self-Hosted</td></tr>
169172
<tr><td><div id="setting-server-ldap-authentication-domain-custom-ca" class="anchored"><code>server.ldap_authentication.domain.custom_ca</code></div></td><td>string</td><td><code></code></td><td>sets the PEM encoded custom root CA for verifying domain certificates when establishing connection with LDAP server</td><td>Serverless/Dedicated/Self-Hosted</td></tr>

pkg/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -995,6 +995,7 @@ GO_TARGETS = [
995995
"//pkg/ccl/schemachangerccl:schemachangerccl",
996996
"//pkg/ccl/schemachangerccl:schemachangerccl_test",
997997
"//pkg/ccl/securityccl/fipsccl:fipsccl",
998+
"//pkg/ccl/securityccl/jwthelper:jwthelper",
998999
"//pkg/ccl/serverccl/adminccl:adminccl_test",
9991000
"//pkg/ccl/serverccl/diagnosticsccl:diagnosticsccl_test",
10001001
"//pkg/ccl/serverccl/statusccl:statusccl_test",

pkg/ccl/jwtauthccl/BUILD.bazel

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ go_library(
44
name = "jwtauthccl",
55
srcs = [
66
"authentication_jwt.go",
7+
"authorization_jwt.go",
78
"settings.go",
89
],
910
importpath = "github.com/cockroachdb/cockroach/pkg/ccl/jwtauthccl",
1011
visibility = ["//visibility:public"],
1112
deps = [
13+
"//pkg/ccl/securityccl/jwthelper",
1214
"//pkg/ccl/utilccl",
1315
"//pkg/security/username",
1416
"//pkg/server/telemetry",
@@ -34,6 +36,7 @@ go_test(
3436
size = "medium",
3537
srcs = [
3638
"authentication_jwt_test.go",
39+
"authorization_jwt_test.go",
3740
"main_test.go",
3841
"settings_test.go",
3942
],
@@ -47,6 +50,8 @@ go_test(
4750
"//pkg/security/securitytest",
4851
"//pkg/security/username",
4952
"//pkg/server",
53+
"//pkg/settings",
54+
"//pkg/settings/cluster",
5055
"//pkg/sql/pgwire/identmap",
5156
"//pkg/testutils",
5257
"//pkg/testutils/serverutils",

pkg/ccl/jwtauthccl/authentication_jwt.go

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"encoding/json"
1111
"fmt"
1212
"io"
13+
"net/http"
1314
"strings"
1415

1516
"github.com/cockroachdb/cockroach/pkg/ccl/utilccl"
@@ -146,6 +147,7 @@ func (authenticator *jwtAuthenticator) ValidateJWTLogin(
146147
tokenBytes []byte,
147148
identMap *identmap.Conf,
148149
) (detailedErrorMsg redact.RedactableString, authError error) {
150+
authenticator.reloadConfig(ctx, st)
149151
authenticator.mu.Lock()
150152
defer authenticator.mu.Unlock()
151153

@@ -370,8 +372,42 @@ func getOpenIdConfigEndpoint(issuerUrl string) string {
370372
return openIdConfigEndpoint
371373
}
372374

373-
var getHttpResponse = func(ctx context.Context, url string, authenticator *jwtAuthenticator) ([]byte, error) {
374-
resp, err := authenticator.mu.conf.httpClient.Get(context.Background(), url)
375+
// getHTTPResponse issues a GET request using the authenticator’s configured
376+
// HTTP client, optionally setting the supplied headers.
377+
//
378+
// ctx – caller’s context (for cancellation / deadlines)
379+
// url – absolute URL to fetch
380+
// a – the *jwtAuthenticator whose client must be reused
381+
// hdr – optional: pass one http.Header with any extra headers, or omit entirely
382+
//
383+
// The function returns the response body (fully read) so that callers can
384+
// inspect the payload without worrying about closing the body.
385+
//
386+
// Callers should wrap the returned error with errors.WithDetailf to tag the
387+
// operation they’re performing.
388+
var getHttpResponse = func(
389+
ctx context.Context,
390+
url string,
391+
authenticator *jwtAuthenticator,
392+
header ...http.Header, // optional variadic param for extra headers
393+
) ([]byte, error) {
394+
395+
// Reject misuse: only zero or one header may be supplied.
396+
if len(header) > 1 {
397+
return nil, errors.New("getHttpResponse: provide at most one extra header")
398+
}
399+
400+
// Build the request
401+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
402+
if err != nil {
403+
return nil, err
404+
}
405+
if len(header) == 1 && header[0] != nil {
406+
req.Header = header[0].Clone()
407+
}
408+
409+
client := authenticator.mu.conf.httpClient
410+
resp, err := client.Do(req)
375411
if err != nil {
376412
return nil, err
377413
}

pkg/ccl/jwtauthccl/authentication_jwt_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -672,7 +672,7 @@ func TestAudienceCheck(t *testing.T) {
672672

673673
// mockGetHttpResponseWithLocalFileContent is a mock function for getHttpResponse. This is used to intercept the call to
674674
// getHttpResponse and return the content of a local file instead of making a http call.
675-
var mockGetHttpResponseWithLocalFileContent = func(ctx context.Context, url string, authenticator *jwtAuthenticator) ([]byte, error) {
675+
var mockGetHttpResponseWithLocalFileContent = func(ctx context.Context, url string, authenticator *jwtAuthenticator, _ ...http.Header) ([]byte, error) {
676676
// remove https:// and replace / with _ in the url to get the testdata file name
677677
fileName := "testdata/" + strings.ReplaceAll(strings.ReplaceAll(url, "https://", ""), "/", "_")
678678
// read content of the file as a byte array
@@ -999,7 +999,7 @@ func TestJWTAuthClientTimeout(t *testing.T) {
999999
testServer.Close()
10001000
}()
10011001

1002-
mockGetHttpResponse := func(ctx context.Context, url string, authenticator *jwtAuthenticator) ([]byte, error) {
1002+
mockGetHttpResponse := func(ctx context.Context, url string, authenticator *jwtAuthenticator, _ ...http.Header) ([]byte, error) {
10031003
if strings.Contains(url, "/.well-known/openid-configuration") {
10041004
return mockGetHttpResponseWithLocalFileContent(ctx, url, authenticator)
10051005
} else if strings.Contains(url, "/oauth2/v3/certs") {

0 commit comments

Comments
 (0)