Skip to content

feat(kernel): JWT private-key M2M auth via WithJWTPrivateKeyM2M - #447

Closed
rahuls-db wants to merge 4 commits into
mainfrom
feat/kernel-jwt-private-key-m2m
Closed

feat(kernel): JWT private-key M2M auth via WithJWTPrivateKeyM2M#447
rahuls-db wants to merge 4 commits into
mainfrom
feat/kernel-jwt-private-key-m2m

Conversation

@rahuls-db

Copy link
Copy Markdown
Collaborator

What

Adds OAuth machine-to-machine auth with a JWT private-key client assertion (RFC 7523) on the kernel backend (WithUseKernel(true)), via a new WithJWTPrivateKeyM2M(JWTPrivateKeyM2MConfig{...}) connector option. The kernel signs a short-lived assertion with the service principal's private key instead of sending a client secret and owns the token lifecycle.

Companion to the kernel-side feature (databricks-sql-kernel #249; napi token_url in #275) and the parallel databricks-sql-python / databricks-sql-nodejs changes.

How

  • auth/oauth/jwtm2m — new authenticator satisfying the internal JWTM2MCredentialsProvider interface (same structural-interface pattern as M2M/U2M). Authenticate returns a clear kernel-only error on the pure-Go Thrift path (JWT signing is the kernel's job).
  • internal/backend/kernelAuthJWTM2M mode + Auth JWT fields + JWTM2MCredentialsProvider; setAuth maps it to the kernel_session_config_set_auth_m2m_jwt C setter (8 args incl. token_url).
  • kernel_config.goresolveKernelAuth resolves the JWT authenticator to the descriptor (scopes + token_url forwarded, unlike the scopes-less shared-secret M2M setter).
  • connector.goWithJWTPrivateKeyM2M(JWTPrivateKeyM2MConfig{...}), a struct option (not positional args) so the many string fields can't be transposed.
  • KERNEL_REV bumped to a build including the JWT C-ABI setter; the TestSetRetry max<min case updated to the kernel's newer correct-and-warn behavior.

Usage

connector, _ := dbsql.NewConnector(
    dbsql.WithServerHostname("adb-….azuredatabricks.net"),
    dbsql.WithHTTPPath("/sql/1.0/warehouses/…"),
    dbsql.WithJWTPrivateKeyM2M(dbsql.JWTPrivateKeyM2MConfig{
        ClientID: "<sp-client-id>",
        KeyFile:  "/path/private_key.pem",
        Kid:      "<kid>",
        TokenURL: "https://login.microsoftonline.com/<tenant>/oauth2/v2.0/token",
        Scopes:   []string{"<databricks-resource-id>/.default"},
    }),
    dbsql.WithUseKernel(true),
)

Testing

  • Unit: jwtm2m authenticator tests; resolveKernelAuth JWT cases in kernel_config_test.go.
  • cgo: set_auth_m2m_jwt marshalling case in TestSetAuthByMode (tagged build).
  • E2E: env-gated TestKernelE2EJWTM2MSelect1 (tagged build). Verified end-to-end against an Azure Databricks warehouse: SELECT 1 = 1 via the kernel backend, authenticated by Entra ID with a JWT private-key assertion.

Not in scope

DSN-string configuration (authType=oauthm2m-jwt + key-file/passphrase/tokenUrl query params) is deferred to a follow-up — the programmatic WithJWTPrivateKeyM2M is the primary surface (passing a key-file path + passphrase through a DSN query string is awkward, and there is no cross-driver DSN precedent for JWT). Use the option.

This pull request and its description were written by Isaac.

Add OAuth M2M auth with a JWT private-key client assertion (RFC 7523) on
the kernel backend. The kernel signs a short-lived assertion with the
private key instead of sending a client secret and owns the token
lifecycle. Kernel-backend only (the pure-Go Thrift path can't sign the
assertion).

- auth/oauth/jwtm2m: new authenticator satisfying the (internal)
  JWTM2MCredentialsProvider interface; Authenticate returns a clear
  kernel-only error on the Thrift path.
- internal/backend/kernel: AuthJWTM2M mode + Auth JWT fields +
  JWTM2MCredentialsProvider interface; setAuth maps it to the
  kernel_session_config_set_auth_m2m_jwt C setter (8 args incl. token_url).
- kernel_config: resolveKernelAuth resolves the JWT authenticator to the
  descriptor (scopes + token_url forwarded, unlike the scopes-less
  shared-secret M2M setter).
- connector: WithJWTPrivateKeyM2M(JWTPrivateKeyM2MConfig{...}) — a struct
  option (not positional args) so the many fields can't be transposed.
- Bump KERNEL_REV to a build with the JWT C-ABI setter; update the
  TestSetRetry max<min case to the kernel's new correct-and-warn behavior.
- Tests: jwtm2m unit tests, resolveKernelAuth JWT cases, a cgo
  set_auth_m2m_jwt marshalling case, and an env-gated live E2E
  (TestKernelE2EJWTM2MSelect1).

Verified end-to-end: SELECT 1 via WithUseKernel(true) +
WithJWTPrivateKeyM2M against an Azure Databricks warehouse, authenticated
by Entra ID with a JWT private-key assertion.

DSN-string config (authType=...) for JWT M2M is deferred to a follow-up.

Signed-off-by: Rahul Singhal <rahul.singhal@databricks.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: 2 Low

Looks good — clean addition that faithfully mirrors the existing M2M/U2M structural-interface + kernel-setter pattern, with matching unit/cgo/e2e coverage. Two low-severity doc/consistency nits: a stray blank line splits the godoc option list, and the default-case rejection message + docstrings still enumerate only PAT/M2M/U2M as supported. The 8-arg set_auth_m2m_jwt marshalling (newCStr for required, newCStrOrNull for optional), scope forwarding, and free() handling all look correct and consistent with the U2M path.

Comment thread doc.go Outdated
- WithMaxDownloadThreads (<num_threads> int). Sets up the max number of concurrent workers for cloud fetch. Default is 10. Optional
- WithAuthenticator (<authenticator> auth.Authenticator). Sets up authentication. Required if neither access token or client credentials are provided.
- WithClientCredentials(<clientID> string, <clientSecret> string). Sets up Oauth M2M authentication.
- WithJWTPrivateKeyM2M(<config> JWTPrivateKeyM2MConfig). Sets up OAuth M2M authentication with a JWT private-key client assertion (RFC 7523) instead of a client secret. Kernel backend only (requires WithUseKernel(true)); the kernel signs the assertion. See the kernel-backend section. Optional

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low — The new WithJWTPrivateKeyM2M bullet is followed by a blank line before the WithUseKernel bullet. In godoc's comment parser a blank line between list items terminates the list, so this splits the single "Supported functional options" bullet list into two separate lists — WithUseKernel / WithWarehouseID render as a detached second list rather than continuing the same one. Every other bullet in this block is contiguous. Remove the blank line to keep it one list.

Comment thread kernel_config.go
// an opaque Unauthenticated.
func resolveKernelAuth(cfg *config.Config) (kernel.Auth, error) {
switch a := cfg.Authenticator.(type) {
case kernel.JWTM2MCredentialsProvider:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low — Now that JWT private-key M2M is a supported kernel auth form (this new case), the sibling enumerations that list what the kernel supports are stale: (1) the default: case's rejection message still reads "PAT (WithAccessToken) and OAuth M2M/U2M (WithClientCredentials / authType) are supported" — it omits JWT private-key M2M / WithJWTPrivateKeyM2M; and (2) the resolveKernelAuth doc comment's bullet list and the validateKernelConfig docstring ("PAT, or OAuth M2M/U2M") likewise don't mention the JWT mode. A caller who lands in the default rejection will see an incomplete list of supported options. Consider updating both the message and the docstrings to include JWT private-key M2M.

Address golangci-lint failures on #447:
- gofmt doc.go after the WithJWTPrivateKeyM2M option-list addition.
- //nolint:gosec G101 on the fakeJWTM2MAuth test literal (a fake
  passphrase in a unit test, not a real credential) — same convention
  the existing proxy tests use.

Signed-off-by: Rahul Singhal <rahul.singhal@databricks.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: 2 Low

Solid, well-tested addition — kernel-only JWT private-key M2M auth is wired cleanly through the connector, auth descriptor, and cgo setter with matching unit/e2e coverage. Two low-severity notes: the option silently no-ops on a missing required field (misleading downstream PAT error), and JWT M2M kernel connections still run telemetry through an authenticator whose Authenticate always errors (best-effort, non-blocking, but a silent observability gap vs. the PAT/M2M paths).

Comment thread connector.go Outdated
// this authenticator fails at authenticate time with a clear kernel-only error.
func WithJWTPrivateKeyM2M(cfg JWTPrivateKeyM2MConfig) ConnOption {
return func(c *config.Config) {
if cfg.ClientID != "" && cfg.KeyFile != "" && cfg.Kid != "" {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low — WithJWTPrivateKeyM2M silently does nothing if any of the three required fields (ClientID, KeyFile, Kid) is empty — c.Authenticator is left untouched. This mirrors the existing WithClientCredentials guard, but here there are three required string fields (easy to forget Kid, or leave one blank from an unset env var), and the failure mode is confusing: with no other authenticator set, the kernel path falls through resolveKernelAuth to the PAT branch and reports "the kernel backend requires a personal access token" — which names the wrong problem. Worse, if WithAccessToken was also supplied, the connection silently authenticates as PAT while the caller believes they configured JWT M2M.

Consider not silently dropping a partially-specified JWT config — e.g. still install the authenticator (letting the kernel report the missing field) or surface the misconfiguration, so a typo/blank field doesn't degrade to a misleading PAT error. Not a blocker given the established convention, but the three-field surface makes this materially easier to hit than the two-field M2M option.

Comment thread connector.go Outdated
"time"

"github.com/databricks/databricks-sql-go/auth"
"github.com/databricks/databricks-sql-go/auth/oauth/jwtm2m"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low — On the kernel backend, telemetry is only skipped for U2M (interactiveU2MAuthenticator check here). A JWT M2M connection goes through normal telemetry init, whose feature-flag and export HTTP calls use the authenticated c.client. That client's Transport.RoundTrip calls t.Authr.Authenticate(req2) (client.go:549), and the jwtm2m authenticator's Authenticate unconditionally returns the kernel-only error — so every telemetry/feature-flag request fails auth. The connection still succeeds (telemetry is best-effort), but telemetry is effectively always disabled for JWT M2M kernel connections and each attempt burns a failing (potentially retried) HTTP round-trip at connect time. This is inconsistent with the PAT/M2M kernel paths, where Authenticate actually works. Worth considering extending the telemetry-skip (or providing a token source) for the JWT M2M kernel case.

(Anchored to the nearest changed line — see the description for the exact location.)

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Addresses PR #447 review feedback:

- Gate the public JWT private-key M2M surface behind
  `cgo && databricks_kernel`. Move WithJWTPrivateKeyM2M and
  JWTPrivateKeyM2MConfig out of connector.go into a new tagged
  connector_kernel_jwt.go, and tag the jwtm2m package itself. The
  pure-Go Thrift path has no JWT-signing implementation, so a
  default (Thrift-only) build no longer exposes an auth option it
  cannot honor (Eric's divergence concern). The internal
  JWTM2MCredentialsProvider interface stays untagged so resolveKernelAuth
  still compiles in the default build.
- Skip telemetry on the kernel JWT M2M path: its Authenticate always
  returns a kernel-only error, so telemetry/feature-flag HTTP calls
  would fail every request and burn round-trips at connect. Extend the
  existing U2M telemetry-skip to cover it (bot F2).
- Fix the godoc option list: restore the contiguous bullet style (a
  blank line between items terminates the godoc list) and add the JWT
  bullet inline (bot F1).
- Update the stale supported-auth enumerations: resolveKernelAuth doc,
  validateKernelConfig docstring, and the default-case rejection
  message now include JWT private-key M2M (bot F2, first review).

Co-authored-by: Isaac
Signed-off-by: Rahul Singhal <rahul.singhal@databricks.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: 1 Low

Looks good — a clean, well-tested addition of kernel-only JWT private-key M2M auth (RFC 7523). Build-tag gating, interface pattern, and unit/cgo/e2e coverage all match existing M2M/U2M conventions; one low note on silent partial-config drop. Nit: the TestKernelE2EJWTM2MSelect1 env-var doc block (kernel_jwt_e2e_test.go) lists DATABRICKS_JWT_TOKEN_URL/DATABRICKS_JWT_SCOPES as optional but omits DATABRICKS_JWT_PASSPHRASE and DATABRICKS_JWT_ALGORITHM, which the test body also reads — worth listing for completeness."

Comment thread connector_kernel_jwt.go
func WithJWTPrivateKeyM2M(cfg JWTPrivateKeyM2MConfig) ConnOption {
return func(c *config.Config) {
if cfg.ClientID != "" && cfg.KeyFile != "" && cfg.Kid != "" {
c.Authenticator = jwtm2m.NewAuthenticator(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low — WithJWTPrivateKeyM2M silently does nothing when any of the three required fields (ClientID, KeyFile, Kid) is empty — c.Authenticator is left untouched. This mirrors the established WithClientCredentials/WithAccessToken pattern, so it's consistent, but it is a footgun: a caller who supplies ClientID + KeyFile but forgets Kid gets 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.

… vars

Addresses PR #447 review (peco-review-bot, 1 Low + a doc nit):

- WithJWTPrivateKeyM2M now warns when given a PARTIAL config (some of the
  required ClientID/KeyFile/Kid set, others blank — e.g. a typo or unset env
  var). A fully-empty config stays a silent no-op (consistent with
  WithClientCredentials/WithAccessToken), but a partial one would otherwise
  install no authenticator and later surface as a misleading "requires a
  personal access token" error, so the misconfiguration is now visible.
- Document DATABRICKS_JWT_PASSPHRASE and DATABRICKS_JWT_ALGORITHM in the
  TestKernelE2EJWTM2MSelect1 env-var block (the test body already reads them).

Adds a kernel-tagged unit test for full/empty/partial WithJWTPrivateKeyM2M.

Co-authored-by: Isaac
Signed-off-by: Rahul Singhal <rahul.singhal@databricks.com>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No issues identified by the review bot.

@rahuls-db rahuls-db closed this Aug 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants