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
3 changes: 3 additions & 0 deletions clients/rancher/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package auth

import (
"github.com/rancher/shepherd/clients/rancher/auth/activedirectory"
"github.com/rancher/shepherd/clients/rancher/auth/oidc"
"github.com/rancher/shepherd/clients/rancher/auth/openldap"
management "github.com/rancher/shepherd/clients/rancher/generated/management/v3"
"github.com/rancher/shepherd/pkg/session"
Expand All @@ -10,6 +11,7 @@ import (
type Client struct {
OLDAP *openldap.OLDAPClient
ActiveDirectory *activedirectory.Client
OIDC *oidc.APIClient
}

// NewClient constructs the Auth Provider Struct
Expand All @@ -27,5 +29,6 @@ func NewClient(mgmt *management.Client, session *session.Session) (*Client, erro
return &Client{
OLDAP: oLDAP,
ActiveDirectory: activeDirectory,
OIDC: oidc.NewAPIClient(mgmt.Opts.URL, session),
}, nil
}
25 changes: 25 additions & 0 deletions clients/rancher/auth/oidc/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package oidc

const (
ConfigurationFileKey = "oidc"
OIDCProviderFeatureFlag = "oidc-provider"
DefaultTokenExpirationSeconds = 3600
DefaultRefreshTokenExpirationSeconds = 86400
)

var DefaultAutomationScopes = []string{
"openid",
"profile",
"offline_access",
"rancher:users",
}

type Config struct {
ClientName string `json:"clientName" yaml:"clientName"`
RedirectURI string `json:"redirectURI" yaml:"redirectURI"`
Scopes []string `json:"scopes" yaml:"scopes"`
TokenExpirationSeconds int `json:"tokenExpirationSeconds" yaml:"tokenExpirationSeconds"`
RefreshTokenExpirationSeconds int `json:"refreshTokenExpirationSeconds" yaml:"refreshTokenExpirationSeconds"`
AdminUsername string `json:"adminUsername" yaml:"adminUsername"`
AdminPassword string `json:"adminPassword" yaml:"adminPassword"`
}
170 changes: 170 additions & 0 deletions clients/rancher/auth/oidc/oidc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package oidc

import (
"crypto/rand"
"crypto/tls"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"

management "github.com/rancher/shepherd/clients/rancher/generated/management/v3"
oidcext "github.com/rancher/shepherd/extensions/auth/oidc"
sheptoken "github.com/rancher/shepherd/extensions/token"
"github.com/rancher/shepherd/pkg/clientbase"
"github.com/rancher/shepherd/pkg/config"
"github.com/rancher/shepherd/pkg/session"
"github.com/sirupsen/logrus"
)

type APIClient struct {
rancherURL string
httpClient *http.Client
noRedirectClient *http.Client
Config *Config
session *session.Session
}

// NewAPIClient constructs an APIClient and loads the "oidc" config block.
func NewAPIClient(rancherURL string, session *session.Session) *APIClient {
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // nolint:gosec
}
normalizedURL := strings.TrimRight(rancherURL, "/")
if !strings.HasPrefix(normalizedURL, "https://") && !strings.HasPrefix(normalizedURL, "http://") {
normalizedURL = "https://" + normalizedURL
}
oidcConfig := new(Config)
config.LoadConfig(ConfigurationFileKey, oidcConfig)
return &APIClient{
rancherURL: normalizedURL,
Config: oidcConfig,
session: session,
httpClient: &http.Client{Transport: transport},
noRedirectClient: &http.Client{
Transport: transport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
},
}
}

// GetDiscovery fetches the OIDC provider metadata document.
func (c *APIClient) GetDiscovery() (*clientbase.Response, map[string]interface{}, error) {
return oidcext.Discovery(c.httpClient, c.rancherURL)
}

// RefreshAccessToken exchanges a refresh_token for a new TokenSet.
func (c *APIClient) RefreshAccessToken(refreshToken, clientID, clientSecret string) (*oidcext.TokenSet, error) {
return oidcext.RefreshAccessToken(c.httpClient, c.rancherURL, refreshToken, clientID, clientSecret)
}

// RawRequest executes an HTTP request against a Rancher API path.
func (c *APIClient) RawRequest(method, path, authHeader string) (*clientbase.Response, error) {
headers := map[string]string{}
if authHeader != "" {
headers["Authorization"] = authHeader
}
return clientbase.Do(c.httpClient, method, c.rancherURL+path, nil, headers)
}

// CompleteAuthCodeFlow drives the headless PKCE authorization-code flow and returns a TokenSet.
func (c *APIClient) CompleteAuthCodeFlow(clientID, clientSecret, redirectURI, scopes, username, password string) (*oidcext.TokenSet, error) {
const maxAttempts = 5
for attempt := 1; attempt <= maxAttempts; attempt++ {
tokenResp, err := sheptoken.GenerateUserToken(&management.User{Username: username, Password: password}, strings.TrimPrefix(c.rancherURL, "https://"))
if err != nil {
return nil, fmt.Errorf("step 1 (Rancher login): %w", err)
}
rancherToken := tokenResp.Token
pkce, err := oidcext.GeneratePKCE()
if err != nil {
return nil, fmt.Errorf("step 2 (PKCE generation): %w", err)
}
state, err := randomState(12)
if err != nil {
return nil, fmt.Errorf("step 2 (state generation): %w", err)
}
nonce, err := randomState(12)
if err != nil {
return nil, fmt.Errorf("step 2 (nonce generation): %w", err)
}
params := url.Values{
"response_type": {"code"},
"client_id": {clientID},
"redirect_uri": {redirectURI},
"scope": {scopes},
"state": {state},
"nonce": {nonce},
"code_challenge": {pkce.Challenge},
"code_challenge_method": {"S256"},
}
authURL := c.rancherURL + oidcext.OIDCAuthPath + "?" + params.Encode()
authResp, err := clientbase.Do(c.noRedirectClient, "GET", authURL, nil, map[string]string{
"Authorization": "Bearer " + rancherToken,
})
if err != nil {
return nil, fmt.Errorf("step 3 (auth endpoint GET): %w", err)
}
if authResp.StatusCode != http.StatusFound {
return nil, fmt.Errorf("step 3 expected 302 from auth endpoint, got %d: %s",
authResp.StatusCode, authResp.Body)
}
location := authResp.Header.Get("Location")
if location == "" {
return nil, fmt.Errorf("step 4: auth endpoint returned 302 but no Location header")
}
if strings.Contains(location, "/dashboard/auth/login") {
logrus.Infof("step 4: auth endpoint redirected to dashboard login β€” Rancher session token was rejected, retrying")
continue
}
redirectParsed, err := url.Parse(location)
if err != nil {
return nil, fmt.Errorf("step 4: parsing Location URL %q: %w", location, err)
}
authCode := redirectParsed.Query().Get("code")
if authCode == "" {
return nil, fmt.Errorf("step 4: no 'code' parameter in redirect Location %q", location)
}
if redirectParsed.Query().Get("state") != state {
return nil, fmt.Errorf("step 4: state mismatch")
}
body := url.Values{
"grant_type": {"authorization_code"},
"code": {authCode},
"code_verifier": {pkce.Verifier},
"client_id": {clientID},
"client_secret": {clientSecret},
"redirect_uri": {redirectURI},
}
resp, err := clientbase.Do(c.httpClient, "POST", c.rancherURL+oidcext.OIDCTokenPath, body, map[string]string{
"Content-Type": "application/x-www-form-urlencoded",
})
if err != nil {
return nil, fmt.Errorf("token exchange POST: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("token exchange returned %d: %s", resp.StatusCode, resp.Body)
}
var ts oidcext.TokenSet
if err := json.Unmarshal(resp.Body, &ts); err != nil {
return nil, fmt.Errorf("parsing token response: %w", err)
}
if ts.AccessToken == "" {
return nil, fmt.Errorf("token response missing access_token field")
}
return &ts, nil
}
return nil, fmt.Errorf("PKCE auth flow failed after %d attempts", maxAttempts)
}

func randomState(n int) (string, error) {
raw := make([]byte, n)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("generating random state: %w", err)
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
31 changes: 6 additions & 25 deletions clients/rancher/auth/scim/client.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
package scim

import (
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
Expand Down Expand Up @@ -89,34 +87,17 @@ func (t *scimTransport) do(method, resource, id string, query url.Values, body i
rawURL += "?" + query.Encode()
}

var bodyReader io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, err
}
bodyReader = bytes.NewReader(b)
}

req, err := http.NewRequest(method, rawURL, bodyReader)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+t.token)
req.Header.Set("Content-Type", "application/scim+json")
req.Header.Set("Accept", "application/scim+json")

resp, err := t.httpClient.Do(req)
if err != nil {
return nil, err
headers := map[string]string{
"Authorization": "Bearer " + t.token,
"Content-Type": "application/scim+json",
"Accept": "application/scim+json",
}
defer resp.Body.Close()

respBody, err := io.ReadAll(resp.Body)
resp, err := clientbase.Do(t.httpClient, method, rawURL, body, headers)
if err != nil {
return nil, err
}
return &Response{StatusCode: resp.StatusCode, Body: respBody, Header: resp.Header}, nil
return &Response{StatusCode: resp.StatusCode, Body: resp.Body, Header: resp.Header}, nil
}

// Users is the interface for SCIM User operations.
Expand Down
39 changes: 39 additions & 0 deletions extensions/auth/oidc/jwt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package oidc

import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
)

