Skip to content
Closed
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
12 changes: 7 additions & 5 deletions central/auth/m2m/claims.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package m2m
import (
"fmt"

"github.com/coreos/go-oidc/v3/oidc"
"github.com/pkg/errors"
"github.com/stackrox/rox/generated/storage"
"github.com/stackrox/rox/pkg/auth/tokens"
Expand All @@ -17,11 +16,14 @@ var (
)

type claimExtractor interface {
ExtractRoxClaims(idToken *oidc.IDToken) (tokens.RoxClaims, error)
ExtractRoxClaims(idToken *IDToken) (tokens.RoxClaims, error)
}

func newClaimExtractorFromConfig(config *storage.AuthMachineToMachineConfig) claimExtractor {
if config.GetType() == storage.AuthMachineToMachineConfig_GENERIC {
switch config.GetType() {
case storage.AuthMachineToMachineConfig_KUBE_TOKEN_REVIEW:
fallthrough
case storage.AuthMachineToMachineConfig_GENERIC:
return &genericClaimExtractor{configID: config.GetId()}
}

Expand All @@ -32,7 +34,7 @@ type genericClaimExtractor struct {
configID string
}

func (g *genericClaimExtractor) ExtractRoxClaims(idToken *oidc.IDToken) (tokens.RoxClaims, error) {
func (g *genericClaimExtractor) ExtractRoxClaims(idToken *IDToken) (tokens.RoxClaims, error) {
var unstructured map[string]interface{}
if err := idToken.Claims(&unstructured); err != nil {
return tokens.RoxClaims{}, errors.Wrap(err, "extracting claims")
Expand Down Expand Up @@ -109,7 +111,7 @@ type githubActionClaims struct {
WorkflowSHA string `json:"workflow_sha"`
}

func (g *githubClaimExtractor) ExtractRoxClaims(idToken *oidc.IDToken) (tokens.RoxClaims, error) {
func (g *githubClaimExtractor) ExtractRoxClaims(idToken *IDToken) (tokens.RoxClaims, error) {
// OIDC tokens issued for GitHub Actions have special claims, we'll reuse them.
var claims githubActionClaims
if err := idToken.Claims(&claims); err != nil {
Expand Down
6 changes: 6 additions & 0 deletions central/auth/m2m/exchanger.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,12 @@ func mapToStringClaims(claims map[string]interface{}) map[string][]string {
stringClaims[key] = []string{value}
case []string:
stringClaims[key] = value
case []any:
for _, v := range value {
if s, ok := v.(string); ok {
stringClaims[key] = append(stringClaims[key], s)
}
}
default:
log.Debugf("Dropping value %v for claim %s since its a nested claim or a non-string type %T", value, key, value)
}
Expand Down
6 changes: 4 additions & 2 deletions central/auth/m2m/exchanger_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@ func TestMapStringToClaims(t *testing.T) {
[]string{"four", "five", "six"},
},
},
"groups": []any{"group1", "group2"},
}
expectedResult := map[string][]string{
"sub": {"my-subject"},
"aud": {"audience-1", "audience-2", "audience-3"},
"sub": {"my-subject"},
"aud": {"audience-1", "audience-2", "audience-3"},
"groups": {"group1", "group2"},
}

result := mapToStringClaims(claims)
Expand Down
12 changes: 11 additions & 1 deletion central/auth/m2m/id_token.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
package m2m

import (
"strings"

"github.com/golang-jwt/jwt/v4"
"github.com/pkg/errors"
"github.com/stackrox/rox/pkg/errox"
)

// IssuerFromRawIDToken retrieves the issuer from a raw ID token.
const KubernetesTokenIssuer = "https://kubernetes"

Check failure on line 11 in central/auth/m2m/id_token.go

View workflow job for this annotation

GitHub Actions / golangci-lint

G101: Potential hardcoded credentials (gosec)

Check failure on line 11 in central/auth/m2m/id_token.go

View workflow job for this annotation

GitHub Actions / golangci-lint

G101: Potential hardcoded credentials (gosec)

// IssuerFromRawIDToken retrieves the issuer from a raw ID token. If the token
// is not a JWT, assume Kubernetes opaque token.
// In case the token is malformed (i.e. jwt.ErrTokenMalformed is met), it will return an error.
// Other errors such as an expired token will be ignored.
// Note: This does **not** verify the token's signature or any other claim value.
func IssuerFromRawIDToken(rawIDToken string) (string, error) {
if strings.HasPrefix(rawIDToken, "sha256~") {
// Not a JWT. Assume Kubernetes opaque token.
return KubernetesTokenIssuer, nil
}

standardClaims := &jwt.RegisteredClaims{}
// Explicitly ignore the signature of the ID token for now.
// This will be handled in a latter part, when the metadata from the provider will be used to verify the signature.
Expand Down
48 changes: 48 additions & 0 deletions central/auth/m2m/kube_token_review.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package m2m

import (
"context"
"encoding/json"

"github.com/pkg/errors"
v1 "k8s.io/api/authentication/v1"
metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
)

// kubeTokenReviewVerifier verifies tokens using the Kubernetes TokenReview API.
type kubeTokenReviewVerifier struct {
clientset kubernetes.Interface
}

func (k *kubeTokenReviewVerifier) VerifyIDToken(ctx context.Context, rawIDToken string) (*IDToken, error) {
tr := &v1.TokenReview{
Spec: v1.TokenReviewSpec{
Token: rawIDToken,
},
}
trResp, err := k.clientset.AuthenticationV1().TokenReviews().
Create(ctx, tr, metaV1.CreateOptions{})
if err != nil {
return nil, errors.Wrap(err, "performing TokenReview request")
}
if !trResp.Status.Authenticated {
return nil, errors.Errorf("token not authenticated: %s", trResp.Status.Error)
}

claims := map[string]any{
"sub": trResp.Status.User.UID,
"name": trResp.Status.User.Username,
"groups": trResp.Status.User.Groups,
}
rawClaims, _ := json.Marshal(claims)

token := &IDToken{
Subject: trResp.Status.User.UID,
Claims: func(v any) error {
return json.Unmarshal(rawClaims, v)
},
Audience: trResp.Status.Audiences,
}
return token, nil
}
33 changes: 33 additions & 0 deletions central/auth/m2m/role_mapper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ func TestResolveRolesForClaims(t *testing.T) {
"aud": {"something", "somewhere"},
"repository": {"github.com/sample-org/sample-repo:main:062348SHA"},
"iss": {"https://stackrox.io"},
"groups": {"system:admins"},
}
config := &storage.AuthMachineToMachineConfig{
Mappings: []*storage.AuthMachineToMachineConfig_Mapping{
Expand Down Expand Up @@ -59,6 +60,10 @@ func TestResolveRolesForClaims(t *testing.T) {
Key: "iss",
ValueExpression: ".*",
Role: authn.NoneRole,
}, {
Key: "groups",
ValueExpression: "system:admins",
Role: "Analyst",
},
},
}
Expand All @@ -79,3 +84,31 @@ func TestResolveRolesForClaims(t *testing.T) {
assert.NoError(t, err)
assert.ElementsMatch(t, resolvedRoles, []permissions.ResolvedRole{roles["Admin"], roles["Analyst"], roles["roxctl"]})
}

func TestResolveRolesForClaimsList(t *testing.T) {
claims := map[string][]string{
"groups": {"system:admins"},
}
config := &storage.AuthMachineToMachineConfig{
Mappings: []*storage.AuthMachineToMachineConfig_Mapping{
{
Key: "groups",
ValueExpression: "system:admins",
Role: "Analyst",
},
},
}
roles := map[string]permissions.ResolvedRole{
"Analyst": &testResolvedRole{name: "Analyst"},
}

roleDSMock := mocks.NewMockDataStore(gomock.NewController(t))

for roleName, resolvedRole := range roles {
roleDSMock.EXPECT().GetAndResolveRole(gomock.Any(), roleName).Return(resolvedRole, nil)
}

resolvedRoles, err := resolveRolesForClaims(context.Background(), claims, roleDSMock, config.GetMappings(), createRegexp(config))
assert.NoError(t, err)
assert.ElementsMatch(t, resolvedRoles, []permissions.ResolvedRole{roles["Analyst"]})
}
36 changes: 32 additions & 4 deletions central/auth/m2m/verifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import (
"github.com/pkg/errors"
"github.com/stackrox/rox/generated/storage"
"github.com/stackrox/rox/pkg/httputil/proxy"
"github.com/stackrox/rox/pkg/k8sutil"
"github.com/stackrox/rox/pkg/logging"
"k8s.io/client-go/kubernetes"
)

const (
Expand All @@ -30,8 +32,14 @@ var (
_ tokenVerifier = (*genericTokenVerifier)(nil)
)

type IDToken struct {
Claims func(any) error
Subject string
Audience []string
}

type tokenVerifier interface {
VerifyIDToken(ctx context.Context, rawIDToken string) (*oidc.IDToken, error)
VerifyIDToken(ctx context.Context, rawIDToken string) (*IDToken, error)
}

type authenticatedRoundTripper struct {
Expand Down Expand Up @@ -68,7 +76,8 @@ func tokenVerifierFromConfig(ctx context.Context, config *storage.AuthMachineToM
}

roundTripper := proxy.RoundTripper(proxy.WithTLSConfig(tlsConfig))
if config.Type == storage.AuthMachineToMachineConfig_KUBE_SERVICE_ACCOUNT {
switch config.Type {
case storage.AuthMachineToMachineConfig_KUBE_SERVICE_ACCOUNT:
token, err := kubeServiceAccountTokenReader{}.readToken()
if err != nil {
return nil, errors.Wrap(err, "Failed to read kube service account token")
Expand All @@ -77,6 +86,17 @@ func tokenVerifierFromConfig(ctx context.Context, config *storage.AuthMachineToM
// By default k8s requires authentication to fetch the OIDC resources for service account tokens
// https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/#service-account-issuer-discovery
roundTripper = authenticatedRoundTripper{roundTripper: roundTripper, token: token}

case storage.AuthMachineToMachineConfig_KUBE_TOKEN_REVIEW:
cfg, err := k8sutil.GetK8sInClusterConfig()
if err != nil {
return nil, err
}
c, err := kubernetes.NewForConfig(cfg)
if err != nil {
return nil, err
}
return &kubeTokenReviewVerifier{c}, nil
}

provider, err := oidc.NewProvider(
Expand All @@ -94,7 +114,7 @@ type genericTokenVerifier struct {
provider *oidc.Provider
}

func (g *genericTokenVerifier) VerifyIDToken(ctx context.Context, rawIDToken string) (*oidc.IDToken, error) {
func (g *genericTokenVerifier) VerifyIDToken(ctx context.Context, rawIDToken string) (*IDToken, error) {
verifier := g.provider.Verifier(&oidc.Config{
// We currently provide no config to expose the client ID that's associated with the ID token.
// The reason for this is the following:
Expand All @@ -106,7 +126,15 @@ func (g *genericTokenVerifier) VerifyIDToken(ctx context.Context, rawIDToken str
SkipClientIDCheck: true,
})

return verifier.Verify(ctx, rawIDToken)
token, err := verifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, err
}
return &IDToken{
Subject: token.Subject,
Audience: token.Audience,
Claims: token.Claims,
}, err
}

func tlsConfigWithCustomCertPool() (*tls.Config, error) {
Expand Down
10 changes: 7 additions & 3 deletions generated/api/v1/auth_service.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion generated/api/v1/auth_service.swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 7 additions & 3 deletions generated/storage/auth_machine_to_machine.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions proto/api/v1/auth_service.proto
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ message AuthMachineToMachineConfig {
GENERIC = 0;
GITHUB_ACTIONS = 1;
KUBE_SERVICE_ACCOUNT = 2;
KUBE_TOKEN_REVIEW = 3;
}
Type type = 2;

Expand Down
1 change: 1 addition & 0 deletions proto/storage/auth_machine_to_machine.proto
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ message AuthMachineToMachineConfig {
GENERIC = 0;
GITHUB_ACTIONS = 1;
KUBE_SERVICE_ACCOUNT = 2;
KUBE_TOKEN_REVIEW = 3;
}
Type type = 2;

Expand Down
Loading
Loading