Skip to content
Open
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
8 changes: 7 additions & 1 deletion sdk/go/openshell/v1/oidc/credentials_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,13 @@ func (a *clientCredentialsAuth) accessTokenForExchange() (string, error) {
return accessToken, nil
}

exchangeCtx, cancel := context.WithTimeout(context.Background(), a.cfg.timeout)
// A zero timeout means "no deadline"; only bound the exchange when a
// positive timeout was configured (mirrors Login and DeviceFlow).
exchangeCtx := context.Background()
cancel := context.CancelFunc(func() {})
if a.cfg.timeout > 0 {
exchangeCtx, cancel = context.WithTimeout(exchangeCtx, a.cfg.timeout)
}
defer cancel()
token, err := exchangeClientCredentials(exchangeCtx, a.cfg, true)
if err != nil {
Expand Down
78 changes: 78 additions & 0 deletions sdk/go/openshell/v1/oidc/credentials_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,84 @@ func TestClientCredentialsAuthLateFlightReusesCachedToken(t *testing.T) {
assert.Equal(t, "cached-token", accessToken)
}

// A non-positive timeout means "no deadline". The exchange context must not be
// born expired, so the token exchange should still succeed.
func TestClientCredentialsAuthNonPositiveTimeoutHasNoDeadline(t *testing.T) {
for _, timeout := range []time.Duration{0, -1 * time.Second} {
t.Run(timeout.String(), func(t *testing.T) {
resetDiscoveryCache()
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/.well-known/openid-configuration" {
_ = json.NewEncoder(w).Encode(map[string]string{
"issuer": server.URL,
"authorization_endpoint": server.URL + "/authorize",
"token_endpoint": server.URL + "/token",
})
return
}
_, _ = w.Write([]byte(tokenResponseJSON("token", "", 3600)))
}))
t.Cleanup(server.Close)

auth, err := NewClientCredentialsAuth(
WithIssuer(server.URL),
WithClientID("client"),
WithClientSecret("secret"),
WithTimeout(timeout), // explicitly no timeout
)
require.NoError(t, err)

// The explicit timeout must be preserved (not replaced by the default).
require.Equal(t, timeout, auth.(*clientCredentialsAuth).cfg.timeout)

metadata, err := auth.GetRequestMetadata(context.Background())
require.NoError(t, err)
assert.Equal(t, "Bearer token", metadata["authorization"])
})
}
}

// The client credentials grant has no user and no ID token, so "openid" must
// never be forced onto it the way the interactive flows force it.
func TestClientCredentialsScopesAreNotNormalized(t *testing.T) {
tests := []struct {
name string
opts []LoginOption
want []string
}{
{
name: "unset sends no scopes",
opts: nil,
want: nil,
},
{
name: "explicit empty sends no scopes",
opts: []LoginOption{WithScopes()},
want: []string{},
},
{
name: "application scopes are sent verbatim",
opts: []LoginOption{WithScopes("sandbox:read", "sandbox:write")},
want: []string{"sandbox:read", "sandbox:write"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
opts := append([]LoginOption{
WithIssuer("https://issuer.example.com"),
WithClientID("client"),
WithClientSecret("secret"),
}, tt.opts...)

cfg, err := resolveClientCredentialsConfig(opts...)
require.NoError(t, err)
assert.Equal(t, tt.want, cfg.scopes)
})
}
}

func TestClientCredentialsAuthCancellationDoesNotPoisonSharedExchange(t *testing.T) {
resetDiscoveryCache()
var server *httptest.Server
Expand Down
11 changes: 11 additions & 0 deletions sdk/go/openshell/v1/oidc/device.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,19 @@ func DeviceLogin(ctx context.Context, opts ...LoginOption) (*oauth2.Token, error
}
cfg.issuer = gwCfg.OIDCIssuer
cfg.clientID = gwCfg.OIDCClientID
// Gateway-configured scopes fill only genuinely-unset scopes, so an
// explicit WithScopes always wins.
if !cfg.scopesSet && gwCfg.OIDCScopes != "" {
cfg.scopes = strings.Fields(gwCfg.OIDCScopes)
cfg.scopesSet = true
}
}

// DeviceLogin authenticates a user, so the request must be an OIDC one
// whether the scopes came from the caller, the gateway metadata, or the
// defaults.
cfg.requireOpenIDScope()

