Files
OpenShell/sdk/go/openshell/v1/oidc/authcode_test.go
Roland HussandDrew Newberry 8dc55e21ee feat(sdk/go): complete Go SDK with domain clients, auth, and hardening (#2702)
* feat(sdk/go): complete Go SDK with domain clients, auth, and hardening

Add the Go SDK implementation covering all gateway RPC services with
domain-typed clients, comprehensive OIDC authentication flows, fake
test doubles, and proto converters.

Domain clients: Sandbox, Provider, Exec, File, TCP, SSH, Policy,
Profile, Health, Service, Config, Workspace, Inference, Refresh.
Each client validates inputs, resolves sandboxes by name, and converts
between domain types and proto at the boundary.

Auth: OIDC authorization code (PKCE), device code (RFC 8628), and
client credentials (RFC 6749 Section 4.4) flows with gateway config
auto-resolution. Token refresh with singleflight deduplication and
exponential backoff. Edge tunnel proxy for gRPC-over-WebSocket.

Gateway: On-disk gateway discovery with user/system directory
precedence, lazy token loading, and auth mode mapping.

Testing: In-memory fake client with deep-copy isolation, watch
broadcasting with filtering, and workspace-scoped object stores.
Bufconn-based gRPC tests for all domain clients.

Ref: #2044

Signed-off-by: Roland Huß <rhuss@redhat.com>

* feat(sdk/go): add gateway client options for logger, timeout, and retry

Add WithLogger, WithTimeout, and WithRetryPolicy options to the gateway
package's NewClient function. These forward the corresponding Config
fields (Logger, Timeout, RetryPolicy) through to the underlying SDK
client, giving callers full control over observability, connection
timeouts, and retry behavior when constructing clients from on-disk
gateway configurations.

Also adds RetryPolicy type to types package and extends Config with the
three new fields.

* fix(mise): sync sccache lock entry

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

---------

Signed-off-by: Roland Huß <rhuss@redhat.com>
Signed-off-by: Drew Newberry <anewberry@nvidia.com>
Co-authored-by: Drew Newberry <anewberry@nvidia.com>
2026-08-13 19:15:21 +00:00

372 lines
10 KiB
Go

// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package oidc
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// --- T012: PKCE verifier/challenge generation tests ---
func TestGenerateCodeVerifier_Length(t *testing.T) {
verifier, err := generateCodeVerifier()
require.NoError(t, err)
// RFC 7636 requires 43-128 characters. Our implementation uses 32
// random bytes -> 43 base64url characters (no padding).
assert.GreaterOrEqual(t, len(verifier), 43)
assert.LessOrEqual(t, len(verifier), 128)
}
func TestGenerateCodeVerifier_Base64URLSafe(t *testing.T) {
verifier, err := generateCodeVerifier()
require.NoError(t, err)
// Must contain only base64url characters (A-Z, a-z, 0-9, -, _).
// No padding (=) allowed per RFC 7636.
for _, c := range verifier {
valid := (c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') ||
c == '-' || c == '_'
assert.True(t, valid, "invalid character in verifier: %c", c)
}
}
func TestGenerateCodeVerifier_Unique(t *testing.T) {
v1, err := generateCodeVerifier()
require.NoError(t, err)
v2, err := generateCodeVerifier()
require.NoError(t, err)
assert.NotEqual(t, v1, v2, "two verifiers should differ (random)")
}
func TestCodeChallengeS256(t *testing.T) {
// RFC 7636 Appendix B test vector:
// verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
// challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
challenge := codeChallengeS256(verifier)
assert.Equal(t, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", challenge)
}
func TestCodeChallengeS256_NoPadding(t *testing.T) {
verifier, err := generateCodeVerifier()
require.NoError(t, err)
challenge := codeChallengeS256(verifier)
// S256 challenge must be base64url without padding.
assert.NotContains(t, challenge, "=")
assert.NotContains(t, challenge, "+")
assert.NotContains(t, challenge, "/")
}
// --- T013: Auth code flow tests ---
func TestGenerateState_Length(t *testing.T) {
state, err := generateState()
require.NoError(t, err)
// 16 random bytes -> 22 base64url chars (no padding).
assert.GreaterOrEqual(t, len(state), 16)
}
func TestGenerateState_Unique(t *testing.T) {
s1, err := generateState()
require.NoError(t, err)
s2, err := generateState()
require.NoError(t, err)
assert.NotEqual(t, s1, s2)
}
func TestBuildAuthURL(t *testing.T) {
authEndpoint := "https://auth.example.com/authorize"
clientID := "test-client"
redirectURI := "http://localhost:8000/callback"
state := "random-state"
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
challenge := codeChallengeS256(verifier)
scopes := []string{"openid", "profile"}
authURL := buildAuthURL(authEndpoint, clientID, redirectURI, state, challenge, scopes)
parsed, err := url.Parse(authURL)
require.NoError(t, err)
q := parsed.Query()
assert.Equal(t, "code", q.Get("response_type"))
assert.Equal(t, clientID, q.Get("client_id"))
assert.Equal(t, redirectURI, q.Get("redirect_uri"))
assert.Equal(t, state, q.Get("state"))
assert.Equal(t, challenge, q.Get("code_challenge"))
assert.Equal(t, "S256", q.Get("code_challenge_method"))
assert.Equal(t, "openid profile", q.Get("scope"))
}
func TestBuildAuthURL_NoPKCE(t *testing.T) {
authURL := buildAuthURL(
"https://auth.example.com/authorize",
"test-client",
"http://localhost:8000/callback",
"state",
"", // empty challenge = no PKCE
[]string{"openid"},
)
parsed, err := url.Parse(authURL)
require.NoError(t, err)
q := parsed.Query()
assert.Equal(t, "code", q.Get("response_type"))
assert.Empty(t, q.Get("code_challenge"), "no PKCE when challenge is empty")
assert.Empty(t, q.Get("code_challenge_method"))
}
func TestStartCallbackServer_ReceivesCode(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
state := "test-state-123"
srv, resultCh, err := startCallbackServer(ctx, 0, state)
require.NoError(t, err)
defer func() { _ = srv.Close() }()
// Extract the port from the server's listener address.
addr := srv.Addr
callbackURL := fmt.Sprintf("http://%s/callback?code=auth-code-xyz&state=%s", addr, state)
resp, err := http.Get(callbackURL)
require.NoError(t, err)
_ = resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
result := <-resultCh
require.NoError(t, result.err)
assert.Equal(t, "auth-code-xyz", result.code)
}
func TestStartCallbackServer_StateMismatch(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
state := "expected-state"
srv, resultCh, err := startCallbackServer(ctx, 0, state)
require.NoError(t, err)
defer func() { _ = srv.Close() }()
addr := srv.Addr
callbackURL := fmt.Sprintf("http://%s/callback?code=some-code&state=wrong-state", addr)
resp, err := http.Get(callbackURL)
require.NoError(t, err)
_ = resp.Body.Close()
result := <-resultCh
require.Error(t, result.err)
assert.True(t, errors.Is(result.err, ErrAuthCode))
assert.Contains(t, result.err.Error(), "state")
}
func TestStartCallbackServer_MissingCode(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
state := "test-state"
srv, resultCh, err := startCallbackServer(ctx, 0, state)
require.NoError(t, err)
defer func() { _ = srv.Close() }()
addr := srv.Addr
callbackURL := fmt.Sprintf("http://%s/callback?state=%s", addr, state)
resp, err := http.Get(callbackURL)
require.NoError(t, err)
_ = resp.Body.Close()
result := <-resultCh
require.Error(t, result.err)
assert.True(t, errors.Is(result.err, ErrAuthCode))
}
func TestStartCallbackServer_ProviderError(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
state := "test-state"
srv, resultCh, err := startCallbackServer(ctx, 0, state)
require.NoError(t, err)
defer func() { _ = srv.Close() }()
addr := srv.Addr
callbackURL := fmt.Sprintf("http://%s/callback?error=access_denied&error_description=user+denied&state=%s", addr, state)
resp, err := http.Get(callbackURL)
require.NoError(t, err)
_ = resp.Body.Close()
result := <-resultCh
require.Error(t, result.err)
assert.True(t, errors.Is(result.err, ErrAuthCode))
assert.Contains(t, result.err.Error(), "access_denied")
}
func TestStartCallbackServer_SpecificPort(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Port 0 tells OS to pick a free port. We just verify it works.
srv, _, err := startCallbackServer(ctx, 0, "state")
require.NoError(t, err)
defer func() { _ = srv.Close() }()
assert.NotEmpty(t, srv.Addr)
}
func TestExchangeCode_Success(t *testing.T) {
tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.NoError(t, r.ParseForm())
assert.Equal(t, "authorization_code", r.Form.Get("grant_type"))
assert.Equal(t, "test-code", r.Form.Get("code"))
assert.Equal(t, "test-client", r.Form.Get("client_id"))
assert.Equal(t, "http://localhost:8000/callback", r.Form.Get("redirect_uri"))
assert.NotEmpty(t, r.Form.Get("code_verifier"), "PKCE verifier should be sent")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"access_token": "at-123",
"refresh_token": "rt-456",
"token_type": "Bearer",
"expires_in": 3600
}`))
}))
defer tokenSrv.Close()
tok, err := exchangeCode(
context.Background(),
tokenSrv.URL+"/token",
"test-client",
"test-code",
"http://localhost:8000/callback",
"pkce-verifier",
)
require.NoError(t, err)
assert.Equal(t, "at-123", tok.AccessToken)
assert.Equal(t, "rt-456", tok.RefreshToken)
assert.Equal(t, "Bearer", tok.TokenType)
assert.False(t, tok.Expiry.IsZero())
}
func TestExchangeCode_NoPKCE(t *testing.T) {
tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.NoError(t, r.ParseForm())
assert.Empty(t, r.Form.Get("code_verifier"), "no PKCE verifier when empty")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"access_token": "at-no-pkce",
"token_type": "Bearer",
"expires_in": 3600
}`))
}))
defer tokenSrv.Close()
tok, err := exchangeCode(
context.Background(),
tokenSrv.URL+"/token",
"test-client",
"test-code",
"http://localhost:8000/callback",
"", // empty verifier = no PKCE
)
require.NoError(t, err)
assert.Equal(t, "at-no-pkce", tok.AccessToken)
}
func TestExchangeCode_ErrorResponse(t *testing.T) {
tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error": "invalid_grant", "error_description": "code expired"}`))
}))
defer tokenSrv.Close()
_, err := exchangeCode(
context.Background(),
tokenSrv.URL+"/token",
"client",
"bad-code",
"http://localhost/callback",
"verifier",
)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrAuthCode))
}
func TestExchangeCode_SecretsNotInError(t *testing.T) {
tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error": "invalid_grant"}`))
}))
defer tokenSrv.Close()
_, err := exchangeCode(
context.Background(),
tokenSrv.URL+"/token",
"client",
"secret-code-value",
"http://localhost/callback",
"secret-verifier-value",
)
require.Error(t, err)
// The error message must not contain the auth code or verifier.
assert.NotContains(t, err.Error(), "secret-code-value")
assert.NotContains(t, err.Error(), "secret-verifier-value")
}
func TestExchangeCode_InvalidJSON(t *testing.T) {
tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`not json`))
}))
defer tokenSrv.Close()
_, err := exchangeCode(
context.Background(),
tokenSrv.URL+"/token",
"client",
"code",
"http://localhost/callback",
"verifier",
)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrAuthCode))
}
// tokenResponseJSON is a helper for creating token endpoint responses.
func tokenResponseJSON(accessToken, refreshToken string, expiresIn int) string {
return fmt.Sprintf(`{
"access_token": %q,
"refresh_token": %q,
"token_type": "Bearer",
"expires_in": %d
}`, accessToken, refreshToken, expiresIn)
}