Skip to content

Commit cf40a35

Browse files
craig[bot]shriramters
andcommitted
Merge #149415
149415: pgwire,provisioning,jwtauthccl: support user provisioning in jwt authentication r=souravcrl a=shriramters This commit introduces automatic user provisioning for JWT-based authentication. Previously, a valid JWT for a user not yet present in the database would result in a login failure. This change was necessary to support identity-provider-managed user provisioning, where users can be created on-the-fly during their first login. To achieve this, the pgwire JWT authentication flow is updated to conditionally create a new SQL user after successful token validation. This change introduces a `VerifyAndExtractIssuer` function on the `JWTVerifier` interface; this routine both verifies the token’s signature and issuer and also returns the issuer string needed by the provisioner, keeping the core pgwire layer decoupled from JWT library details. The provisioning source is recorded in the `PROVISIONSRC` role option for the newly created user, linking them back to the JWT issuer. This enables better auditing and management of provisioned users. The provisioning source parser and its tests have also been refactored to be more extensible for future authentication methods. Fixes: CRDB-51730 Epic: CRDB-48764 Release note (enterprise change): Added the ability to automatically provision users authenticating via JWT. This is controlled by the new cluster setting `security.provisioning.jwt.enabled`. When set to true, a successful JWT authentication for a non-existent user will create that user in CockroachDB. The newly created role will have the `PROVISIONSRC` role option set to `jwt_token:<issuer>`, identifying the token's issuer as the source of the provisioned user. Co-authored-by: Shriram Ravindranathan <[email protected]>
2 parents f43db40 + 3afab6e commit cf40a35

File tree

9 files changed

+578
-144
lines changed

9 files changed

+578
-144
lines changed

pkg/ccl/jwtauthccl/authentication_jwt.go

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ func (authenticator *jwtAuthenticator) ValidateJWTLogin(
197197
}
198198

199199
// Match the input user identity against the user identities mapped within the JWT.
200-
user, authError = authenticator.RetrieveIdentity(ctx, user, tokenBytes, identMap)
200+
user, authError = authenticator.retrieveIdentityLocked(user, tokenBytes, identMap)
201201
if authError != nil {
202202
return
203203
}
@@ -231,10 +231,40 @@ func (authenticator *jwtAuthenticator) ValidateJWTLogin(
231231
return "", nil
232232
}
233233

