-
Notifications
You must be signed in to change notification settings - Fork 65
feat(kernel): JWT private-key M2M auth via WithJWTPrivateKeyM2M #447
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
c425029
feat(kernel): support JWT private-key M2M auth via WithJWTPrivateKeyM2M
rahuls-db 2ab4b65
style: gofmt doc.go + suppress gosec G101 on JWT test literals
rahuls-db 268fb1e
fix(kernel): gate JWT M2M public surface behind kernel build tag
rahuls-db 47ea68a
fix(kernel): warn on partial JWT M2M config; document all JWT e2e env…
rahuls-db File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1 @@ | ||
| 9ac3f3d3f3e804d52d8890e890d9f8a8a617ec93 | ||
| ef2c8bedb0345076d3147e05c35362778ac4e130 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| //go:build cgo && databricks_kernel | ||
|
|
||
| // Package jwtm2m provides an OAuth machine-to-machine authenticator that | ||
| // authenticates with a JWT private-key client assertion (RFC 7523) instead of | ||
| // a client secret. | ||
| // | ||
| // This authenticator is KERNEL-BACKEND ONLY. The JWT is signed by the native | ||
| // kernel (which owns the assertion signing + token lifecycle); the pure-Go | ||
| // Thrift path has no JWT-signing implementation, so Authenticate returns an | ||
| // error directing the caller at the kernel backend. The authenticator exists | ||
| // so cfg.Authenticator stays the single source of truth for auth on both | ||
| // backends: the kernel selects this mode by asserting the (internal) | ||
| // JWTM2MCredentialsProvider interface that this type satisfies structurally. | ||
| package jwtm2m | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "net/http" | ||
|
|
||
| "github.com/databricks/databricks-sql-go/auth" | ||
| ) | ||
|
|
||
| // NewAuthenticator builds a JWT private-key M2M authenticator. clientID, | ||
| // keyFile, and kid are required; passphrase (for an encrypted PKCS#8 key), | ||
| // algorithm (default RS256), tokenURL (the OAuth IdP token endpoint — required | ||
| // for an external-IdP-backed workspace such as Entra ID), and scopes are | ||
| // optional. | ||
| func NewAuthenticator(clientID, keyFile, kid, passphrase, algorithm, tokenURL string, scopes []string) auth.Authenticator { | ||
| return &authClient{ | ||
| clientID: clientID, | ||
| keyFile: keyFile, | ||
| kid: kid, | ||
| passphrase: passphrase, | ||
| algorithm: algorithm, | ||
| tokenURL: tokenURL, | ||
| scopes: scopes, | ||
| } | ||
| } | ||
|
|
||
| type authClient struct { | ||
| clientID string | ||
| keyFile string | ||
| kid string | ||
| passphrase string | ||
| algorithm string | ||
| tokenURL string | ||
| scopes []string | ||
| } | ||
|
|
||
| // JWTM2MCredentials exposes the private-key assertion inputs so the kernel | ||
| // backend can drive the kernel's own JWT client-assertion flow. It structurally | ||
| // satisfies the JWTM2MCredentialsProvider interface the kernel backend asserts | ||
| // (defined in internal/backend/kernel, so the key-reading capability is not part | ||
| // of the driver's public API). | ||
| func (c *authClient) JWTM2MCredentials() (clientID, keyFile, kid, passphrase, algorithm, tokenURL string, scopes []string) { | ||
| return c.clientID, c.keyFile, c.kid, c.passphrase, c.algorithm, c.tokenURL, c.scopes | ||
| } | ||
|
|
||
| // Authenticate is unsupported on the pure-Go (Thrift) path: signing a JWT | ||
| // client assertion is done by the native kernel, not the Go driver. Use the | ||
| // kernel backend (WithUseKernel(true)) with this authenticator. | ||
| func (c *authClient) Authenticate(r *http.Request) error { | ||
| return fmt.Errorf("jwtm2m: JWT private-key M2M is only supported on the kernel backend; " + | ||
| "enable it with WithUseKernel(true)") | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| //go:build cgo && databricks_kernel | ||
|
|
||
| package jwtm2m | ||
|
|
||
| import ( | ||
| "net/http" | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
| ) | ||
|
|
||
| func TestJWTM2MCredentials(t *testing.T) { | ||
| t.Run("forwards all fields verbatim", func(t *testing.T) { | ||
| a := NewAuthenticator( | ||
| "sp-uuid", "/keys/jwt.pem", "kid-1", "pw", "ES256", | ||
| "https://login.microsoftonline.com/tenant/oauth2/v2.0/token", | ||
| []string{"resource/.default"}, | ||
| ) | ||
| clientID, keyFile, kid, passphrase, algorithm, tokenURL, scopes := | ||
| a.(*authClient).JWTM2MCredentials() | ||
| assert.Equal(t, "sp-uuid", clientID) | ||
| assert.Equal(t, "/keys/jwt.pem", keyFile) | ||
| assert.Equal(t, "kid-1", kid) | ||
| assert.Equal(t, "pw", passphrase) | ||
| assert.Equal(t, "ES256", algorithm) | ||
| assert.Equal(t, "https://login.microsoftonline.com/tenant/oauth2/v2.0/token", tokenURL) | ||
| assert.Equal(t, []string{"resource/.default"}, scopes) | ||
| }) | ||
|
|
||
| t.Run("empty optionals stay empty", func(t *testing.T) { | ||
| a := NewAuthenticator("sp", "/k.pem", "kid", "", "", "", nil) | ||
| _, _, _, passphrase, algorithm, tokenURL, scopes := a.(*authClient).JWTM2MCredentials() | ||
| assert.Equal(t, "", passphrase) | ||
| assert.Equal(t, "", algorithm) | ||
| assert.Equal(t, "", tokenURL) | ||
| assert.Nil(t, scopes) | ||
| }) | ||
| } | ||
|
|
||
| func TestAuthenticateIsKernelOnly(t *testing.T) { | ||
| // The Go (Thrift) path can't sign a JWT client assertion — signing is the | ||
| // kernel's job — so Authenticate must fail loudly pointing at the kernel. | ||
| a := NewAuthenticator("sp", "/k.pem", "kid", "", "", "", nil) | ||
| err := a.Authenticate(&http.Request{}) | ||
| assert.Error(t, err) | ||
| assert.Contains(t, err.Error(), "kernel backend") | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| //go:build cgo && databricks_kernel | ||
|
|
||
| package dbsql | ||
|
|
||
| import ( | ||
| "github.com/databricks/databricks-sql-go/auth/oauth/jwtm2m" | ||
| "github.com/databricks/databricks-sql-go/internal/config" | ||
| "github.com/databricks/databricks-sql-go/logger" | ||
| ) | ||
|
|
||
| // JWTPrivateKeyM2MConfig configures OAuth machine-to-machine authentication via | ||
| // a JWT private-key client assertion (RFC 7523). A struct is used (rather than | ||
| // positional args) so the many string fields can't be transposed. | ||
| // | ||
| // KERNEL BACKEND ONLY: the assertion is signed by the native kernel, so both | ||
| // this type and WithJWTPrivateKeyM2M exist only in a build compiled with the | ||
| // kernel backend (`cgo && databricks_kernel`). A default (Thrift-only) build | ||
| // does not expose them, so a Thrift user can't configure an auth mode the | ||
| // Thrift path cannot honor. | ||
| type JWTPrivateKeyM2MConfig struct { | ||
| // ClientID is the service principal / OAuth client id (the assertion | ||
| // issuer and subject). | ||
| ClientID string | ||
| // KeyFile is the path to the PEM-encoded private key that signs the | ||
| // assertion. | ||
| KeyFile string | ||
| // Kid is the key id written into the JWT header so the IdP can select the | ||
| // registered public key. | ||
| Kid string | ||
| // Passphrase decrypts an encrypted PKCS#8 key; leave empty for an | ||
| // unencrypted key. | ||
| Passphrase string | ||
| // Algorithm is the JWT signing algorithm (RS256/384/512, PS256/384/512, | ||
| // ES256, ES384); empty defaults to RS256. | ||
| Algorithm string | ||
| // TokenURL is the OAuth IdP token endpoint. Required when the workspace's | ||
| // OAuth authority is an external IdP (e.g. Entra ID for Azure Databricks), | ||
| // since Databricks-native OIDC does not advertise the private_key_jwt | ||
| // method; empty falls back to the kernel's OIDC discovery. | ||
| TokenURL string | ||
| // Scopes overrides the requested OAuth scopes; empty uses the kernel | ||
| // default (all-apis). | ||
| Scopes []string | ||
| } | ||
|
|
||
| // WithJWTPrivateKeyM2M sets up OAuth M2M authentication using a JWT private-key | ||
| // client assertion. See JWTPrivateKeyM2MConfig. Requires the kernel backend | ||
| // (WithUseKernel(true)); the kernel signs the assertion. This option is only | ||
| // compiled into a kernel-enabled build (`cgo && databricks_kernel`) — the | ||
| // pure-Go Thrift path has no JWT-signing implementation, so exposing it there | ||
| // would only add a divergent, non-functional surface. | ||
| func WithJWTPrivateKeyM2M(cfg JWTPrivateKeyM2MConfig) ConnOption { | ||
| return func(c *config.Config) { | ||
| if cfg.ClientID != "" && cfg.KeyFile != "" && cfg.Kid != "" { | ||
| c.Authenticator = jwtm2m.NewAuthenticator( | ||
| cfg.ClientID, cfg.KeyFile, cfg.Kid, cfg.Passphrase, cfg.Algorithm, cfg.TokenURL, cfg.Scopes, | ||
| ) | ||
| return | ||
| } | ||
| // Consistent with WithClientCredentials/WithAccessToken, a fully-empty | ||
| // config is a no-op (the caller simply didn't pick this option). But a | ||
| // PARTIAL config (some required fields set, others blank — e.g. a typo or | ||
| // an unset env var) is almost certainly a mistake: no authenticator gets | ||
| // installed, so connect later fails with a misleading "requires a personal | ||
| // access token" (or authenticates as PAT if WithAccessToken was also set). | ||
| // Warn so the misconfiguration isn't wholly invisible, rather than | ||
| // silently dropping it. | ||
| if cfg.ClientID != "" || cfg.KeyFile != "" || cfg.Kid != "" { | ||
| logger.Warn().Msg("WithJWTPrivateKeyM2M: incomplete config; ClientID, KeyFile, and Kid are all " + | ||
| "required. No JWT authenticator was installed — the connection will not use JWT private-key M2M.") | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| //go:build cgo && databricks_kernel | ||
|
|
||
| package dbsql | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "github.com/databricks/databricks-sql-go/internal/config" | ||
| ) | ||
|
|
||
| func TestWithJWTPrivateKeyM2M(t *testing.T) { | ||
| apply := func(cfg JWTPrivateKeyM2MConfig) *config.Config { | ||
| c := &config.Config{} | ||
| WithJWTPrivateKeyM2M(cfg)(c) | ||
| return c | ||
| } | ||
|
|
||
| t.Run("full config installs the JWT authenticator", func(t *testing.T) { | ||
| c := apply(JWTPrivateKeyM2MConfig{ClientID: "sp", KeyFile: "/k.pem", Kid: "kid-1"}) | ||
| if c.Authenticator == nil { | ||
| t.Fatal("expected an authenticator to be installed for a complete JWT config") | ||
| } | ||
| // It must satisfy the interface the kernel backend asserts to select JWT M2M. | ||
| if _, ok := c.Authenticator.(interface { | ||
| JWTM2MCredentials() (string, string, string, string, string, string, []string) | ||
| }); !ok { | ||
| t.Fatalf("installed authenticator %T does not expose JWTM2MCredentials", c.Authenticator) | ||
| } | ||
| }) | ||
|
|
||
| t.Run("empty config is a silent no-op", func(t *testing.T) { | ||
| c := apply(JWTPrivateKeyM2MConfig{}) | ||
| if c.Authenticator != nil { | ||
| t.Fatalf("expected no authenticator for an empty config, got %T", c.Authenticator) | ||
| } | ||
| }) | ||
|
|
||
| t.Run("partial config installs no authenticator", func(t *testing.T) { | ||
| // ClientID + KeyFile but no Kid: the required trio is incomplete, so no | ||
| // authenticator is installed (and WithJWTPrivateKeyM2M warns). | ||
| c := apply(JWTPrivateKeyM2MConfig{ClientID: "sp", KeyFile: "/k.pem"}) | ||
| if c.Authenticator != nil { | ||
| t.Fatalf("expected no authenticator for a partial config, got %T", c.Authenticator) | ||
| } | ||
| }) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,9 +10,10 @@ package kernel | |
| type AuthMode int | ||
|
|
||
| const ( | ||
| AuthPAT AuthMode = iota // personal access token | ||
| AuthM2M // OAuth client-credentials (client id + secret) | ||
| AuthU2M // OAuth user-to-machine (browser/PKCE; kernel-owned flow) | ||
| AuthPAT AuthMode = iota // personal access token | ||
| AuthM2M // OAuth client-credentials (client id + secret) | ||
| AuthU2M // OAuth user-to-machine (browser/PKCE; kernel-owned flow) | ||
| AuthJWTM2M // OAuth client-credentials via a JWT private-key client assertion | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same here, we do not need it in Go if it does not support it now. |
||
| ) | ||
|
|
||
| // Auth is the resolved auth descriptor for a kernel connection. Only the fields | ||
|
|
@@ -29,10 +30,18 @@ const ( | |
| type Auth struct { | ||
| Mode AuthMode | ||
| Token string // PAT | ||
| ClientID string // M2M + U2M (U2M: the cloud-inferred Go client id) | ||
| ClientID string // M2M + U2M + JWT M2M (U2M: the cloud-inferred Go client id) | ||
| ClientSecret string // M2M | ||
| Scopes []string // U2M — Thrift-parity scopes from oauth.GetScopes; nil → kernel default | ||
| Scopes []string // U2M / JWT M2M — nil → kernel default | ||
| RedirectPort uint16 // U2M — no user option today; 0 → kernel default port (8020) | ||
|
|
||
| // JWT private-key M2M (Mode == AuthJWTM2M). The kernel signs a short-lived | ||
| // client assertion with the private key instead of sending a secret. | ||
| JWTKeyFile string // path to the PEM private key (required) | ||
| JWTKid string // key id written into the JWT header (required) | ||
| JWTPassphrase string // passphrase for an encrypted PKCS#8 key ("" → unencrypted) | ||
| JWTAlgorithm string // signing algorithm ("" → kernel default RS256) | ||
| TokenURL string // OAuth IdP token endpoint ("" → kernel OIDC discovery) | ||
| } | ||
|
|
||
| // M2MCredentialsProvider is implemented by the OAuth M2M authenticator to expose | ||
|
|
@@ -78,3 +87,18 @@ type U2MCredentialsProvider interface { | |
| // U2MClientID returns the OAuth client id for the U2M browser flow. | ||
| U2MClientID() string | ||
| } | ||
|
|
||
| // JWTM2MCredentialsProvider is implemented by the JWT private-key M2M | ||
| // authenticator to expose the assertion-signing inputs. The kernel backend reads | ||
| // these to drive the kernel's own JWT client-assertion flow (the kernel signs the | ||
| // assertion and owns the token exchange), rather than using the authenticator's | ||
| // Authenticate method — which is unsupported on the pure-Go Thrift path anyway. | ||
| // Internal for the same reason as M2MCredentialsProvider (the key-reading | ||
| // capability is not part of the driver's public API); satisfied structurally by | ||
| // the unexported jwtm2m authenticator. | ||
| type JWTM2MCredentialsProvider interface { | ||
| // JWTM2MCredentials returns the client id, private-key file path, key id, | ||
| // passphrase (may be ""), algorithm (may be ""), token URL (may be ""), and | ||
| // scopes (may be nil). | ||
| JWTM2MCredentials() (clientID, keyFile, kid, passphrase, algorithm, tokenURL string, scopes []string) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔵 Low —
WithJWTPrivateKeyM2Msilently does nothing when any of the three required fields (ClientID,KeyFile,Kid) is empty —c.Authenticatoris left untouched. This mirrors the establishedWithClientCredentials/WithAccessTokenpattern, so it's consistent, but it is a footgun: a caller who suppliesClientID+KeyFilebut forgetsKidgets no authenticator set, and the failure surfaces later as a confusing "unsupported authenticator" / unauthenticated error at connect rather than at option-construction time. Since a struct option already avoids field transposition (the stated design goal), consider at least logging a debug/warn when a partial JWT config is dropped, so the misconfiguration isn't wholly invisible. Not blocking — flagging for awareness given it's the intended pattern.