Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 66 additions & 1 deletion config/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -159,6 +162,7 @@ func ProfileProperties() []string {
apiVersion,
baseURL,
OpsManagerURLField,
AuthServerURLField,
orgID,
output,
privateAPIKey,
Expand Down Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down
75 changes: 75 additions & 0 deletions config/profile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand All @@ -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
Expand Down
12 changes: 6 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand Down
115 changes: 115 additions & 0 deletions transport/auth_server_transport_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading
Loading