234-
// RetrieveIdentity is part of the JWTVerifier interface in pgwire.
234+
// RetrieveIdentity implements pgwire.JWTVerifier.
235+
//
236+
// It reloads the authenticator’s configuration from the passed
237+
// *cluster.Settings then delegates to retrieveIdentityLocked
238+
// while holding a.mu.
239+
//
240+
// The method parses `tokenBytes`, applies the ident-map, and returns
241+
// the SQL user that this JWT is allowed to act as. On success the
242+
// returned username is either `user` (when it matches a token
243+
// principal) or the single mapped identity. on failure it returns an
244+
// error context'd with "JWT authentication:".
235245
func (authenticator *jwtAuthenticator) RetrieveIdentity(
236-
ctx context.Context, user username.SQLUsername, tokenBytes []byte, identMap *identmap.Conf,
246+
ctx context.Context,
247+
st *cluster.Settings,
248+
user username.SQLUsername,
249+
tokenBytes []byte,
250+
identMap *identmap.Conf,
237251
) (retrievedUser username.SQLUsername, authError error) {
252+
authenticator.reloadConfig(ctx, st)
253+
authenticator.mu.Lock()
254+
defer authenticator.mu.Unlock()
255+
return authenticator.retrieveIdentityLocked(user, tokenBytes, identMap)
256+
}
257+
258+
// retrieveIdentityLocked contains the core principal-to-user mapping
259+
// logic. The caller must already hold a.mu; this helper therefore
260+
// performs no locking or config reloads and must never call anything
261+
// that would reacquire the same mutex.
262+
func (authenticator *jwtAuthenticator) retrieveIdentityLocked(
263+
user username.SQLUsername, tokenBytes []byte, identMap *identmap.Conf,
264+
) (retrievedUser username.SQLUsername, authError error) {
265+
if !authenticator.mu.conf.enabled {
266+
return user, errors.New("JWT authentication: not enabled")
267+
}
238268
unverifiedToken, err := jwt.ParseInsecure(tokenBytes)
239269
if err != nil {
240270
return user, errors.WithDetailf(
@@ -299,6 +329,7 @@ func (authenticator *jwtAuthenticator) RetrieveIdentity(
299329
break
300330
}
301331
}
332+
302333
if !principalMatch {
303334
// If the username is not provided, and we match it to a single user,
304335
// then use that user identity.
@@ -308,11 +339,78 @@ func (authenticator *jwtAuthenticator) RetrieveIdentity(
308339
return user, errors.WithDetailf(
309340
errors.Newf("JWT authentication: invalid principal"),
310341
"token issued for %s and login was for %s", tokenPrincipals, user.Normalized())
342+
311343
}
312344

313345
return user, nil
314346
}
315347

348+
// VerifyAndExtractIssuer checks a JWT’s JWS signature with the configured
349+
// key set and confirms the `iss` claim matches a configured issuer.
350+
//
351+
// It is called from the provisioning path in authJwtToken, AFTER
352+
// RetrieveIdentity has set the replacement identity. Audience, expiry and
353+
// other claim checks happen later in Authenticate(); this helper should not
354+
// modify state.
355+
//
356+
// Like ValidateJWTLogin, it returns two error values:
357+
//
358+
// issuer – the verified `iss` claim to build "jwt_token:<issuer>"
359+
// detailedErr – redactable detail for LogAuthFailed
360+
// authErr – high-level error shown to the client
361+
func (a *jwtAuthenticator) VerifyAndExtractIssuer(
362+
ctx context.Context, st *cluster.Settings, tokenBytes []byte,
363+
) (issuer string, detailedErr redact.RedactableString, authErr error) {
364+
a.reloadConfig(ctx, st)
365+
a.mu.Lock()
366+
defer a.mu.Unlock()
367+
368+
if !a.mu.conf.enabled {
369+
return "", "", errors.New("JWT authentication: not enabled")
370+
}
371+
372+
// Parse token without verifying signature to pull out issuer & key id.
373+
unverified, err := jwt.ParseInsecure(tokenBytes)
374+
if err != nil {
375+
return "", redact.Sprintf("JWT authentication: invalid token format: %v", err),
376+
errors.New("JWT authentication: invalid token")
377+
}
378+
379+
issuer = unverified.Issuer()
380+
if err := a.mu.conf.issuersConf.checkIssuerConfigured(issuer); err != nil {
381+
return "", "", errors.WithDetail(
382+
errors.New("JWT authentication: invalid issuer"),
383+
fmt.Sprintf("token issued by %s", issuer),
384+
)
385+
}
386+
387+
// Fetch the JWKS (auto-fetch or static) for that issuer.
388+
var set jwk.Set
389+
if a.mu.conf.jwksAutoFetchEnabled {
390+
set, err = a.remoteFetchJWKS(ctx, issuer)
391+
if err != nil {
392+
return "", redact.Sprintf("JWT authentication: unable to fetch jwks: %v", err),
393+
errors.New("JWT authentication: unable to validate token")
394+
}
395+
} else {
396+
set = a.mu.conf.jwks
397+
}
398+
399+
// JWS verification
400+
if _, err := jwt.Parse(
401+
tokenBytes,
402+
jwt.WithKeySet(set, jws.WithInferAlgorithmFromKey(true)),
403+
jwt.WithValidate(false),
404+
); err != nil {
405+
return "", "", errors.WithDetailf(
406+
errors.New("JWT authentication: invalid token"),
407+
"unable to parse token: %v", err,
408+
)
409+
}
410+
411+
return issuer, "", nil
412+
}
413+
316414
// remoteFetchJWKS fetches the JWKS URI from the provided issuer URL.
317415
func (authenticator *jwtAuthenticator) remoteFetchJWKS(
318416
ctx context.Context, issuerURL string,

pkg/ccl/jwtauthccl/authentication_jwt_test.go

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -498,14 +498,14 @@ func TestSingleClaim(t *testing.T) {
498498
require.ErrorContains(t, err, "JWT authentication: invalid principal")
499499

500500
// Validation is successful for a token with a matched principal.
501-
retrievedUser, err := verifier.RetrieveIdentity(ctx, username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap)
501+
retrievedUser, err := verifier.RetrieveIdentity(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap)
502502
require.NoError(t, err)
503503
require.Equal(t, username.MakeSQLUsernameFromPreNormalizedString(username1), retrievedUser)
504504
_, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap)
505505
require.NoError(t, err)
506506

507507
// Validation is successful for a token without a username provided, as a single principal is matched.
508-
retrievedUser, err = verifier.RetrieveIdentity(ctx, username.MakeSQLUsernameFromPreNormalizedString(""), token, identMap)
508+
retrievedUser, err = verifier.RetrieveIdentity(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(""), token, identMap)
509509
require.NoError(t, err)
510510
require.Equal(t, username.MakeSQLUsernameFromPreNormalizedString(username1), retrievedUser)
511511
_, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(""), token, identMap)
@@ -539,21 +539,21 @@ func TestMultipleClaim(t *testing.T) {
539539
require.ErrorContains(t, err, "JWT authentication: invalid principal")
540540

541541
// Validation is successful for a token with a matched principal - test1.
542-
retrievedUser, err := verifier.RetrieveIdentity(ctx, username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap)
542+
retrievedUser, err := verifier.RetrieveIdentity(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap)
543543
require.NoError(t, err)
544544
require.Equal(t, username.MakeSQLUsernameFromPreNormalizedString(username1), retrievedUser)
545545
_, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username1), token, identMap)
546546
require.NoError(t, err)
547547

548548
// Validation is successful for a token with a matched principal - test2.
549-
retrievedUser, err = verifier.RetrieveIdentity(ctx, username.MakeSQLUsernameFromPreNormalizedString(username2), token, identMap)
549+
retrievedUser, err = verifier.RetrieveIdentity(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username2), token, identMap)
550550
require.NoError(t, err)
551551
require.Equal(t, username.MakeSQLUsernameFromPreNormalizedString(username2), retrievedUser)
552552
_, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(username2), token, identMap)
553553
require.NoError(t, err)
554554

