Files
OpenShell/sdk/go/openshell/v1/oidc/authcode.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

237 lines
7.1 KiB
Go

// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package oidc
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
"golang.org/x/oauth2"
)
// generateCodeVerifier creates a PKCE code verifier per RFC 7636.
// It generates 32 random bytes and encodes them as base64url without
// padding, producing a 43-character string.
func generateCodeVerifier() (string, error) {
b := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, b); err != nil {
return "", fmt.Errorf("generate code verifier: %w", err)
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// codeChallengeS256 computes the S256 PKCE code challenge for the
// given verifier. It returns BASE64URL(SHA256(verifier)) without
// padding, as specified in RFC 7636 Section 4.2.
func codeChallengeS256(verifier string) string {
h := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(h[:])
}
// generateState creates a cryptographic state parameter for the
// authorization request. It generates 16 random bytes encoded as
// base64url without padding.
func generateState() (string, error) {
b := make([]byte, 16)
if _, err := io.ReadFull(rand.Reader, b); err != nil {
return "", fmt.Errorf("generate state: %w", err)
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// buildAuthURL constructs the authorization endpoint URL with query
// parameters for the authorization code flow. If challenge is empty,
// PKCE parameters are omitted (for providers that do not support it).
func buildAuthURL(authEndpoint, clientID, redirectURI, state, challenge string, scopes []string) string {
u, err := url.Parse(authEndpoint)
if err != nil || u.Scheme == "" {
u = &url.URL{Path: authEndpoint}
}
q := u.Query()
q.Set("response_type", "code")
q.Set("client_id", clientID)
q.Set("redirect_uri", redirectURI)
q.Set("state", state)
q.Set("scope", strings.Join(scopes, " "))
if challenge != "" {
q.Set("code_challenge", challenge)
q.Set("code_challenge_method", "S256")
}
u.RawQuery = q.Encode()
return u.String()
}
// callbackResult carries the authorization code (or error) from the
// callback server to the auth code flow orchestrator.
type callbackResult struct {
code string
err error
}
// startCallbackServer starts a localhost HTTP server to receive the
// OIDC provider's authorization callback. It listens on the specified
// port (use 0 for OS-assigned port). The server handles a single
// callback request and sends the result on the returned channel.
//
// The caller is responsible for calling srv.Close() when done.
func startCallbackServer(ctx context.Context, port int, expectedState string) (*http.Server, <-chan callbackResult, error) {
resultCh := make(chan callbackResult, 1)
var sendOnce sync.Once
send := func(r callbackResult) {
sendOnce.Do(func() { resultCh <- r })
}
mux := http.NewServeMux()
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if errCode := q.Get("error"); errCode != "" {
desc := q.Get("error_description")
msg := fmt.Sprintf("provider error: %s", errCode)
if desc != "" {
msg += ": " + desc
}
http.Error(w, msg, http.StatusBadRequest)
send(callbackResult{err: fmt.Errorf("%w: %s", ErrAuthCode, msg)})
return
}
state := q.Get("state")
if state != expectedState {
http.Error(w, "state mismatch", http.StatusBadRequest)
send(callbackResult{err: fmt.Errorf("%w: state mismatch", ErrAuthCode)})
return
}
code := q.Get("code")
if code == "" {
http.Error(w, "missing authorization code", http.StatusBadRequest)
send(callbackResult{err: fmt.Errorf("%w: missing authorization code in callback", ErrAuthCode)})
return
}
w.Header().Set("Content-Type", "text/html")
_, _ = fmt.Fprint(w, "<html><body><h1>Login successful</h1><p>You can close this window.</p></body></html>")
send(callbackResult{code: code})
})
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil {
return nil, nil, fmt.Errorf("%w: failed to start callback server on port %d: %v", ErrCallbackServer, port, err)
}
srv := &http.Server{
Addr: listener.Addr().String(),
Handler: mux,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 30 * time.Second,
}
done := make(chan struct{})
go func() {
_ = srv.Serve(listener)
close(done)
}()
go func() {
select {
case <-ctx.Done():
case <-done:
return
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
}()
return srv, resultCh, nil
}
// tokenResponse is the JSON structure returned by the token endpoint.
type tokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
Error string `json:"error"`
ErrorDesc string `json:"error_description"`
}
// exchangeCode exchanges an authorization code for tokens at the
// token endpoint. If codeVerifier is empty, the PKCE code_verifier
// parameter is omitted from the request.
//
// Secrets (code, verifier) are never included in error messages.
func exchangeCode(ctx context.Context, tokenEndpoint, clientID, code, redirectURI, codeVerifier string) (*oauth2.Token, error) {
data := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"client_id": {clientID},
"redirect_uri": {redirectURI},
}
if codeVerifier != "" {
data.Set("code_verifier", codeVerifier)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint, strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("%w: failed to create token request: %v", ErrAuthCode, err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := oidcHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: token request failed: %v", ErrAuthCode, err)
}
defer func() { _ = resp.Body.Close() }()
const maxResponseBytes = 1 << 20
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
if err != nil {
return nil, fmt.Errorf("%w: failed to read token response: %v", ErrAuthCode, err)
}
var tokResp tokenResponse
if err := json.Unmarshal(body, &tokResp); err != nil {
return nil, fmt.Errorf("%w: invalid token response JSON: %v", ErrAuthCode, err)
}
if resp.StatusCode != http.StatusOK || tokResp.Error != "" {
msg := "token exchange failed"
if tokResp.Error != "" {
msg = fmt.Sprintf("token exchange failed: %s", tokResp.Error)
if tokResp.ErrorDesc != "" {
msg += ": " + tokResp.ErrorDesc
}
}
return nil, fmt.Errorf("%w: %s", ErrAuthCode, msg)
}
tok := &oauth2.Token{
AccessToken: tokResp.AccessToken,
RefreshToken: tokResp.RefreshToken,
TokenType: tokResp.TokenType,
}
if tokResp.ExpiresIn > 0 {
tok.Expiry = time.Now().Add(time.Duration(tokResp.ExpiresIn) * time.Second)
}
return tok, nil
}