Skip to content

Commit

Permalink
Clean up spike from #4317 for merge
Browse files Browse the repository at this point in the history
  • Loading branch information
evankanderson committed Jan 30, 2025
1 parent a8c5b09 commit cde854a
Show file tree
Hide file tree
Showing 19 changed files with 1,804 additions and 1,375 deletions.
2 changes: 1 addition & 1 deletion docs/docs/ref/proto.mdx

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

22 changes: 22 additions & 0 deletions internal/auth/context.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// SPDX-FileCopyrightText: Copyright 2023 The Minder Authors
// SPDX-License-Identifier: Apache-2.0

package auth

import "context"

type idContextKeyType struct{}

var idContextKey idContextKeyType

func WithIdentityContext(ctx context.Context, identity *Identity) context.Context {

Check failure on line 12 in internal/auth/context.go

View workflow job for this annotation

GitHub Actions / lint / Run golangci-lint

exported: exported function WithIdentityContext should have comment or be unexported (revive)
return context.WithValue(ctx, idContextKey, identity)
}

func IdentityFromContext(ctx context.Context) *Identity {

Check failure on line 16 in internal/auth/context.go

View workflow job for this annotation

GitHub Actions / lint / Run golangci-lint

exported: exported function IdentityFromContext should have comment or be unexported (revive)
id, ok := ctx.Value(idContextKey).(*Identity)
if !ok {
return nil
}
return id
}
121 changes: 121 additions & 0 deletions internal/auth/githubactions/githubactions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
//
// Copyright 2024 Stacklok, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Package githubactions provides an implementation of the GitHub IdentityProvider.
package githubactions

import (
"context"
"testing"

"github.com/lestrrat-go/jwx/v2/jwt"

"github.com/mindersec/minder/internal/auth"
)

func TestGitHubActions_Resolve(t *testing.T) {
t.Parallel()
tests := []struct {
name string
identity string
want *auth.Identity
}{{
name: "Resolve from storage",
identity: "repo+evankanderson/actions-id-token-testing+ref+refs/heads/main",
want: &auth.Identity{
HumanName: "repo:evankanderson/actions-id-token-testing:ref:refs/heads/main",
UserID: "repo+evankanderson/actions-id-token-testing+ref+refs/heads/main",
},
}, {
name: "Resolve from human input",
identity: "repo:evankanderson/actions-id-token-testing:ref:refs/heads/main",
want: &auth.Identity{
HumanName: "repo:evankanderson/actions-id-token-testing:ref:refs/heads/main",
UserID: "repo+evankanderson/actions-id-token-testing+ref+refs/heads/main",
},
}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
gha := &GitHubActions{}

got, err := gha.Resolve(context.Background(), tt.identity)
if err != nil {
t.Errorf("GitHubActions.Resolve() error = %v", err)
}

tt.want.Provider = gha
if tt.want.String() != got.String() {
t.Errorf("GitHubActions.Resolve() = %v, want %v", got.String(), tt.want.String())
}
if tt.want.Human() != got.Human() {
t.Errorf("GitHubActions.Resolve() = %v, want %v", got.Human(), tt.want.Human())
}
})
}
}

func TestGitHubActions_Validate(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input func() jwt.Token
want *auth.Identity
wantErr bool
}{{
name: "Validate token",
input: func() jwt.Token {
tok := jwt.New()
_ = tok.Set("iss", "https://token.actions.githubusercontent.com")
_ = tok.Set("sub", "repo:evankanderson/actions-id-token-testing:ref:refs/heads/main")
return tok
},
want: &auth.Identity{
HumanName: "repo:evankanderson/actions-id-token-testing:ref:refs/heads/main",
UserID: "repo+evankanderson/actions-id-token-testing+ref+refs/heads/main",
},
}, {
name: "Validate token with invalid issuer",
input: func() jwt.Token {
tok := jwt.New()
_ = tok.Set("iss", "https://issuer.minder.com/")
_ = tok.Set("sub", "repo:evankanderson/actions-id-token-testing:ref:refs/heads/main")
return tok
},
want: nil,
wantErr: true,
}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
gha := &GitHubActions{}
got, err := gha.Validate(context.Background(), tt.input())
if (err != nil) != tt.wantErr {
t.Errorf("GitHubActions.Validate() error = %v, wantErr %v", err, tt.wantErr)
return
}

if !tt.wantErr {
tt.want.Provider = gha
}
if tt.want.String() != got.String() {
t.Errorf("GitHubActions.Validate() = %v, want %v", got.String(), tt.want.String())
}
if tt.want.Human() != got.Human() {
t.Errorf("GitHubActions.Validate() = %v, want %v", got.Human(), tt.want.Human())
}
})
}
}
2 changes: 2 additions & 0 deletions internal/auth/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/puzpuzpuz/xsync/v3"
"github.com/rs/zerolog"
)