// DecodeJWTPayload decodes the payload section of a JWT without verifying the signature.
func DecodeJWTPayload(token string) (map[string]interface{}, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid JWT structure: expected 3 parts, got %d", len(parts))
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("decoding JWT payload: %w", err)
}
var claims map[string]interface{}
if err := json.Unmarshal(payload, &claims); err != nil {
return nil, fmt.Errorf("parsing JWT claims: %w", err)
}
return claims, nil
}

// TamperJWTSignature replaces the signature segment of a JWT with random bytes.
func TamperJWTSignature(token string) (string, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return "", fmt.Errorf("invalid JWT: cannot tamper signature")
}
fakeSig := make([]byte, 32)
if _, err := rand.Read(fakeSig); err != nil {
return "", fmt.Errorf("generating fake signature: %w", err)
}
return parts[0] + "." + parts[1] + "." + base64.RawURLEncoding.EncodeToString(fakeSig), nil
}
69 changes: 69 additions & 0 deletions extensions/auth/oidc/oidc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package oidc

import (
"encoding/json"
"fmt"
"net/http"
"net/url"

"github.com/rancher/shepherd/pkg/clientbase"
)

const (
OIDCClientGroup = "management.cattle.io"
OIDCClientVersion = "v3"
OIDCClientResource = "oidcclients"
OIDCClientKind = "OIDCClient"
Comment on lines +13 to +16

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this can be removed later when you update OIDCClient CRUD to use wrangler context after the pkg/apis bump in go.mod


OIDCDiscoveryPath = "/oidc/.well-known/openid-configuration"
OIDCAuthPath = "/oidc/authorize"
OIDCTokenPath = "/oidc/token"
UsersPath = "/v3/users"
ClustersPath = "/v3/clusters"
)