// Validate required configuration.
if cfg.issuer == "" || cfg.clientID == "" {
return nil, fmt.Errorf(
Expand Down
163 changes: 163 additions & 0 deletions sdk/go/openshell/v1/oidc/device_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,169 @@ func TestDeviceLogin_Success(t *testing.T) {

// TestDeviceLogin_MissingIssuer verifies that DeviceLogin returns
// ErrOIDCConfig when the issuer is not provided.
// DeviceLogin authenticates a user, so the device authorization request must
// carry "openid" on the wire even when the caller supplied its own scopes.
func TestDeviceLogin_AlwaysRequestsOpenIDScope(t *testing.T) {
tests := []struct {
name string
opts []LoginOption
want string
}{
{
name: "unset scopes send the defaults",
opts: nil,
want: "openid profile email",
},
{
name: "explicit empty still sends openid",
opts: []LoginOption{WithScopes()},
want: "openid",
},
{
name: "application scopes gain openid",
opts: []LoginOption{WithScopes("sandbox:read")},
want: "openid sandbox:read",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resetDiscoveryCache()

scope := &capturedScope{}
srv := setupScopeCapturingDeviceProvider(t, scope)

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

opts := append([]LoginOption{
WithIssuer(srv.URL),
WithClientID("device-client"),
WithDisplayFunc(func(_, _ string) {}),
}, tt.opts...)

_, err := DeviceLogin(ctx, opts...)
require.NoError(t, err)
assert.Equal(t, tt.want, scope.get(t))
})
}
}

// DeviceLogin must honor the gateway's configured oidc_scopes the way the
// Rust CLI does (see build_scopes in crates/openshell-cli/src/oidc_auth.rs),
// while an explicit WithScopes still wins.
func TestDeviceLogin_GatewayScopes(t *testing.T) {
tests := []struct {
name string
gatewayScope string
opts []LoginOption
want string
}{
{
name: "gateway scopes are used when the caller sets none",
gatewayScope: "openid sandbox:read sandbox:write",
want: "openid sandbox:read sandbox:write",
},
{
name: "gateway scopes gain openid",
gatewayScope: "sandbox:read",
want: "openid sandbox:read",
},
{
name: "explicit scopes win over gateway scopes",
gatewayScope: "sandbox:read",
opts: []LoginOption{WithScopes("sandbox:admin")},
want: "openid sandbox:admin",
},
{
name: "empty gateway scopes fall back to the defaults",
gatewayScope: "",
want: "openid profile email",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resetDiscoveryCache()

scope := &capturedScope{}
srv := setupScopeCapturingDeviceProvider(t, scope)

fakeConfig := &gateway.Config{
Name: "device-gw",
Endpoint: "gateway.example.com:443",
Dir: t.TempDir(),
OIDCIssuer: srv.URL,
OIDCClientID: "gw-device-client",
OIDCScopes: tt.gatewayScope,
}

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

opts := append([]LoginOption{
WithGateway("device-gw"),
WithDisplayFunc(func(_, _ string) {}),
withGatewayResolver(func(string) (*gateway.Config, error) {
return fakeConfig, nil
}),
}, tt.opts...)

_, err := DeviceLogin(ctx, opts...)
require.NoError(t, err)
assert.Equal(t, tt.want, scope.get(t))
})
}
}

// capturedScope records the scope parameter seen by a mock provider.
type capturedScope struct {
value string
seen atomic.Bool
}

func (c *capturedScope) get(t *testing.T) string {
t.Helper()
require.True(t, c.seen.Load(), "the authorization request was never made")
return c.value
}

// setupScopeCapturingDeviceProvider serves a device-flow provider that records
// the scope parameter sent to the device authorization endpoint.
func setupScopeCapturingDeviceProvider(t *testing.T, scope *capturedScope) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
var srv *httptest.Server

mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": srv.URL,
"authorization_endpoint": srv.URL + "/authorize",
"token_endpoint": srv.URL + "/token",
"device_authorization_endpoint": srv.URL + "/device",
})
})
mux.HandleFunc("/device", func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
scope.value = r.Form.Get("scope")
scope.seen.Store(true)
_ = json.NewEncoder(w).Encode(map[string]any{
"device_code": "test-device-code",
"user_code": "ABCD-1234",
"verification_uri": "https://example.com/activate",
"expires_in": 300,
"interval": 1,
})
})
mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(tokenResponseJSON("device-access-token", "", 3600)))
})

srv = httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}

func TestDeviceLogin_MissingIssuer(t *testing.T) {
resetDiscoveryCache()

Expand Down
14 changes: 12 additions & 2 deletions sdk/go/openshell/v1/oidc/oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"io"
"os"
"slices"
"strings"

"golang.org/x/oauth2"

Expand Down Expand Up @@ -50,6 +51,9 @@ func Login(ctx context.Context, gatewayName string, opts ...LoginOption) (*oauth
if err != nil {
return nil, err
}
// Login authenticates a user, so the request must be an OIDC one whether the
// scopes came from the caller, the gateway metadata, or the defaults.
cfg.requireOpenIDScope()

// FR-019: Check for existing valid token on disk before starting
// an interactive flow.
Expand Down Expand Up @@ -115,8 +119,8 @@ func Login(ctx context.Context, gatewayName string, opts ...LoginOption) (*oauth
return tok, nil
}

// resolveOIDCConfig resolves the OIDC issuer and client ID either from
// the gateway metadata or from explicit options. Returns the token
// resolveOIDCConfig resolves the OIDC issuer, client ID, and scopes either
// from the gateway metadata or from explicit options. Returns the token
// directory path (empty if in-memory or no directory available).
func resolveOIDCConfig(cfg *loginConfig, gatewayName string) (string, error) {
tokenDir := cfg.tokenDir
Expand All @@ -136,6 +140,12 @@ func resolveOIDCConfig(cfg *loginConfig, gatewayName string) (string, error) {
}
cfg.issuer = gwCfg.OIDCIssuer
cfg.clientID = gwCfg.OIDCClientID
// Gateway-configured scopes fill only genuinely-unset scopes, so an
// explicit WithScopes always wins.
if !cfg.scopesSet && gwCfg.OIDCScopes != "" {
cfg.scopes = strings.Fields(gwCfg.OIDCScopes)
cfg.scopesSet = true
}
if tokenDir == "" {
tokenDir = gwCfg.Dir
}
Expand Down
Loading
Loading