555555
// Validation fails for a token without a username provided, as multiple principals are matched.
556-
_, err = verifier.RetrieveIdentity(ctx, username.MakeSQLUsernameFromPreNormalizedString(""), token, identMap)
556+
_, err = verifier.RetrieveIdentity(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(""), token, identMap)
557557
require.ErrorContains(t, err, "JWT authentication: invalid principal")
558558
_, err = verifier.ValidateJWTLogin(ctx, s.ClusterSettings(), username.MakeSQLUsernameFromPreNormalizedString(""), token, identMap)
559559
require.ErrorContains(t, err, "JWT authentication: invalid principal")
@@ -1166,3 +1166,43 @@ func TestJWTAuthWithIssuerJWKSConfAutoFetchJWKS(t *testing.T) {
11661166
})
11671167
}
11681168
}
1169+
1170+
func TestVerifyAndExtractIssuer(t *testing.T) {
1171+
defer leaktest.AfterTest(t)()
1172+
defer log.Scope(t).Close(t)
1173+
1174+
ctx := context.Background()
1175+
s := serverutils.StartServerOnly(t, base.TestServerArgs{})
1176+
defer s.Stopper().Stop(ctx)
1177+
1178+
// Enable JWT and configure a single issuer.
1179+
JWTAuthEnabled.Override(ctx, &s.ClusterSettings().SV, true)
1180+
JWTAuthIssuersConfig.Override(ctx, &s.ClusterSettings().SV, issuer1)
1181+
1182+
// Create signing material.
1183+
keySet, key, _ := createJWKS(t)
1184+
token := createJWT(t, username1, audience1, issuer1, timeutil.Now().Add(time.Hour), key, jwa.RS256, "", "")
1185+
JWTAuthJWKS.Override(ctx, &s.ClusterSettings().SV, serializePublicKeySet(t, keySet))
1186+
1187+
verifier := ConfigureJWTAuth(ctx, s.AmbientCtx(), s.ClusterSettings(), s.StorageClusterID())
1188+
1189+
// Succeeds with correct issuer & signature.
1190+
iss, detail, err := verifier.VerifyAndExtractIssuer(ctx, s.ClusterSettings(), token)
1191+
require.NoError(t, err)
1192+
require.Empty(t, detail)
1193+
require.Equal(t, issuer1, iss)
1194+
1195+
// Fails on wrong issuer.
1196+
badIssuerToken := createJWT(t, username1, audience1, issuer2, timeutil.Now().Add(time.Hour), key, jwa.RS256, "", "")
1197+
_, detail, err = verifier.VerifyAndExtractIssuer(ctx, s.ClusterSettings(), badIssuerToken)
1198+
require.ErrorContains(t, err, "JWT authentication: invalid issuer")
1199+
require.Contains(t, errors.FlattenDetails(err), "token issued by issuer2")
1200+
require.Empty(t, detail)
1201+
1202+
// Fails on bad signature (different key).
1203+
_, _, otherKey := createJWKS(t)
1204+
badSigToken := createJWT(t, username1, audience1, issuer1, timeutil.Now().Add(time.Hour), otherKey, jwa.ES384, "", "")
1205+
_, detail, err = verifier.VerifyAndExtractIssuer(ctx, s.ClusterSettings(), badSigToken)
1206+
require.ErrorContains(t, err, "JWT authentication: invalid token")
1207+
require.Empty(t, detail)
1208+
}

pkg/ccl/logictestccl/testdata/logic_test/provisioning

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
statement error role "root" cannot have a PROVISIONSRC
55
ALTER ROLE root PROVISIONSRC 'ldap:ldap.example.com'
66

7-
statement error pq: PROVISIONSRC "ldap.example.com" was not prefixed with any valid auth methods \["ldap"\]
7+
statement error pq: PROVISIONSRC "ldap.example.com" was not prefixed with any valid auth methods \["ldap" "jwt_token"\]
88
CREATE ROLE role_with_provisioning PROVISIONSRC 'ldap.example.com'
99

1010
statement error pq: conflicting role options

0 commit comments

Comments
 (0)