From 30db9d883251790cdae98c2866421e2d08a0427c Mon Sep 17 00:00:00 2001 From: Justin Richer Date: Fri, 8 May 2026 16:36:14 -0400 Subject: [PATCH 01/14] Wire atlas-sdk-go local checkout via replace directive for atlas-cli-oauth development The Go module system requires the replaced module path to match the local module's declared path. Our local atlas-sdk-go declares v20250312018, so the replace directive targets that version and transport.go's import is updated to match. The SDK version itself is otherwise unchanged in behavior. Assisted-by: Claude Code (claude-sonnet-4-6) --- go.mod | 6 ++++-- go.sum | 10 ++++------ transport/transport.go | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index c2b32d0..c92b35a 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/stretchr/testify v1.11.1 github.com/zalando/go-keyring v0.2.6 go.mongodb.org/atlas v0.38.0 - go.mongodb.org/atlas-sdk/v20250312006 v20250312006.0.0 + go.mongodb.org/atlas-sdk/v20250312018 v20250312018.1.0 go.uber.org/mock v0.6.0 ) @@ -32,7 +32,7 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/mod v0.27.0 // indirect - golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.28.0 // indirect @@ -40,4 +40,6 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) +replace go.mongodb.org/atlas-sdk/v20250312018 => ../atlas-sdk-go + tool go.uber.org/mock/mockgen diff --git a/go.sum b/go.sum index 2089a52..4dcdb44 100644 --- a/go.sum +++ b/go.sum @@ -49,8 +49,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= @@ -59,16 +59,14 @@ github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8u github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= go.mongodb.org/atlas v0.38.0 h1:zfwymq20GqivGwxPZfypfUDry+WwMGVui97z1d8V4bU= go.mongodb.org/atlas v0.38.0/go.mod h1:DJYtM+vsEpPEMSkQzJnFHrT0sP7ev6cseZc/GGjJYG8= -go.mongodb.org/atlas-sdk/v20250312006 v20250312006.0.0 h1:3y9pfi0UYVTz/44lhtVQkvDWg/QMB+jOoxA7poab1nU= -go.mongodb.org/atlas-sdk/v20250312006 v20250312006.0.0/go.mod h1:UZYSaCimjGs3j+wMwgHSKUSIvoJXzmy/xrer0t5TLgo= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= diff --git a/transport/transport.go b/transport/transport.go index 9c29b5c..0b1034d 100644 --- a/transport/transport.go +++ b/transport/transport.go @@ -24,7 +24,7 @@ import ( "github.com/mongodb-forks/digest" "github.com/mongodb/atlas-cli-core/config" - "go.mongodb.org/atlas-sdk/v20250312006/auth/clientcredentials" + "go.mongodb.org/atlas-sdk/v20250312018/auth/clientcredentials" atlasauth "go.mongodb.org/atlas/auth" ) From f1ea6c41ca1e9b7c9bfee5a24c826928db5e8131 Mon Sep 17 00:00:00 2001 From: Justin Richer Date: Fri, 8 May 2026 19:25:11 -0400 Subject: [PATCH 02/14] Add UserDelegation auth mechanism and FlowForAuthIssuer for dedicated OAuth AS Adds UserDelegation as a new AuthMechanism value for sessions created via the new connect path. FlowForAuthIssuer creates an auth.Config that uses the AuthServerURL field from the underlying library, keeping it separate from the existing FlowWithConfig path and its OpsManagerURL/AccountURL dependencies. Also wires the local go-client-mongodb-atlas checkout via replace directive. Assisted-by: Claude Code (claude-opus-4-6) --- config/profile.go | 1 + go.mod | 5 ++++- go.sum | 2 -- transport/oauth.go | 48 +++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 52 insertions(+), 4 deletions(-) diff --git a/config/profile.go b/config/profile.go index 11dd750..5a61d14 100644 --- a/config/profile.go +++ b/config/profile.go @@ -313,6 +313,7 @@ type AuthMechanism string const ( APIKeys AuthMechanism = "api_keys" UserAccount AuthMechanism = "user_account" + UserDelegation AuthMechanism = "user_delegation" ServiceAccount AuthMechanism = "service_account" NoAuth AuthMechanism = "no_auth" ) diff --git a/go.mod b/go.mod index c92b35a..75a2fef 100644 --- a/go.mod +++ b/go.mod @@ -40,6 +40,9 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace go.mongodb.org/atlas-sdk/v20250312018 => ../atlas-sdk-go +replace ( + go.mongodb.org/atlas => ../go-client-mongodb-atlas + go.mongodb.org/atlas-sdk/v20250312018 => ../atlas-sdk-go +) tool go.uber.org/mock/mockgen diff --git a/go.sum b/go.sum index 4dcdb44..2dea94b 100644 --- a/go.sum +++ b/go.sum @@ -57,8 +57,6 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= -go.mongodb.org/atlas v0.38.0 h1:zfwymq20GqivGwxPZfypfUDry+WwMGVui97z1d8V4bU= -go.mongodb.org/atlas v0.38.0/go.mod h1:DJYtM+vsEpPEMSkQzJnFHrT0sP7ev6cseZc/GGjJYG8= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= diff --git a/transport/oauth.go b/transport/oauth.go index e1c01c7..76927c1 100644 --- a/transport/oauth.go +++ b/transport/oauth.go @@ -16,6 +16,7 @@ package transport import ( "net/http" + "net/url" "github.com/mongodb/atlas-cli-core/config" "go.mongodb.org/atlas/auth" @@ -24,9 +25,17 @@ import ( const ( cloudGovServiceURL = "https://cloud.mongodbgov.com/" - // Client IDs + // Client IDs for the existing auth path (cloud.mongodb.com proxy → auth.mongodb.com) ClientID = "0oabtxactgS3gHIR0297" // ClientID for production GovClientID = "0oabtyfelbTBdoucy297" // GovClientID for production + + // Client IDs and gov URL for the dedicated OAuth Authorization Server. + // The commercial auth issuer URL comes from the auth.Config.AuthServerURL field, + // populated by default in auth.NewConfig from go.mongodb.org/atlas. + // The gov URL is a placeholder pending the gov AS deployment. + govDefaultAuthIssuerURL = "https://authorize.mongodbgov.com" + authIssuerClientID = "0oabtxactgS3gHIR0297" // placeholder: same as production until new AS client is provisioned + govAuthIssuerClientID = "0oabtyfelbTBdoucy297" // placeholder: same as gov production until new AS client is provisioned ) type ServiceGetter interface { @@ -36,6 +45,13 @@ type ServiceGetter interface { AccountURL() string } +// AuthIssuerGetter provides the minimal config needed for the dedicated OAuth AS flow. +// Unlike ServiceGetter, it does not expose OpsManagerURL or AccountURL because the +// auth issuer URL is compiled in, not derived from the API base URL. +type AuthIssuerGetter interface { + Service() string +} + func FlowWithConfig(c ServiceGetter, client *http.Client, version string) (*auth.Config, error) { id := ClientID if c.Service() == config.CloudGovService { @@ -60,3 +76,33 @@ func FlowWithConfig(c ServiceGetter, client *http.Client, version string) (*auth } return auth.NewConfigWithOptions(client, authOpts...) } + +// FlowForAuthIssuer creates an auth.Config for the dedicated OAuth Authorization Server. +// This is the parallel path for UserDelegation sessions and intentionally does not read +// OpsManagerURL or AccountURL — the auth server URL comes from the Config's AuthServerURL +// field, populated by default in auth.NewConfig. +func FlowForAuthIssuer(c AuthIssuerGetter, client *http.Client, version string) (*auth.Config, error) { + id := authIssuerClientID + if c.Service() == config.CloudGovService { + id = govAuthIssuerClientID + } + + cfg, err := auth.NewConfigWithOptions(client, + auth.SetUserAgent(config.UserAgent(version)), + auth.SetClientID(id), + auth.SetScopes([]string{"openid", "profile", "offline_access"}), + ) + if err != nil { + return nil, err + } + + // For gov, override the auth server URL with the gov-specific endpoint. + if c.Service() == config.CloudGovService { + cfg.AuthServerURL, err = url.Parse(govDefaultAuthIssuerURL) + if err != nil { + return nil, err + } + } + + return cfg, nil +} From 0c5ef53d8e098a685cfdbdef1df27cab01324dba Mon Sep 17 00:00:00 2001 From: Justin Richer Date: Fri, 8 May 2026 19:37:24 -0400 Subject: [PATCH 03/14] Add UserDelegation case in HTTPClientFromProfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routes UserDelegation sessions through NewAccessTokenTransportForAuthIssuer so that token refresh targets the dedicated OAuth AS (AuthServerURL) instead of the cloud.mongodb.com proxy. Placed after ServiceAccount in the switch to preserve the existing UserAccount → ServiceAccount fallthrough behavior. Assisted-by: Claude Code (claude-opus-4-6) --- transport/client.go | 20 ++++++++++++++++++++ transport/transport.go | 24 ++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/transport/client.go b/transport/client.go index 6b386d5..413be8b 100644 --- a/transport/client.go +++ b/transport/client.go @@ -69,6 +69,26 @@ func HTTPClientFromProfile(profile ProfileProvider, version string, httpTranspor fallthrough case config.ServiceAccount: return NewServiceAccountClientWithHost(profile.ClientID(), profile.ClientSecret(), profile.OpsManagerURL()), nil + case config.UserDelegation: + token, err := profile.Token() + if err != nil { + return nil, err + } + + if token != nil { + tr, err := NewAccessTokenTransportForAuthIssuer(token, httpTransport, version, func(t *auth.Token) error { + profile.SetAccessToken(t.AccessToken) + profile.SetRefreshToken(t.RefreshToken) + return profile.Save() + }) + if err != nil { + return nil, err + } + return &http.Client{Transport: tr}, nil + } + + // No token available, fall through to unauthenticated client + fallthrough case config.NoAuth: fallthrough default: diff --git a/transport/transport.go b/transport/transport.go index 0b1034d..581f52f 100644 --- a/transport/transport.go +++ b/transport/transport.go @@ -90,6 +90,30 @@ func NewDigestTransport(username, password string, base http.RoundTripper) *dige } } +// NewAccessTokenTransportForAuthIssuer creates a token transport that refreshes against the +// dedicated OAuth Authorization Server (AuthServerURL) instead of the cloud.mongodb.com proxy. +// Used for UserDelegation sessions created by atlas auth connect. +func NewAccessTokenTransportForAuthIssuer(token *atlasauth.Token, base http.RoundTripper, version string, saveToken func(*atlasauth.Token) error) (http.RoundTripper, error) { + if token == nil { + return nil, errors.New("token is nil") + } + + client := http.DefaultClient + client.Transport = Default() + + flow, err := FlowForAuthIssuer(config.Default(), client, version) + if err != nil { + return nil, err + } + + return &tokenTransport{ + token: token, + base: base, + authConfig: flow, + saveToken: saveToken, + }, nil +} + func NewAccessTokenTransport(token *atlasauth.Token, base http.RoundTripper, version string, saveToken func(*atlasauth.Token) error) (http.RoundTripper, error) { if token == nil { return nil, errors.New("token is nil") From 6d2590054ff8804cfe03b0e54201e89fb3b99ac1 Mon Sep 17 00:00:00 2001 From: Justin Richer Date: Fri, 8 May 2026 20:47:40 -0400 Subject: [PATCH 04/14] Add auth server metadata cache field to profile Stores the discovered OAuth AS metadata as a nested object in the profile config, with server response and cache expiry kept as separate sub-keys to avoid collisions with server-defined fields. Assisted-by: Claude Code (claude-opus-4-6) --- config/profile.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/config/profile.go b/config/profile.go index 5a61d14..008e998 100644 --- a/config/profile.go +++ b/config/profile.go @@ -47,6 +47,7 @@ const ( ClientIDField = "client_id" ClientSecretField = "client_secret" OpsManagerURLField = "ops_manager_url" + authServerMetadataField = "auth_server_metadata" AccountURLField = "account_url" baseURL = "base_url" apiVersion = "api_version" @@ -488,6 +489,26 @@ func (p *Profile) AccountURL() string { return p.GetString(AccountURLField) } +// AuthServerMetadata gets the cached OAuth Authorization Server metadata. +// Returns nil if no metadata is cached. +func AuthServerMetadata() map[string]any { return Default().AuthServerMetadata() } +func (p *Profile) AuthServerMetadata() map[string]any { + value := p.Get(authServerMetadataField) + if value == nil { + return nil + } + if m, ok := value.(map[string]any); ok { + return m + } + return nil +} + +// SetAuthServerMetadata stores the OAuth Authorization Server metadata in the profile. +func SetAuthServerMetadata(v map[string]any) { Default().SetAuthServerMetadata(v) } +func (p *Profile) SetAuthServerMetadata(v map[string]any) { + p.Set(authServerMetadataField, v) +} + // ProjectID get configured project ID. func ProjectID() string { return Default().ProjectID() } func (p *Profile) ProjectID() string { From 6e038e4d51bea53f0d5b93e131b1b845f7a2b2b8 Mon Sep 17 00:00:00 2001 From: Justin Richer Date: Fri, 8 May 2026 21:31:36 -0400 Subject: [PATCH 05/14] Add auth_server_url profile property and client ID override for FlowForAuthIssuer Enables overriding the auth server URL and client ID via atlas config set or environment variables (MONGODB_ATLAS_AUTH_SERVER_URL, MONGODB_ATLAS_CLIENT_ID), following the same precedence as FlowWithConfig: profile override wins, gov default is the fallback. Assisted-by: Claude Code (claude-opus-4-6) --- config/profile.go | 14 ++++++++++++++ transport/oauth.go | 18 ++++++++++++------ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/config/profile.go b/config/profile.go index 008e998..35d5e95 100644 --- a/config/profile.go +++ b/config/profile.go @@ -47,6 +47,7 @@ const ( ClientIDField = "client_id" ClientSecretField = "client_secret" OpsManagerURLField = "ops_manager_url" + AuthServerURLField = "auth_server_url" authServerMetadataField = "auth_server_metadata" AccountURLField = "account_url" baseURL = "base_url" @@ -160,6 +161,7 @@ func ProfileProperties() []string { apiVersion, baseURL, OpsManagerURLField, + AuthServerURLField, orgID, output, privateAPIKey, @@ -483,6 +485,18 @@ func (p *Profile) SetOpsManagerURL(v string) { p.Set(OpsManagerURLField, v) } +// AuthServerURL gets the configured auth server URL override. +func AuthServerURL() string { return Default().AuthServerURL() } +func (p *Profile) AuthServerURL() string { + return p.GetString(AuthServerURLField) +} + +// SetAuthServerURL sets the configured auth server URL override. +func SetAuthServerURL(v string) { Default().SetAuthServerURL(v) } +func (p *Profile) SetAuthServerURL(v string) { + p.Set(AuthServerURLField, v) +} + // AccountURL gets the configured account base url. func AccountURL() string { return Default().AccountURL() } func (p *Profile) AccountURL() string { diff --git a/transport/oauth.go b/transport/oauth.go index 76927c1..dfc3287 100644 --- a/transport/oauth.go +++ b/transport/oauth.go @@ -45,11 +45,11 @@ type ServiceGetter interface { AccountURL() string } -// AuthIssuerGetter provides the minimal config needed for the dedicated OAuth AS flow. -// Unlike ServiceGetter, it does not expose OpsManagerURL or AccountURL because the -// auth issuer URL is compiled in, not derived from the API base URL. +// AuthIssuerGetter provides the config needed for the dedicated OAuth AS flow. type AuthIssuerGetter interface { Service() string + ClientID() string + AuthServerURL() string } func FlowWithConfig(c ServiceGetter, client *http.Client, version string) (*auth.Config, error) { @@ -83,7 +83,9 @@ func FlowWithConfig(c ServiceGetter, client *http.Client, version string) (*auth // field, populated by default in auth.NewConfig. func FlowForAuthIssuer(c AuthIssuerGetter, client *http.Client, version string) (*auth.Config, error) { id := authIssuerClientID - if c.Service() == config.CloudGovService { + if c.ClientID() != "" { + id = c.ClientID() + } else if c.Service() == config.CloudGovService { id = govAuthIssuerClientID } @@ -96,8 +98,12 @@ func FlowForAuthIssuer(c AuthIssuerGetter, client *http.Client, version string) return nil, err } - // For gov, override the auth server URL with the gov-specific endpoint. - if c.Service() == config.CloudGovService { + if configURL := c.AuthServerURL(); configURL != "" { + cfg.AuthServerURL, err = url.Parse(configURL) + if err != nil { + return nil, err + } + } else if c.Service() == config.CloudGovService { cfg.AuthServerURL, err = url.Parse(govDefaultAuthIssuerURL) if err != nil { return nil, err From cd85f9a782116ba42dc4e024fb0b0db857d8ce8b Mon Sep 17 00:00:00 2001 From: Justin Richer Date: Fri, 8 May 2026 22:06:52 -0400 Subject: [PATCH 06/14] Store token expiry from expires_in instead of parsing access token JWT Adds token_expiry profile field and stops parsing the access token as a JWT. The expiry is stored at token acquisition time and loaded back through tokenClaims for compatibility with the existing Token() flow. Assisted-by: Claude Code (claude-opus-4-6) --- config/profile.go | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/config/profile.go b/config/profile.go index 35d5e95..8728d6c 100644 --- a/config/profile.go +++ b/config/profile.go @@ -44,6 +44,7 @@ const ( privateAPIKey = "private_api_key" AccessTokenField = "access_token" RefreshTokenField = "refresh_token" + TokenExpiryField = "token_expiry" ClientIDField = "client_id" ClientSecretField = "client_secret" OpsManagerURLField = "ops_manager_url" @@ -454,11 +455,35 @@ func (p *Profile) AccessTokenSubject() (string, error) { return c.Subject, err } +// TokenExpiry gets the stored token expiry time. +func TokenExpiry() string { return Default().TokenExpiry() } +func (p *Profile) TokenExpiry() string { + return p.GetString(TokenExpiryField) +} + +// SetTokenExpiry stores the token expiry time. +func SetTokenExpiry(v string) { Default().SetTokenExpiry(v) } +func (p *Profile) SetTokenExpiry(v string) { + p.Set(TokenExpiryField, v) +} + func (p *Profile) tokenClaims() (jwt.RegisteredClaims, error) { c := jwt.RegisteredClaims{} - // ParseUnverified is ok here, only want to make sure is a JWT and to get the claims for a Subject - _, _, err := new(jwt.Parser).ParseUnverified(p.AccessToken(), &c) - return c, err + // TODO: The OAuth client MUST NOT depend on the access token being a JWT. + // Token expiry should come from expires_in, and user identity from a + // userinfo endpoint or ID token — not from access token claims. + // + // We are stuffing a stored expiry timestamp back into a JWT RegisteredClaims + // struct solely because Token() reads ExpiresAt from it. This is backwards — + // the claims struct exists for JWT parsing, not for carrying stored profile + // values. The right fix is for Token() to read the expiry directly, but that + // is a larger refactor of the existing auth flow. + if expiry := p.TokenExpiry(); expiry != "" { + if t, err := time.Parse(time.RFC3339, expiry); err == nil { + c.ExpiresAt = jwt.NewNumericDate(t) + } + } + return c, nil } // APIVersion get the default API version. From 4bdafef77614bf2af4ec0fd1779de08114e7f053 Mon Sep 17 00:00:00 2001 From: Justin Richer Date: Mon, 11 May 2026 10:51:34 -0400 Subject: [PATCH 07/14] Branch tokenClaims on auth type to restore legacy JWT parsing UserDelegation reads expiry from the stored token_expiry field without parsing the access token. All other auth types retain the existing JWT parsing behavior so that AccessTokenSubject and Token expiry continue to work for legacy login sessions. Assisted-by: Claude Code (claude-opus-4-6) --- config/profile.go | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/config/profile.go b/config/profile.go index 8728d6c..5d41725 100644 --- a/config/profile.go +++ b/config/profile.go @@ -469,21 +469,23 @@ func (p *Profile) SetTokenExpiry(v string) { func (p *Profile) tokenClaims() (jwt.RegisteredClaims, error) { c := jwt.RegisteredClaims{} - // TODO: The OAuth client MUST NOT depend on the access token being a JWT. - // Token expiry should come from expires_in, and user identity from a - // userinfo endpoint or ID token — not from access token claims. - // - // We are stuffing a stored expiry timestamp back into a JWT RegisteredClaims - // struct solely because Token() reads ExpiresAt from it. This is backwards — - // the claims struct exists for JWT parsing, not for carrying stored profile - // values. The right fix is for Token() to read the expiry directly, but that - // is a larger refactor of the existing auth flow. - if expiry := p.TokenExpiry(); expiry != "" { - if t, err := time.Parse(time.RFC3339, expiry); err == nil { - c.ExpiresAt = jwt.NewNumericDate(t) + + if p.AuthType() == UserDelegation { + // UserDelegation reads expiry from the stored token_expiry field, + // populated from expires_in at token acquisition time. The access + // token is not parsed. Subject is not available in this path. + if expiry := p.TokenExpiry(); expiry != "" { + if t, err := time.Parse(time.RFC3339, expiry); err == nil { + c.ExpiresAt = jwt.NewNumericDate(t) + } } + return c, nil } - return c, nil + + // All other auth types parse the access token as a JWT to extract claims. + // TODO: migrate these paths to stop depending on the access token format. + _, _, err := new(jwt.Parser).ParseUnverified(p.AccessToken(), &c) + return c, err } // APIVersion get the default API version. From 618241685c6fe50e94e4acc92a6e12a3c47ded41 Mon Sep 17 00:00:00 2001 From: Justin Richer Date: Mon, 11 May 2026 16:06:33 -0400 Subject: [PATCH 08/14] Add self-contained authServerTransport for UserDelegation token refresh Introduces authServerTransport as a separate transport type that manages its own token lifecycle using discovered OAuth AS metadata. Reads the token endpoint from cached metadata and refreshes via RefreshAccessToken. Follows the same self-contained pattern as NewServiceAccountClientWithHost. ProfileProvider extended with AuthServerMetadata and SetTokenExpiry so the transport can read metadata and persist refreshed token expiry. Assisted-by: Claude Code (claude-opus-4-6) --- transport/client.go | 8 ++++++- transport/transport.go | 53 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 54 insertions(+), 7 deletions(-) diff --git a/transport/client.go b/transport/client.go index 413be8b..e03a77e 100644 --- a/transport/client.go +++ b/transport/client.go @@ -16,6 +16,7 @@ package transport import ( "net/http" + "time" "github.com/mongodb/atlas-cli-core/config" "go.mongodb.org/atlas/auth" @@ -31,10 +32,12 @@ type ProfileProvider interface { Token() (*auth.Token, error) SetAccessToken(string) SetRefreshToken(string) + SetTokenExpiry(string) Save() error ClientID() string ClientSecret() string OpsManagerURL() string + AuthServerMetadata() map[string]any } func HTTPClient(version string, httpTransport http.RoundTripper) (*http.Client, error) { @@ -76,9 +79,12 @@ func HTTPClientFromProfile(profile ProfileProvider, version string, httpTranspor } if token != nil { - tr, err := NewAccessTokenTransportForAuthIssuer(token, httpTransport, version, func(t *auth.Token) error { + tr, err := NewAccessTokenTransportForAuthIssuer(token, httpTransport, version, profile.AuthServerMetadata(), func(t *auth.Token) error { profile.SetAccessToken(t.AccessToken) profile.SetRefreshToken(t.RefreshToken) + if !t.Expiry.IsZero() { + profile.SetTokenExpiry(t.Expiry.Format(time.RFC3339)) + } return profile.Save() }) if err != nil { diff --git a/transport/transport.go b/transport/transport.go index 581f52f..4cf926b 100644 --- a/transport/transport.go +++ b/transport/transport.go @@ -90,10 +90,11 @@ func NewDigestTransport(username, password string, base http.RoundTripper) *dige } } -// NewAccessTokenTransportForAuthIssuer creates a token transport that refreshes against the -// dedicated OAuth Authorization Server (AuthServerURL) instead of the cloud.mongodb.com proxy. -// Used for UserDelegation sessions created by atlas auth connect. -func NewAccessTokenTransportForAuthIssuer(token *atlasauth.Token, base http.RoundTripper, version string, saveToken func(*atlasauth.Token) error) (http.RoundTripper, error) { +// NewAccessTokenTransportForAuthIssuer creates a self-contained transport for +// UserDelegation sessions. It manages its own token lifecycle using the +// discovered OAuth Authorization Server metadata, following the same pattern +// as NewServiceAccountClientWithHost. +func NewAccessTokenTransportForAuthIssuer(token *atlasauth.Token, base http.RoundTripper, version string, metadata map[string]any, saveToken func(*atlasauth.Token) error) (http.RoundTripper, error) { if token == nil { return nil, errors.New("token is nil") } @@ -106,14 +107,54 @@ func NewAccessTokenTransportForAuthIssuer(token *atlasauth.Token, base http.Roun return nil, err } - return &tokenTransport{ + return &authServerTransport{ token: token, - base: base, authConfig: flow, + metadata: metadata, + base: base, saveToken: saveToken, }, nil } +type authServerTransport struct { + token *atlasauth.Token + authConfig *atlasauth.Config + metadata map[string]any + base http.RoundTripper + saveToken func(*atlasauth.Token) error +} + +func (tr *authServerTransport) tokenEndpoint() string { + if m, ok := tr.metadata["metadata"].(map[string]any); ok { + if ep, ok := m["token_endpoint"].(string); ok { + return ep + } + } + return "" +} + +func (tr *authServerTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if !tr.token.Valid() { + tokenEndpoint := tr.tokenEndpoint() + if tokenEndpoint == "" { + return nil, errors.New("token_endpoint not found in auth server metadata") + } + + token, err := tr.authConfig.RefreshAccessToken(req.Context(), tokenEndpoint, tr.token.RefreshToken) + if err != nil { + return nil, err + } + tr.token = token + if err := tr.saveToken(tr.token); err != nil { + return nil, err + } + } + + tr.token.SetAuthHeader(req) + + return tr.base.RoundTrip(req) +} + func NewAccessTokenTransport(token *atlasauth.Token, base http.RoundTripper, version string, saveToken func(*atlasauth.Token) error) (http.RoundTripper, error) { if token == nil { return nil, errors.New("token is nil") From c694da3a4d366cedd0ae51e26abb2d35583b29b5 Mon Sep 17 00:00:00 2001 From: Justin Richer Date: Wed, 13 May 2026 10:50:54 -0400 Subject: [PATCH 09/14] Restore ParseUnverified rationale comment in tokenClaims The note explaining that an unverified JWT parse is intentional on the legacy path got dropped when the UserDelegation branch was added. Pulled it back verbatim and grouped it with the existing "all other auth types" lead-in and the migration TODO. Assisted-by: Claude Code (claude-opus-4-6) --- config/profile.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/profile.go b/config/profile.go index 5d41725..5e1b8d5 100644 --- a/config/profile.go +++ b/config/profile.go @@ -484,6 +484,8 @@ func (p *Profile) tokenClaims() (jwt.RegisteredClaims, error) { // All other auth types parse the access token as a JWT to extract claims. // TODO: migrate these paths to stop depending on the access token format. + + // ParseUnverified is ok here, only want to make sure is a JWT and to get the claims for a Subject. _, _, err := new(jwt.Parser).ParseUnverified(p.AccessToken(), &c) return c, err } From 013e506dc9753ff1e71b18aca7037a7b679bc19b Mon Sep 17 00:00:00 2001 From: Justin Richer Date: Wed, 13 May 2026 10:50:54 -0400 Subject: [PATCH 10/14] Regenerate mock after ProfileProvider grew UserDelegation methods Earlier commits on this branch added SetTokenExpiry and AuthServerMetadata to ProfileProvider without regenerating the mock, breaking go vet on the transport package. Assisted-by: Claude Code (claude-opus-4-6) --- transport/mock_profile_provider.go | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/transport/mock_profile_provider.go b/transport/mock_profile_provider.go index 06f93b5..a861687 100644 --- a/transport/mock_profile_provider.go +++ b/transport/mock_profile_provider.go @@ -41,6 +41,20 @@ func (m *MockProfileProvider) EXPECT() *MockProfileProviderMockRecorder { return m.recorder } +// AuthServerMetadata mocks base method. +func (m *MockProfileProvider) AuthServerMetadata() map[string]any { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AuthServerMetadata") + ret0, _ := ret[0].(map[string]any) + return ret0 +} + +// AuthServerMetadata indicates an expected call of AuthServerMetadata. +func (mr *MockProfileProviderMockRecorder) AuthServerMetadata() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthServerMetadata", reflect.TypeOf((*MockProfileProvider)(nil).AuthServerMetadata)) +} + // AuthType mocks base method. func (m *MockProfileProvider) AuthType() config.AuthMechanism { m.ctrl.T.Helper() @@ -163,6 +177,18 @@ func (mr *MockProfileProviderMockRecorder) SetRefreshToken(arg0 any) *gomock.Cal return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetRefreshToken", reflect.TypeOf((*MockProfileProvider)(nil).SetRefreshToken), arg0) } +// SetTokenExpiry mocks base method. +func (m *MockProfileProvider) SetTokenExpiry(arg0 string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SetTokenExpiry", arg0) +} + +// SetTokenExpiry indicates an expected call of SetTokenExpiry. +func (mr *MockProfileProviderMockRecorder) SetTokenExpiry(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetTokenExpiry", reflect.TypeOf((*MockProfileProvider)(nil).SetTokenExpiry), arg0) +} + // Token mocks base method. func (m *MockProfileProvider) Token() (*auth.Token, error) { m.ctrl.T.Helper() From 92f7e64b6d112185ebf9674e38bbc0afd7c85a49 Mon Sep 17 00:00:00 2001 From: Justin Richer Date: Wed, 13 May 2026 21:03:22 -0400 Subject: [PATCH 11/14] Add unit tests for UserDelegation transport and profile fields Covers FlowForAuthIssuer client ID and AuthServerURL selection including the gov defaults and overrides, authServerTransport.RoundTrip on valid and expired tokens, the missing-token-endpoint error path, the UserDelegation case in HTTPClientFromProfile, and the new TokenExpiry, AuthServerURL, AuthServerMetadata profile fields plus the UserDelegation branch of tokenClaims. Assisted-by: Claude Code (claude-opus-4-7) --- config/profile_test.go | 75 ++++++++++++++++ transport/auth_server_transport_test.go | 115 ++++++++++++++++++++++++ transport/client_test.go | 32 +++++++ transport/oauth_test.go | 89 ++++++++++++++++++ 4 files changed, 311 insertions(+) create mode 100644 transport/auth_server_transport_test.go create mode 100644 transport/oauth_test.go diff --git a/config/profile_test.go b/config/profile_test.go index 02e44fb..cda83a5 100644 --- a/config/profile_test.go +++ b/config/profile_test.go @@ -439,3 +439,78 @@ func TestWithProfile_Multiple_Profiles(t *testing.T) { require.True(t, ok) assert.Equal(t, "profile1", stillRetrieved1.name) } + +func TestProfile_TokenExpiry(t *testing.T) { + ctrl := gomock.NewController(t) + mockStore := mocks.NewMockStore(ctrl) + p := &Profile{name: DefaultProfile, configStore: mockStore} + + mockStore.EXPECT().SetProfileValue(DefaultProfile, TokenExpiryField, "2026-05-13T12:00:00Z").Times(1) + mockStore.EXPECT().GetHierarchicalValue(DefaultProfile, TokenExpiryField).Return("2026-05-13T12:00:00Z").Times(1) + + p.SetTokenExpiry("2026-05-13T12:00:00Z") + assert.Equal(t, "2026-05-13T12:00:00Z", p.TokenExpiry()) +} + +func TestProfile_AuthServerURL(t *testing.T) { + ctrl := gomock.NewController(t) + mockStore := mocks.NewMockStore(ctrl) + p := &Profile{name: DefaultProfile, configStore: mockStore} + + mockStore.EXPECT().SetProfileValue(DefaultProfile, AuthServerURLField, "https://authorize.example.com").Times(1) + mockStore.EXPECT().GetHierarchicalValue(DefaultProfile, AuthServerURLField).Return("https://authorize.example.com").Times(1) + + p.SetAuthServerURL("https://authorize.example.com") + assert.Equal(t, "https://authorize.example.com", p.AuthServerURL()) + + // AuthServerURL is user-configurable, so it must appear in ProfileProperties() + assert.Contains(t, ProfileProperties(), AuthServerURLField) +} + +func TestProfile_AuthServerMetadata(t *testing.T) { + ctrl := gomock.NewController(t) + mockStore := mocks.NewMockStore(ctrl) + p := &Profile{name: DefaultProfile, configStore: mockStore} + + metadata := map[string]any{ + "metadata": map[string]any{"issuer": "https://authorize.example.com"}, + "expiry": "2026-05-20T00:00:00Z", + } + + mockStore.EXPECT().SetProfileValue(DefaultProfile, authServerMetadataField, metadata).Times(1) + p.SetAuthServerMetadata(metadata) + + t.Run("round-trip", func(t *testing.T) { + mockStore.EXPECT().GetHierarchicalValue(DefaultProfile, authServerMetadataField).Return(metadata).Times(1) + assert.Equal(t, metadata, p.AuthServerMetadata()) + }) + + t.Run("nil when unset", func(t *testing.T) { + mockStore.EXPECT().GetHierarchicalValue(DefaultProfile, authServerMetadataField).Return(nil).Times(1) + assert.Nil(t, p.AuthServerMetadata()) + }) + + t.Run("nil when underlying value is wrong type", func(t *testing.T) { + mockStore.EXPECT().GetHierarchicalValue(DefaultProfile, authServerMetadataField).Return("not-a-map").Times(1) + assert.Nil(t, p.AuthServerMetadata()) + }) +} + +func TestProfile_TokenClaims_UserDelegation(t *testing.T) { + p := &Profile{name: DefaultProfile, configStore: NewInMemoryStore()} + // AuthType()'s env-var sniffing reads from Default(), so route those calls + // through the same profile for the duration of the test. + prev := Default() + SetDefaultProfile(p) + t.Cleanup(func() { SetDefaultProfile(prev) }) + + p.SetAuthType(UserDelegation) + p.SetTokenExpiry("2026-05-13T12:00:00Z") + + claims, err := p.tokenClaims() + require.NoError(t, err) + require.NotNil(t, claims.ExpiresAt) + assert.Equal(t, "2026-05-13T12:00:00Z", claims.ExpiresAt.Format("2006-01-02T15:04:05Z")) + // Subject is not populated for UserDelegation — the access token is not parsed + assert.Empty(t, claims.Subject) +} diff --git a/transport/auth_server_transport_test.go b/transport/auth_server_transport_test.go new file mode 100644 index 0000000..132b167 --- /dev/null +++ b/transport/auth_server_transport_test.go @@ -0,0 +1,115 @@ +// Copyright 2026 MongoDB Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package transport + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + atlasauth "go.mongodb.org/atlas/auth" +) + +// recordingTransport captures the last request's Authorization header. +type recordingTransport struct { + lastAuthHeader string +} + +func (rt *recordingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + rt.lastAuthHeader = req.Header.Get("Authorization") + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil +} + +func TestAuthServerTransport_RoundTrip(t *testing.T) { + token := &atlasauth.Token{ + AccessToken: "valid-at", + TokenType: "Bearer", + Expiry: time.Now().Add(time.Hour), + } + base := &recordingTransport{} + tr := &authServerTransport{ + token: token, + base: base, + saveToken: func(*atlasauth.Token) error { return nil }, + } + + req, err := http.NewRequest(http.MethodGet, "https://api.example.com/x", nil) + require.NoError(t, err) + resp, err := tr.RoundTrip(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "Bearer valid-at", base.lastAuthHeader) +} + +func TestAuthServerTransport_RoundTrip_Refresh(t *testing.T) { + tokenEndpoint := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, `{"access_token":"refreshed-at","refresh_token":"new-rt","token_type":"Bearer","expires_in":3600}`) + })) + defer tokenEndpoint.Close() + + expired := &atlasauth.Token{ + AccessToken: "expired-at", + RefreshToken: "old-rt", + TokenType: "Bearer", + Expiry: time.Now().Add(-time.Minute), + } + + authCfg, err := FlowForAuthIssuer(&fakeAuthIssuerGetter{service: "cloud"}, http.DefaultClient, testVersion) + require.NoError(t, err) + + var saved *atlasauth.Token + base := &recordingTransport{} + tr := &authServerTransport{ + token: expired, + authConfig: authCfg, + metadata: map[string]any{"metadata": map[string]any{"token_endpoint": tokenEndpoint.URL}}, + base: base, + saveToken: func(t *atlasauth.Token) error { + saved = t + return nil + }, + } + + req, err := http.NewRequest(http.MethodGet, "https://api.example.com/x", nil) + require.NoError(t, err) + _, err = tr.RoundTrip(req) + require.NoError(t, err) + + require.NotNil(t, saved) + assert.Equal(t, "refreshed-at", saved.AccessToken) + assert.Equal(t, "Bearer refreshed-at", base.lastAuthHeader) +} + +func TestAuthServerTransport_RoundTrip_MissingTokenEndpoint(t *testing.T) { + expired := &atlasauth.Token{ + AccessToken: "expired-at", + Expiry: time.Now().Add(-time.Minute), + } + tr := &authServerTransport{ + token: expired, + metadata: map[string]any{"metadata": map[string]any{}}, + base: &recordingTransport{}, + saveToken: func(*atlasauth.Token) error { return nil }, + } + + req, _ := http.NewRequest(http.MethodGet, "https://api.example.com/x", nil) + _, err := tr.RoundTrip(req) + require.Error(t, err) + assert.Contains(t, err.Error(), "token_endpoint not found") +} diff --git a/transport/client_test.go b/transport/client_test.go index 7237320..327fcd7 100644 --- a/transport/client_test.go +++ b/transport/client_test.go @@ -145,6 +145,38 @@ func TestHTTPClientFromProfile(t *testing.T) { require.NotNil(t, client.Transport) }, }, + { + name: "User Delegation with valid token", + setupMock: func(m *MockProfileProvider) { + token := &auth.Token{ + AccessToken: "access-token", + RefreshToken: "refresh-token", + TokenType: "Bearer", + Expiry: time.Now().Add(time.Hour), + } + m.EXPECT().AuthType().Return(config.UserDelegation) + m.EXPECT().Token().Return(token, nil) + m.EXPECT().AuthServerMetadata().Return(map[string]any{}) + }, + validateFunc: func(t *testing.T, client *http.Client) { + t.Helper() + require.NotNil(t, client) + _, ok := client.Transport.(*authServerTransport) + require.True(t, ok, "Expected authServerTransport for UserDelegation auth") + }, + }, + { + name: "User Delegation with nil token falls through to unauthenticated client", + setupMock: func(m *MockProfileProvider) { + m.EXPECT().AuthType().Return(config.UserDelegation) + m.EXPECT().Token().Return(nil, nil) + }, + validateFunc: func(t *testing.T, client *http.Client) { + t.Helper() + require.NotNil(t, client) + assert.Equal(t, httpTransport, client.Transport) + }, + }, { name: "NoAuth authentication", setupMock: func(m *MockProfileProvider) { diff --git a/transport/oauth_test.go b/transport/oauth_test.go new file mode 100644 index 0000000..c7a9c59 --- /dev/null +++ b/transport/oauth_test.go @@ -0,0 +1,89 @@ +// Copyright 2026 MongoDB Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package transport + +import ( + "net/http" + "testing" + + "github.com/mongodb/atlas-cli-core/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeAuthIssuerGetter struct { + service string + clientID string + authServerURL string +} + +func (f *fakeAuthIssuerGetter) Service() string { return f.service } +func (f *fakeAuthIssuerGetter) ClientID() string { return f.clientID } +func (f *fakeAuthIssuerGetter) AuthServerURL() string { return f.authServerURL } + +func TestFlowForAuthIssuer(t *testing.T) { + tests := []struct { + name string + getter *fakeAuthIssuerGetter + expectedClientID string + expectedAuthURL string + expectedErrSubstr string + }{ + { + name: "commercial defaults", + getter: &fakeAuthIssuerGetter{service: config.CloudService}, + expectedClientID: authIssuerClientID, + expectedAuthURL: "https://authorize.mongodb.com", + }, + { + name: "gov defaults", + getter: &fakeAuthIssuerGetter{service: config.CloudGovService}, + expectedClientID: govAuthIssuerClientID, + expectedAuthURL: govDefaultAuthIssuerURL, + }, + { + name: "client ID override", + getter: &fakeAuthIssuerGetter{service: config.CloudService, clientID: "override-id"}, + expectedClientID: "override-id", + expectedAuthURL: "https://authorize.mongodb.com", + }, + { + name: "auth server URL override beats gov default", + getter: &fakeAuthIssuerGetter{service: config.CloudGovService, authServerURL: "https://override.example.com"}, + expectedClientID: govAuthIssuerClientID, + expectedAuthURL: "https://override.example.com", + }, + { + name: "invalid auth server URL", + getter: &fakeAuthIssuerGetter{service: config.CloudService, authServerURL: "://bad"}, + expectedErrSubstr: "missing protocol scheme", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg, err := FlowForAuthIssuer(tt.getter, http.DefaultClient, testVersion) + if tt.expectedErrSubstr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedErrSubstr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.expectedClientID, cfg.ClientID) + assert.Equal(t, tt.expectedAuthURL, cfg.AuthServerURL.String()) + assert.Equal(t, []string{"openid", "profile", "offline_access"}, cfg.Scopes) + }) + } +} From 87a8c4e22aba4dc7e64694d47209e82ba3b4fa0c Mon Sep 17 00:00:00 2001 From: Justin Richer Date: Tue, 19 May 2026 11:14:43 +0200 Subject: [PATCH 12/14] Use atlas scope for FlowForAuthIssuer The dedicated AS path requires the atlas scope. The previous setting of [openid, profile, offline_access] was copied from the legacy FlowWithConfig device-flow path, which talks to a different AS (auth.mongodb.com via the cloud.mongodb.com proxy) and does use OIDC scopes. Assisted-by: Claude Code (claude-opus-4-7) --- transport/oauth.go | 2 +- transport/oauth_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/transport/oauth.go b/transport/oauth.go index dfc3287..2a8cddd 100644 --- a/transport/oauth.go +++ b/transport/oauth.go @@ -92,7 +92,7 @@ func FlowForAuthIssuer(c AuthIssuerGetter, client *http.Client, version string) cfg, err := auth.NewConfigWithOptions(client, auth.SetUserAgent(config.UserAgent(version)), auth.SetClientID(id), - auth.SetScopes([]string{"openid", "profile", "offline_access"}), + auth.SetScopes([]string{"atlas"}), ) if err != nil { return nil, err diff --git a/transport/oauth_test.go b/transport/oauth_test.go index c7a9c59..5deec17 100644 --- a/transport/oauth_test.go +++ b/transport/oauth_test.go @@ -83,7 +83,7 @@ func TestFlowForAuthIssuer(t *testing.T) { require.NoError(t, err) assert.Equal(t, tt.expectedClientID, cfg.ClientID) assert.Equal(t, tt.expectedAuthURL, cfg.AuthServerURL.String()) - assert.Equal(t, []string{"openid", "profile", "offline_access"}, cfg.Scopes) + assert.Equal(t, []string{"atlas"}, cfg.Scopes) }) } } From c38eda980f99ea16999a0f2d758ddcdf3f53050d Mon Sep 17 00:00:00 2001 From: Justin Richer Date: Thu, 2 Jul 2026 14:19:57 -0400 Subject: [PATCH 13/14] Remove local checkout replace directives Resolve go-client and atlas-sdk from their released module versions rather than local sibling checkouts. Assisted-by: Claude Code (claude-opus-4-8) --- go.mod | 5 ----- go.sum | 4 ++++ 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 75a2fef..ddd5486 100644 --- a/go.mod +++ b/go.mod @@ -40,9 +40,4 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect ) -replace ( - go.mongodb.org/atlas => ../go-client-mongodb-atlas - go.mongodb.org/atlas-sdk/v20250312018 => ../atlas-sdk-go -) - tool go.uber.org/mock/mockgen diff --git a/go.sum b/go.sum index 2dea94b..566d04a 100644 --- a/go.sum +++ b/go.sum @@ -57,6 +57,10 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= +go.mongodb.org/atlas v0.38.0 h1:zfwymq20GqivGwxPZfypfUDry+WwMGVui97z1d8V4bU= +go.mongodb.org/atlas v0.38.0/go.mod h1:DJYtM+vsEpPEMSkQzJnFHrT0sP7ev6cseZc/GGjJYG8= +go.mongodb.org/atlas-sdk/v20250312018 v20250312018.1.0 h1:tHQf++4+qGqXrKrKsPhnHLIOdMW4KYku2/opNSuQa0s= +go.mongodb.org/atlas-sdk/v20250312018 v20250312018.1.0/go.mod h1:Jaz/KTPpC4od8zWi/84FbvsaFasm2+1Oy0yn+BY/2Gs= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= From 6656f0a4d9908d4333e37573441b57858c442ddc Mon Sep 17 00:00:00 2001 From: Justin Richer Date: Thu, 2 Jul 2026 14:56:51 -0400 Subject: [PATCH 14/14] Bump atlas-sdk to v20250312021 Match the atlas-sdk version used by the CLI so both modules resolve the same release. Assisted-by: Claude Code (claude-opus-4-8) --- go.mod | 2 +- go.sum | 4 ++-- transport/transport.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index ddd5486..c3234d5 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/stretchr/testify v1.11.1 github.com/zalando/go-keyring v0.2.6 go.mongodb.org/atlas v0.38.0 - go.mongodb.org/atlas-sdk/v20250312018 v20250312018.1.0 + go.mongodb.org/atlas-sdk/v20250312021 v20250312021.0.0 go.uber.org/mock v0.6.0 ) diff --git a/go.sum b/go.sum index 566d04a..6a889bd 100644 --- a/go.sum +++ b/go.sum @@ -59,8 +59,8 @@ github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8u github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= go.mongodb.org/atlas v0.38.0 h1:zfwymq20GqivGwxPZfypfUDry+WwMGVui97z1d8V4bU= go.mongodb.org/atlas v0.38.0/go.mod h1:DJYtM+vsEpPEMSkQzJnFHrT0sP7ev6cseZc/GGjJYG8= -go.mongodb.org/atlas-sdk/v20250312018 v20250312018.1.0 h1:tHQf++4+qGqXrKrKsPhnHLIOdMW4KYku2/opNSuQa0s= -go.mongodb.org/atlas-sdk/v20250312018 v20250312018.1.0/go.mod h1:Jaz/KTPpC4od8zWi/84FbvsaFasm2+1Oy0yn+BY/2Gs= +go.mongodb.org/atlas-sdk/v20250312021 v20250312021.0.0 h1:bZYcZVnXNr7QO6uIRawTMxmEk2otUY4U3JC57DfKHgw= +go.mongodb.org/atlas-sdk/v20250312021 v20250312021.0.0/go.mod h1:zp4x/Ub4/nYjlMRSSVd+IRr5AgZDE+tmBtRcTpNEPs4= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= diff --git a/transport/transport.go b/transport/transport.go index 4cf926b..1dfe7c0 100644 --- a/transport/transport.go +++ b/transport/transport.go @@ -24,7 +24,7 @@ import ( "github.com/mongodb-forks/digest" "github.com/mongodb/atlas-cli-core/config" - "go.mongodb.org/atlas-sdk/v20250312018/auth/clientcredentials" + "go.mongodb.org/atlas-sdk/v20250312021/auth/clientcredentials" atlasauth "go.mongodb.org/atlas/auth" )