type TokenSet struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
Scope string `json:"scope"`
}

// Discovery fetches the OIDC provider metadata document.
func Discovery(httpClient *http.Client, rancherURL string) (*clientbase.Response, map[string]interface{}, error) {
resp, err := clientbase.Do(httpClient, "GET", rancherURL+OIDCDiscoveryPath, nil, nil)
if err != nil {
return nil, nil, fmt.Errorf("fetching OIDC discovery: %w", err)
}
var doc map[string]interface{}
if err := json.Unmarshal(resp.Body, &doc); err != nil {
return resp, nil, fmt.Errorf("parsing discovery document: %w", err)
}
return resp, doc, nil
}

// RefreshAccessToken exchanges a refresh_token for a new TokenSet.
func RefreshAccessToken(httpClient *http.Client, rancherURL, refreshToken, clientID, clientSecret string) (*TokenSet, error) {
body := url.Values{
"grant_type": {"refresh_token"},
"refresh_token": {refreshToken},
"client_id": {clientID},
"client_secret": {clientSecret},
}
resp, err := clientbase.Do(httpClient, "POST", rancherURL+OIDCTokenPath, body, map[string]string{
"Content-Type": "application/x-www-form-urlencoded",
})
if err != nil {
return nil, fmt.Errorf("refresh token POST: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("refresh returned %d: %s", resp.StatusCode, resp.Body)
}
var ts TokenSet
if err := json.Unmarshal(resp.Body, &ts); err != nil {
return nil, fmt.Errorf("parsing refresh response: %w", err)
}
return &ts, nil
}
Loading
Loading