//go:generate go run go.uber.org/mock/mockgen -package mock_$GOPACKAGE -destination=./mock/$GOFILE -source=./$GOFILE
Expand Down Expand Up @@ -119,6 +120,7 @@ func NewIdentityClient(providers ...IdentityProvider) (*IdentityClient, error) {
}
for _, p := range providers {
u := p.URL() // URL's String has a pointer receiver
zerolog.Ctx(context.Background()).Debug().Str("provider", p.String()).Str("url", u.String()).Msg("Registering provider")

prev, ok := c.providers.LoadOrStore(p.String(), p)
if ok { // We had an existing value, this is a configuration error.
Expand Down
54 changes: 48 additions & 6 deletions internal/auth/jwt/dynamic/dynamic_fetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,17 @@ import (
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"

"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jws"
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/lestrrat-go/jwx/v2/jwt/openid"
"github.com/rs/zerolog"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/metric"

stacklok_jwt "github.com/mindersec/minder/internal/auth/jwt"
)
Expand All @@ -39,6 +44,10 @@ type openIdConfig struct {
JwksURI string `json:"jwks_uri"`
}

var cachedIssuers metric.Int64Counter
var dynamicAuths metric.Int64Counter
var metricsInit sync.Once

// Validator dynamically validates JWTs by fetching the key from the well-known OIDC issuer URL.
type Validator struct {
jwks *jwk.Cache
Expand All @@ -49,6 +58,24 @@ var _ stacklok_jwt.Validator = (*Validator)(nil)

// NewDynamicValidator creates a new instance of the dynamic JWT validator
func NewDynamicValidator(ctx context.Context, aud string) *Validator {
metricsInit.Do(func() {
meter := otel.Meter("minder")
var err error
cachedIssuers, err = meter.Int64Counter("dynamic_jwt.cached_issuers",
metric.WithDescription("Number of cached issuers for dynamic JWT validation"),
metric.WithUnit("count"),
)
if err != nil {
zerolog.Ctx(context.Background()).Warn().Err(err).Msg("Creating gauge for cached issuers failed")
}
dynamicAuths, err = meter.Int64Counter("dynamic_jwt.auths",
metric.WithDescription("Number of dynamic JWT authentications"),
metric.WithUnit("count"),
)
if err != nil {
zerolog.Ctx(context.Background()).Warn().Err(err).Msg("Creating gauge for dynamic JWT authentications failed")
}
})
return &Validator{
jwks: jwk.NewCache(ctx),
aud: aud,
Expand All @@ -57,6 +84,9 @@ func NewDynamicValidator(ctx context.Context, aud string) *Validator {

// ParseAndValidate implements jwt.Validator.
func (m Validator) ParseAndValidate(tokenString string) (openid.Token, error) {
if dynamicAuths != nil {
dynamicAuths.Add(context.Background(), 1)
}
// This is based on https://github.com/lestrrat-go/jwx/blob/v2/examples/jwt_parse_with_key_provider_example_test.go

_, b64payload, _, err := jws.SplitCompact([]byte(tokenString))
Expand All @@ -69,7 +99,8 @@ func (m Validator) ParseAndValidate(tokenString string) (openid.Token, error) {
return nil, fmt.Errorf("failed to decode JWT payload: %w", err)
}

parsed, err := jwt.Parse(jwtPayload, jwt.WithVerify(false), jwt.WithToken(openid.New()))
parsed, err := jwt.Parse(jwtPayload,
jwt.WithVerify(false), jwt.WithToken(openid.New()), jwt.WithAudience(m.aud))
if err != nil {
return nil, fmt.Errorf("failed to parse JWT payload: %w", err)
}
Expand All @@ -95,11 +126,22 @@ func (m Validator) getKeySet(issuer string) (jwk.Set, error) {
if err != nil {
return nil, fmt.Errorf("failed to fetch JWKS URL from openid: %w", err)
}
if err := m.jwks.Register(jwksUrl, jwk.WithMinRefreshInterval(15*time.Minute)); err != nil {
return nil, fmt.Errorf("failed to register JWKS URL: %w", err)
ret, err := m.jwks.Get(context.Background(), jwksUrl)
if err == nil {
return ret, err
}

return m.jwks.Get(context.Background(), jwksUrl)
// There's no nice way to check this error, which contains dynamic content. :-(
if strings.Contains(err.Error(), "is not registered") {
if cachedIssuers != nil {
cachedIssuers.Add(context.Background(), 1)
}
if err := m.jwks.Register(jwksUrl, jwk.WithMinRefreshInterval(15*time.Minute)); err != nil {
return nil, fmt.Errorf("failed to register JWKS URL: %w", err)
}

return m.jwks.Get(context.Background(), jwksUrl)
}
return nil, err
}

func getJWKSUrlForOpenId(issuer string) (string, error) {
Expand All @@ -117,7 +159,7 @@ func getJWKSUrlForOpenId(issuer string) (string, error) {

body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("Failed to read respons body: %w", err)
return "", fmt.Errorf("Failed to read response body: %w", err)
}

config := openIdConfig{}
Expand Down
Loading

0 comments on commit cde854a

Please sign in to comment.