diff --git a/config/profile.go b/config/profile.go index 11dd750..5e1b8d5 100644 --- a/config/profile.go +++ b/config/profile.go @@ -44,9 +44,12 @@ const ( privateAPIKey = "private_api_key" AccessTokenField = "access_token" RefreshTokenField = "refresh_token" + TokenExpiryField = "token_expiry" ClientIDField = "client_id" ClientSecretField = "client_secret" OpsManagerURLField = "ops_manager_url" + AuthServerURLField = "auth_server_url" + authServerMetadataField = "auth_server_metadata" AccountURLField = "account_url" baseURL = "base_url" apiVersion = "api_version" @@ -159,6 +162,7 @@ func ProfileProperties() []string { apiVersion, baseURL, OpsManagerURLField, + AuthServerURLField, orgID, output, privateAPIKey, @@ -313,6 +317,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" ) @@ -450,9 +455,37 @@ 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 + + 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 + } + + // 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 } @@ -481,12 +514,44 @@ 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 { 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 { 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/go.mod b/go.mod index c2b32d0..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/v20250312006 v20250312006.0.0 + go.mongodb.org/atlas-sdk/v20250312021 v20250312021.0.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 diff --git a/go.sum b/go.sum index 2089a52..6a889bd 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,16 @@ 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.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= 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/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.go b/transport/client.go index 6b386d5..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) { @@ -69,6 +72,29 @@ 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, 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 { + 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/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/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() diff --git a/transport/oauth.go b/transport/oauth.go index e1c01c7..2a8cddd 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 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) { id := ClientID if c.Service() == config.CloudGovService { @@ -60,3 +76,39 @@ 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.ClientID() != "" { + id = c.ClientID() + } else if c.Service() == config.CloudGovService { + id = govAuthIssuerClientID + } + + cfg, err := auth.NewConfigWithOptions(client, + auth.SetUserAgent(config.UserAgent(version)), + auth.SetClientID(id), + auth.SetScopes([]string{"atlas"}), + ) + if err != nil { + return nil, err + } + + 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 + } + } + + return cfg, nil +} diff --git a/transport/oauth_test.go b/transport/oauth_test.go new file mode 100644 index 0000000..5deec17 --- /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{"atlas"}, cfg.Scopes) + }) + } +} diff --git a/transport/transport.go b/transport/transport.go index 9c29b5c..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/v20250312006/auth/clientcredentials" + "go.mongodb.org/atlas-sdk/v20250312021/auth/clientcredentials" atlasauth "go.mongodb.org/atlas/auth" ) @@ -90,6 +90,71 @@ func NewDigestTransport(username, password string, base http.RoundTripper) *dige } } +// 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") + } + + client := http.DefaultClient + client.Transport = Default() + + flow, err := FlowForAuthIssuer(config.Default(), client, version) + if err != nil { + return nil, err + } + + return &authServerTransport{ + token: token, + 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")