From c8d2b6c5037b8988ebb4951ca68f0de5e1a9e1de Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Fri, 3 Apr 2026 16:36:14 -0400 Subject: [PATCH 01/10] Implement TokenSmith authentication backend and enhance JWT handling Signed-off-by: Alex Lovell-Troy --- cmd/smd/auth.go | 133 ++++++++++++++++++++++++++++----------- cmd/smd/main.go | 63 ++++++++++++++++--- cmd/smd/routers.go | 14 ++--- docs/authentication.adoc | 72 ++++++++++++++++++++- go.mod | 5 +- 5 files changed, 233 insertions(+), 54 deletions(-) diff --git a/cmd/smd/auth.go b/cmd/smd/auth.go index 004d48aa..b611878f 100644 --- a/cmd/smd/auth.go +++ b/cmd/smd/auth.go @@ -8,18 +8,94 @@ import ( "io" "net/http" "slices" + "strings" + "time" jwtauth "github.com/OpenCHAMI/jwtauth/v5" "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/openchami/tokensmith/pkg/authn" ) func (s *SmD) IsUsingAuthentication() bool { return s.jwksURL != "" } -func (s *SmD) VerifyClaims(testClaims []string, r *http.Request) (bool, error) { - // extract claims from JWT +func (s *SmD) UsingTokenSmithAuth() bool { + return s.IsUsingAuthentication() && s.authBackend == authBackendTokenSmith +} + +func parseCSVValues(raw string) []string { + if strings.TrimSpace(raw) == "" { + return nil + } + + values := strings.Split(raw, ",") + parsed := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + parsed = append(parsed, value) + } + + return parsed +} + +func (s *SmD) initializeAuth() error { + if s.UsingTokenSmithAuth() { + return s.initializeTokenSmithAuth() + } + + return s.fetchPublicKeyFromURL(s.jwksURL) +} + +func (s *SmD) initializeTokenSmithAuth() error { + if strings.TrimSpace(s.authIssuer) == "" { + return errors.New("auth-issuer is required when auth-backend=tokensmith") + } + if len(s.authAudiences) == 0 { + return errors.New("auth-audiences is required when auth-backend=tokensmith") + } + + client := newHTTPClient() + client.Timeout = 40 * time.Second + if _, err := fetchJWKSFromURL(s.jwksURL, client); err != nil { + return err + } + + mw, err := authn.Middleware(authn.Options{ + Issuers: []string{s.authIssuer}, + Audiences: append([]string(nil), s.authAudiences...), + JWKSURLs: []string{s.jwksURL}, + HTTPClient: client, + }) + if err != nil { + return fmt.Errorf("failed to initialize TokenSmith middleware: %w", err) + } + + s.authMiddleware = mw + return nil +} + +func (s *SmD) verifiedClaimsFromRequest(r *http.Request) (map[string]any, error) { + if s.UsingTokenSmithAuth() { + claims, ok := authn.VerifiedClaimsFromContext(r.Context()) + if !ok { + return nil, errors.New("verified claims not found in context") + } + return claims, nil + } + _, claims, err := jwtauth.FromContext(r.Context()) + if err != nil { + return nil, err + } + return claims, nil +} + +func (s *SmD) VerifyClaims(testClaims []string, r *http.Request) (bool, error) { + claims, err := s.verifiedClaimsFromRequest(r) if err != nil { return false, fmt.Errorf("failed to get claims(s) from token: %v", err) } @@ -35,12 +111,11 @@ func (s *SmD) VerifyClaims(testClaims []string, r *http.Request) (bool, error) { } func (s *SmD) VerifyScope(testScopes []string, r *http.Request) (bool, error) { - // extract the scopes from JWT - var scopes []string - _, claims, err := jwtauth.FromContext(r.Context()) + claims, err := s.verifiedClaimsFromRequest(r) if err != nil { return false, fmt.Errorf("failed to get claim(s) from token: %v", err) } + var scopes []string appendScopes := func(slice []string, scopeClaim any) []string { switch scopeClaim.(type) { @@ -54,39 +129,18 @@ func (s *SmD) VerifyScope(testScopes []string, r *http.Request) (bool, error) { } case []string: slice = append(slice, scopeClaim.([]string)...) + case string: + slice = append(slice, strings.Fields(scopeClaim.(string))...) } return slice } - v, ok := claims["scp"] - if ok { + if v, ok := claims["scp"]; ok { scopes = appendScopes(scopes, v) } - v, ok = claims["scope"] - if ok { + if v, ok := claims["scope"]; ok { scopes = appendScopes(scopes, v) } - // check for both 'scp' and 'scope' claims for scope - scopeClaim, ok := claims["scp"] - if ok { - switch scopeClaim.(type) { - case []any: - // convert all scopes to str and append - for _, s := range scopeClaim.([]any) { - switch s.(type) { - case string: - scopes = append(scopes, s.(string)) - } - } - case []string: - scopes = append(scopes, scopeClaim.([]string)...) - } - } - scopeClaim, ok = claims["scope"] - if ok { - scopes = append(scopes, scopeClaim.([]string)...) - } - // verify that each of the test scopes are included for _, testScope := range testScopes { index := slices.Index(scopes, testScope) @@ -115,9 +169,7 @@ func newHTTPClient() *http.Client { return &http.Client{Transport: &statusCheckTransport{}} } -func (s *SmD) fetchPublicKeyFromURL(url string) error { - client := newHTTPClient() - +func fetchJWKSFromURL(url string, client *http.Client) ([]byte, error) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() set, err := jwk.Fetch(ctx, url, jwk.WithHTTPClient(client)) @@ -130,11 +182,22 @@ func (s *SmD) fetchPublicKeyFromURL(url string) error { msg = "received empty response for key: %w" } - return fmt.Errorf(msg, err) + return nil, fmt.Errorf(msg, err) } jwks, err := json.Marshal(set) if err != nil { - return fmt.Errorf("failed to marshal JWKS: %v", err) + return nil, fmt.Errorf("failed to marshal JWKS: %v", err) + } + + return jwks, nil +} + +func (s *SmD) fetchPublicKeyFromURL(url string) error { + client := newHTTPClient() + + jwks, err := fetchJWKSFromURL(url, client) + if err != nil { + return err } s.tokenAuth, err = jwtauth.NewKeySet(jwks) if err != nil { diff --git a/cmd/smd/main.go b/cmd/smd/main.go index 4e8e927a..11c4dbbd 100644 --- a/cmd/smd/main.go +++ b/cmd/smd/main.go @@ -41,7 +41,6 @@ import ( "github.com/Cray-HPE/hms-certs/pkg/hms_certs" compcreds "github.com/Cray-HPE/hms-compcredentials" sstorage "github.com/Cray-HPE/hms-securestorage" - jwtauth "github.com/OpenCHAMI/jwtauth/v5" "github.com/OpenCHAMI/smd/v2/internal/hbtdapi" "github.com/OpenCHAMI/smd/v2/internal/hmsds" "github.com/OpenCHAMI/smd/v2/internal/pgmigrate" @@ -89,6 +88,11 @@ type Job struct { const httpListenDefault = ":27779" +const ( + authBackendLegacy = "legacy" + authBackendTokenSmith = "tokensmith" +) + type SmD struct { db hmsds.HMSDB dbDSN string @@ -172,9 +176,14 @@ type SmD struct { discMapLock sync.Mutex //router - router *chi.Mux - tokenAuth *jwtauth.JWTAuth - jwksURL string + router *chi.Mux + tokenAuth any + jwksURL string + authBackend string + authIssuer string + authAudiencesCSV string + authAudiences []string + authMiddleware func(http.Handler) http.Handler httpClient *retryablehttp.Client } @@ -594,6 +603,9 @@ func (s *SmD) parseCmdLine() { flag.StringVar(&s.dbPortStr, "dbport", "", "Database port") flag.StringVar(&s.dbOpts, "dbopts", "", "Database options string") flag.StringVar(&s.jwksURL, "jwks-url", "", "Set the JWKS URL to fetch public key for validation") + flag.StringVar(&s.authBackend, "auth-backend", authBackendLegacy, "Authentication backend to use with jwks-url: legacy or tokensmith") + flag.StringVar(&s.authIssuer, "auth-issuer", "", "Expected JWT issuer when auth-backend=tokensmith") + flag.StringVar(&s.authAudiencesCSV, "auth-audiences", "", "Comma-separated expected JWT audiences when auth-backend=tokensmith") flag.BoolVar(&applyMigrations, "migrate", false, "Apply all database migrations before starting") flag.BoolVar(&s.enableDiscovery, "enable-discovery", enableDiscoveryDefault, "Enable discovery-related subroutines") flag.BoolVar(&s.openchami, "openchami", OPENCHAMI_DEFAULT, "Enabled OpenCHAMI features") @@ -663,6 +675,35 @@ func (s *SmD) parseCmdLine() { s.jwksURL = val } } + envvar = "SMD_AUTH_BACKEND" + if s.authBackend == authBackendLegacy { + if val := os.Getenv(envvar); val != "" { + s.authBackend = val + } + } + envvar = "SMD_AUTH_ISSUER" + if s.authIssuer == "" { + if val := os.Getenv(envvar); val != "" { + s.authIssuer = val + } + } + envvar = "SMD_AUTH_AUDIENCES" + if s.authAudiencesCSV == "" { + if val := os.Getenv(envvar); val != "" { + s.authAudiencesCSV = val + } + } + + s.authBackend = strings.ToLower(strings.TrimSpace(s.authBackend)) + if s.authBackend == "" { + s.authBackend = authBackendLegacy + } + if s.authBackend != authBackendLegacy && s.authBackend != authBackendTokenSmith { + fmt.Printf("Bad auth-backend %q\n", s.authBackend) + flag.Usage() + os.Exit(1) + } + s.authAudiences = parseCSVValues(s.authAudiencesCSV) port, err := strconv.ParseInt(s.dbPortStr, 10, 64) if err != nil { @@ -981,19 +1022,23 @@ func main() { s.DiscoveryUpdater() } - // Initialize token authorization and load JWKS well-knowns from .well-known endpoint - if s.jwksURL != "" { - s.LogAlways("Fetching public key from server...") + // Initialize token authorization when a JWKS URL is configured. + if s.IsUsingAuthentication() { + s.LogAlways("Initializing authentication with backend %q...", s.authBackend) for i := 0; i <= 5; i++ { - err = s.fetchPublicKeyFromURL(s.jwksURL) + err = s.initializeAuth() if err != nil { s.LogAlways("failed to initialize auth token: %v", err) time.Sleep(5 * time.Second) continue } - s.LogAlways("Initialized the auth token successfully.") + s.LogAlways("Initialized authentication successfully.") break } + if err != nil { + s.LogAlways("Failed to initialize authentication after retries: %v", err) + os.Exit(1) + } } // Start serving HTTP diff --git a/cmd/smd/routers.go b/cmd/smd/routers.go index f9ad4d69..62818dc9 100644 --- a/cmd/smd/routers.go +++ b/cmd/smd/routers.go @@ -48,6 +48,10 @@ type Route struct { type Routes []Route func (s *SmD) NewRouter(publicRoutes []Route, protectedRoutes []Route) *chi.Mux { + // Setup logger + zerolog.TimeFieldFormat = zerolog.TimeFormatUnix + logger := zlog.Output(zerolog.ConsoleWriter{Out: os.Stderr}) + // create router and use recommended middleware router := chi.NewRouter() router.Use(middleware.RequestID) @@ -55,19 +59,13 @@ func (s *SmD) NewRouter(publicRoutes []Route, protectedRoutes []Route) *chi.Mux router.Use(middleware.Logger) router.Use(middleware.Recoverer) router.Use(middleware.StripSlashes) - if s.zerolog { - zerolog.TimeFieldFormat = zerolog.TimeFormatUnix - logger := zlog.Output(zerolog.ConsoleWriter{Out: os.Stderr}) - - router.Use(openchami_logger.OpenCHAMILogger(logger)) - } + router.Use(openchami_logger.OpenCHAMILogger(logger)) router.NotFound(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s.Logger(http.NotFoundHandler(), "NotFoundHandler") })) router.Use(middleware.Timeout(60 * time.Second)) - - if s.jwksURL != "" { + if s.IsUsingAuthentication() { router.Group(func(r chi.Router) { r.Use( jwtauth.Verifier(s.tokenAuth), diff --git a/docs/authentication.adoc b/docs/authentication.adoc index 5445bd79..98d15aa4 100644 --- a/docs/authentication.adoc +++ b/docs/authentication.adoc @@ -1,4 +1,74 @@ == Security and Authentication === Authentication Mechanisms -Placeholder. +SMD supports optional JWT authentication for protected routes. + +Authentication is enabled when `jwks-url` is configured. If `jwks-url` is unset, +all routes remain unauthenticated. + +When authentication is enabled, SMD supports two backends: + +* `legacy`: + Uses the existing `jwtauth` plus `chi-middleware/auth` stack. + This is the default backend. +* `tokensmith`: + Uses `github.com/openchami/tokensmith/pkg/authn` for JWT validation, + JWKS refresh, issuer checks, audience checks, and verified-claims context. + +=== Configuration + +The following flags and environment variables control authentication: + +* `-jwks-url` or `SMD_JWKS_URL`: + JWKS endpoint used for JWT validation. +* `-auth-backend` or `SMD_AUTH_BACKEND`: + Authentication backend to use with `jwks-url`. + Supported values are `legacy` and `tokensmith`. +* `-auth-issuer` or `SMD_AUTH_ISSUER`: + Required when `auth-backend=tokensmith`. + Expected JWT issuer. +* `-auth-audiences` or `SMD_AUTH_AUDIENCES`: + Required when `auth-backend=tokensmith`. + Comma-separated expected JWT audiences. + +These controls are applied at startup. Changing them requires restarting SMD. + +=== Runtime Controls + +At runtime, SMD authentication behavior is controlled only by the startup +configuration above. + +The current implementation does not support: + +* hot reloading of authentication settings +* switching auth backends without restarting the process +* a separate `auth-enabled` toggle independent of `jwks-url` +* request-time overrides of issuer, audience, or backend behavior + +Per request, clients only control the standard Bearer token they send in the +`Authorization` header. Public versus protected route behavior remains fixed by +the server's route registration. + +=== Route Behavior + +SMD continues to split routes into public and protected groups: + +* Public routes remain accessible without a token. +* Protected routes require a valid Bearer JWT when authentication is enabled. + +The current TokenSmith integration is AuthN-only. It validates tokens and places +verified claims in request context, but does not yet add route-level scope or +policy enforcement. + +=== Notes + +* The TokenSmith backend requires issuer and audience configuration in addition + to the JWKS URL. +* SMD validates the JWKS endpoint during startup initialization. +* Existing well-formed clients should not need to change request shape. They + still send `Authorization: Bearer ` for protected routes. +* Clients may see stricter `401 Unauthorized` responses after enabling the + TokenSmith backend if their tokens have issuer, audience, time-claim, or + JWKS/algorithm inconsistencies that were previously tolerated. +* Existing deployments using `jwks-url` can continue using the `legacy` + backend until they are ready to switch to `tokensmith`. diff --git a/go.mod b/go.mod index 0e09413e..c8c88ddd 100644 --- a/go.mod +++ b/go.mod @@ -26,7 +26,10 @@ require ( ) require ( + github.com/MicahParks/jwkset v0.11.0 // indirect + github.com/MicahParks/keyfunc/v3 v3.8.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/confluentinc/confluent-kafka-go/v2 v2.10.0 // indirect @@ -64,7 +67,7 @@ require ( github.com/hashicorp/vault/api v1.16.0 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect From 5954738682e052932cac837e5c2588e751bc3660 Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Fri, 3 Apr 2026 16:45:01 -0400 Subject: [PATCH 02/10] Refactor authentication scope verification and update dependencies Signed-off-by: Alex Lovell-Troy --- cmd/smd/auth.go | 12 +++++------ cmd/smd/routers.go | 13 ++++++++---- go.mod | 13 ++++++++---- go.sum | 53 ++++++++++++++++++++++++++++++++++------------ 4 files changed, 63 insertions(+), 28 deletions(-) diff --git a/cmd/smd/auth.go b/cmd/smd/auth.go index b611878f..40ffe67d 100644 --- a/cmd/smd/auth.go +++ b/cmd/smd/auth.go @@ -118,19 +118,19 @@ func (s *SmD) VerifyScope(testScopes []string, r *http.Request) (bool, error) { var scopes []string appendScopes := func(slice []string, scopeClaim any) []string { - switch scopeClaim.(type) { + switch typedScopeClaim := scopeClaim.(type) { case []any: // convert all scopes to str and append - for _, s := range scopeClaim.([]any) { - switch s.(type) { + for _, s := range typedScopeClaim { + switch typedScope := s.(type) { case string: - slice = append(slice, s.(string)) + slice = append(slice, typedScope) } } case []string: - slice = append(slice, scopeClaim.([]string)...) + slice = append(slice, typedScopeClaim...) case string: - slice = append(slice, strings.Fields(scopeClaim.(string))...) + slice = append(slice, strings.Fields(typedScopeClaim)...) } return slice } diff --git a/cmd/smd/routers.go b/cmd/smd/routers.go index 62818dc9..44d0233f 100644 --- a/cmd/smd/routers.go +++ b/cmd/smd/routers.go @@ -67,10 +67,15 @@ func (s *SmD) NewRouter(publicRoutes []Route, protectedRoutes []Route) *chi.Mux router.Use(middleware.Timeout(60 * time.Second)) if s.IsUsingAuthentication() { router.Group(func(r chi.Router) { - r.Use( - jwtauth.Verifier(s.tokenAuth), - openchami_authenticator.AuthenticatorWithRequiredClaims(s.tokenAuth, []string{"sub", "iss", "aud"}), - ) + if s.UsingTokenSmithAuth() { + r.Use(s.authMiddleware) + } else { + tokenAuth := s.tokenAuth.(*jwtauth.JWTAuth) + r.Use( + jwtauth.Verifier(tokenAuth), + openchami_authenticator.AuthenticatorWithRequiredClaims(tokenAuth, []string{"sub", "iss", "aud"}), + ) + } // Register protected routes for _, route := range protectedRoutes { diff --git a/go.mod b/go.mod index c8c88ddd..00a9a0f5 100644 --- a/go.mod +++ b/go.mod @@ -12,16 +12,18 @@ require ( github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/Masterminds/squirrel v1.5.4 github.com/OpenCHAMI/jwtauth/v5 v5.0.0-20240321222802-e6cb468a2a18 - github.com/go-chi/chi/v5 v5.1.0 + github.com/go-chi/chi/v5 v5.2.3 github.com/golang-migrate/migrate/v4 v4.18.2 github.com/google/uuid v1.6.0 github.com/hashicorp/go-retryablehttp v0.7.7 + github.com/invopop/jsonschema v0.13.0 github.com/lestrrat-go/jwx/v2 v2.1.1 github.com/lib/pq v1.10.9 github.com/openchami/chi-middleware/auth v0.0.0-20240812224658-b16b83c70700 github.com/openchami/chi-middleware/log v0.0.0-20240812224658-b16b83c70700 github.com/openchami/schemas v0.0.0-20250625220233-9aad17a286c4 - github.com/rs/zerolog v1.33.0 + github.com/openchami/tokensmith v0.0.2 + github.com/rs/zerolog v1.34.0 github.com/sirupsen/logrus v1.9.3 ) @@ -31,13 +33,16 @@ require ( github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect github.com/buger/jsonparser v1.1.1 // indirect + github.com/casbin/casbin/v2 v2.135.0 // indirect + github.com/casbin/govaluate v1.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/confluentinc/confluent-kafka-go/v2 v2.10.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-jose/go-jose/v4 v4.1.0 // indirect github.com/goccy/go-json v0.10.3 // indirect - github.com/invopop/jsonschema v0.13.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/lestrrat-go/blackmagic v1.0.2 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/httprc v1.0.6 // indirect @@ -76,7 +81,7 @@ require ( go.uber.org/atomic v1.11.0 // indirect golang.org/x/crypto v0.37.0 // indirect golang.org/x/net v0.39.0 // indirect - golang.org/x/sys v0.32.0 // indirect + golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.24.0 // indirect golang.org/x/time v0.11.0 // indirect ) diff --git a/go.sum b/go.sum index 376581dd..6d64470b 100644 --- a/go.sum +++ b/go.sum @@ -26,6 +26,10 @@ github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0 github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= +github.com/MicahParks/jwkset v0.11.0 h1:yc0zG+jCvZpWgFDFmvs8/8jqqVBG9oyIbmBtmjOhoyQ= +github.com/MicahParks/jwkset v0.11.0/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0= +github.com/MicahParks/keyfunc/v3 v3.8.0 h1:Hx2dgIjAXGk9slakM6rV9BOeaWDPEXXZ4Us8guNBfds= +github.com/MicahParks/keyfunc/v3 v3.8.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/hcsshim v0.11.5 h1:haEcLNpj9Ka1gd3B3tAEs9CpE0c+1IhoL59w/exYU38= @@ -64,10 +68,16 @@ github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPn github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I= +github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/buger/goterm v1.0.4 h1:Z9YvGmOih81P0FbVtEYTFF6YsSgxSUKEhf/f9bTMXbY= github.com/buger/goterm v1.0.4/go.mod h1:HiFWV3xnkolgrBV3mY8m0X0Pumt4zg4QhbdOzQtB8tE= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk= +github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18= +github.com/casbin/govaluate v1.3.0 h1:VA0eSY0M2lA86dYd5kPPuNZMUD9QkWnOCnavGrw9myc= +github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= @@ -139,8 +149,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fvbommel/sortorder v1.0.2 h1:mV4o8B2hKboCdkJm+a7uX/SIpZob4JzUpc5GGnM45eo= github.com/fvbommel/sortorder v1.0.2/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0= -github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= -github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= +github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY= github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= @@ -168,8 +178,12 @@ github.com/gogo/googleapis v1.4.1 h1:1Yx4Myt7BxzvUr5ldGSbwYiZG6t9wGBZ+8/fX3Wvtq0 github.com/gogo/googleapis v1.4.1/go.mod h1:2lpHqI5OcWCtVElxXnPt+s8oJvMpySlOyM6xDCrzib4= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8= github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk= +github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= @@ -211,6 +225,8 @@ github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9 github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.1-vault-5 h1:kI3hhbbyzr4dldA8UdTb7ZlVVlI2DACdCfz31RPDgJM= github.com/hashicorp/hcl v1.0.1-vault-5/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= github.com/hashicorp/vault/api v1.16.0 h1:nbEYGJiAPGzT9U4oWgaaB0g+Rj8E59QuHKyA5LhwQN4= @@ -258,8 +274,9 @@ github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0V github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -318,6 +335,8 @@ github.com/openchami/chi-middleware/log v0.0.0-20240812224658-b16b83c70700 h1:Gz github.com/openchami/chi-middleware/log v0.0.0-20240812224658-b16b83c70700/go.mod h1:UuXvr2loD4MtvZeKr57W0WpBs+gm0KM1kdtcXrE8M6s= github.com/openchami/schemas v0.0.0-20250625220233-9aad17a286c4 h1:89rudSw0TeedlHbGr5L9WEW9lJ3yMEtY2EgxoC7EGso= github.com/openchami/schemas v0.0.0-20250625220233-9aad17a286c4/go.mod h1:3dridLqXvAdO0ypPXuxnXRgaK2h/dItVKGseCgFQ13k= +github.com/openchami/tokensmith v0.0.2 h1:Nh/6X/0KPcAD6Hb9xmSh64ktDzlDYHCmU+s7C+qG/iU= +github.com/openchami/tokensmith v0.0.2/go.mod h1:L4ZCMX/vPGwXUUn9otw+UdfFTbarv+ZVO/FjhZmoOAE= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= @@ -343,9 +362,9 @@ github.com/r3labs/sse v0.0.0-20210224172625-26fe804710bc h1:zAsgcP8MhzAbhMnB1QQ2 github.com/r3labs/sse v0.0.0-20210224172625-26fe804710bc/go.mod h1:S8xSOnV3CgpNrWd0GQ/OoQfMtlg2uPRSuTzcSGrzwK8= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= -github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= -github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/secure-systems-lab/go-securesystemslib v0.4.0 h1:b23VGrQhTA8cN2CbBw7/FulN9fTtqYUdS5+Oxzt+DUE= @@ -364,17 +383,17 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= -github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= -github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw= github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8= github.com/testcontainers/testcontainers-go/modules/compose v0.33.0 h1:PyrUOF+zG+xrS3p+FesyVxMI+9U+7pwhZhyFozH3jKY= @@ -437,28 +456,34 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3 h1:hNQpMuAJe5CtcUqCXaWga3FHu+kQvCqcsoVaQgSV60o= golang.org/x/exp v0.0.0-20240112132812-db7319d0e0e3/go.mod h1:idGWGoKP1toJGkd5/ig9ZLuPcZBC3ewk7SzmH0uou08= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI= golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/genproto v0.0.0-20240325203815-454cdb8f5daa h1:ePqxpG3LVx+feAUOx8YmR5T7rc0rdzK8DyxM8cQ9zq0= From 69a13d9f7b632a09b6e226fb20a379f21ab7c95b Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Fri, 3 Apr 2026 17:13:24 -0400 Subject: [PATCH 03/10] Implement legacy authentication support and enhance logging for auth rejections Signed-off-by: Alex Lovell-Troy --- cmd/smd/auth.go | 198 ++++++++++++++++-- cmd/smd/auth_test.go | 436 +++++++++++++++++++++++++++++++++++++++ cmd/smd/main.go | 17 +- cmd/smd/routers.go | 13 +- docs/authentication.adoc | 7 + 5 files changed, 631 insertions(+), 40 deletions(-) create mode 100644 cmd/smd/auth_test.go diff --git a/cmd/smd/auth.go b/cmd/smd/auth.go index 40ffe67d..3f63bd42 100644 --- a/cmd/smd/auth.go +++ b/cmd/smd/auth.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "context" "encoding/json" "errors" @@ -13,9 +14,12 @@ import ( jwtauth "github.com/OpenCHAMI/jwtauth/v5" "github.com/lestrrat-go/jwx/v2/jwk" + openchami_authenticator "github.com/openchami/chi-middleware/auth" "github.com/openchami/tokensmith/pkg/authn" ) +var requiredLegacyClaims = []string{"sub", "iss", "aud"} + func (s *SmD) IsUsingAuthentication() bool { return s.jwksURL != "" } @@ -43,11 +47,29 @@ func parseCSVValues(raw string) []string { } func (s *SmD) initializeAuth() error { + s.clearAuthState() + if s.UsingTokenSmithAuth() { return s.initializeTokenSmithAuth() } - return s.fetchPublicKeyFromURL(s.jwksURL) + return s.initializeLegacyAuth() +} + +func (s *SmD) clearAuthState() { + s.legacyTokenAuth = nil + s.protectedAuthMiddleware = nil +} + +func (s *SmD) initializeLegacyAuth() error { + tokenAuth, err := fetchLegacyTokenAuthFromURL(s.jwksURL) + if err != nil { + return err + } + + s.legacyTokenAuth = tokenAuth + s.protectedAuthMiddleware = s.withAuthRejectionLogging(authBackendLegacy, buildLegacyProtectedAuthMiddleware(tokenAuth)) + return nil } func (s *SmD) initializeTokenSmithAuth() error { @@ -74,10 +96,140 @@ func (s *SmD) initializeTokenSmithAuth() error { return fmt.Errorf("failed to initialize TokenSmith middleware: %w", err) } - s.authMiddleware = mw + s.protectedAuthMiddleware = s.withAuthRejectionLogging(authBackendTokenSmith, mw) return nil } +func (s *SmD) ProtectedAuthMiddleware() func(http.Handler) http.Handler { + if !s.IsUsingAuthentication() { + return nil + } + if s.protectedAuthMiddleware != nil { + return s.protectedAuthMiddleware + } + + return unavailableAuthMiddleware() +} + +func unavailableAuthMiddleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sendJsonError(w, http.StatusServiceUnavailable, "authentication is enabled but not initialized") + }) + } +} + +func buildLegacyProtectedAuthMiddleware(tokenAuth *jwtauth.JWTAuth) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return jwtauth.Verifier(tokenAuth)( + openchami_authenticator.AuthenticatorWithRequiredClaims(tokenAuth, requiredLegacyClaims)(next), + ) + } +} + +func (s *SmD) withAuthRejectionLogging(backend string, base func(http.Handler) http.Handler) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + handler := base(next) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + recorder := &authStatusRecorder{ResponseWriter: w} + handler.ServeHTTP(recorder, r) + s.logAuthRejection(backend, r, recorder) + }) + } +} + +func (s *SmD) logAuthRejection(backend string, r *http.Request, recorder *authStatusRecorder) { + statusCode := recorder.StatusCode() + if statusCode != http.StatusUnauthorized && statusCode != http.StatusForbidden { + return + } + if s == nil || s.lg == nil { + return + } + + authHeaderState, authHeaderScheme := describeAuthorizationHeader(r.Header.Get("Authorization")) + message := fmt.Sprintf( + "Auth rejection backend=%s status=%d method=%s path=%s remote=%s auth_header=%s auth_scheme=%s", + backend, + statusCode, + r.Method, + r.URL.Path, + r.RemoteAddr, + authHeaderState, + authHeaderScheme, + ) + + if backend == authBackendTokenSmith { + message += fmt.Sprintf(" expected_issuer=%q expected_audiences=%q", s.authIssuer, strings.Join(s.authAudiences, ",")) + } + if challenge := compactWhitespace(recorder.Header().Get("WWW-Authenticate")); challenge != "" { + message += fmt.Sprintf(" www_authenticate=%q", challenge) + } + if detail := recorder.BodySnippet(); detail != "" { + message += fmt.Sprintf(" detail=%q", detail) + } + + s.LogAlways("%s", message) +} + +func describeAuthorizationHeader(header string) (state string, scheme string) { + trimmed := strings.TrimSpace(header) + if trimmed == "" { + return "missing", "none" + } + + fields := strings.Fields(trimmed) + if len(fields) == 0 { + return "present", "unknown" + } + + return "present", strings.ToLower(fields[0]) +} + +func compactWhitespace(value string) string { + return strings.Join(strings.Fields(value), " ") +} + +type authStatusRecorder struct { + http.ResponseWriter + statusCode int + body bytes.Buffer +} + +func (w *authStatusRecorder) WriteHeader(statusCode int) { + w.statusCode = statusCode + w.ResponseWriter.WriteHeader(statusCode) +} + +func (w *authStatusRecorder) Write(body []byte) (int, error) { + if w.statusCode == 0 { + w.statusCode = http.StatusOK + } + + remaining := 256 - w.body.Len() + if remaining > 0 { + if len(body) > remaining { + _, _ = w.body.Write(body[:remaining]) + } else { + _, _ = w.body.Write(body) + } + } + + return w.ResponseWriter.Write(body) +} + +func (w *authStatusRecorder) StatusCode() int { + if w.statusCode == 0 { + return http.StatusOK + } + + return w.statusCode +} + +func (w *authStatusRecorder) BodySnippet() string { + return compactWhitespace(w.body.String()) +} + func (s *SmD) verifiedClaimsFromRequest(r *http.Request) (map[string]any, error) { if s.UsingTokenSmithAuth() { claims, ok := authn.VerifiedClaimsFromContext(r.Context()) @@ -115,15 +267,25 @@ func (s *SmD) VerifyScope(testScopes []string, r *http.Request) (bool, error) { if err != nil { return false, fmt.Errorf("failed to get claim(s) from token: %v", err) } - var scopes []string + scopes := extractScopesFromClaims(claims) + // verify that each of the test scopes are included + for _, testScope := range testScopes { + index := slices.Index(scopes, testScope) + if index < 0 { + return false, fmt.Errorf("invalid or missing scope") + } + } + // NOTE: should this be ok if no scopes were found? + return true, nil +} + +func extractScopesFromClaims(claims map[string]any) []string { appendScopes := func(slice []string, scopeClaim any) []string { switch typedScopeClaim := scopeClaim.(type) { case []any: - // convert all scopes to str and append - for _, s := range typedScopeClaim { - switch typedScope := s.(type) { - case string: + for _, scope := range typedScopeClaim { + if typedScope, ok := scope.(string); ok { slice = append(slice, typedScope) } } @@ -134,6 +296,8 @@ func (s *SmD) VerifyScope(testScopes []string, r *http.Request) (bool, error) { } return slice } + + var scopes []string if v, ok := claims["scp"]; ok { scopes = appendScopes(scopes, v) } @@ -141,15 +305,7 @@ func (s *SmD) VerifyScope(testScopes []string, r *http.Request) (bool, error) { scopes = appendScopes(scopes, v) } - // verify that each of the test scopes are included - for _, testScope := range testScopes { - index := slices.Index(scopes, testScope) - if index < 0 { - return false, fmt.Errorf("invalid or missing scope") - } - } - // NOTE: should this be ok if no scopes were found? - return true, nil + return scopes } type statusCheckTransport struct { @@ -192,17 +348,17 @@ func fetchJWKSFromURL(url string, client *http.Client) ([]byte, error) { return jwks, nil } -func (s *SmD) fetchPublicKeyFromURL(url string) error { +func fetchLegacyTokenAuthFromURL(url string) (*jwtauth.JWTAuth, error) { client := newHTTPClient() jwks, err := fetchJWKSFromURL(url, client) if err != nil { - return err + return nil, err } - s.tokenAuth, err = jwtauth.NewKeySet(jwks) + tokenAuth, err := jwtauth.NewKeySet(jwks) if err != nil { - return fmt.Errorf("failed to initialize JWKS: %v", err) + return nil, fmt.Errorf("failed to initialize JWKS: %v", err) } - return nil + return tokenAuth, nil } diff --git a/cmd/smd/auth_test.go b/cmd/smd/auth_test.go new file mode 100644 index 00000000..157f8bba --- /dev/null +++ b/cmd/smd/auth_test.go @@ -0,0 +1,436 @@ +// MIT License +// +// (C) Copyright [2026] Hewlett Packard Enterprise Development LP +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL +// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR +// OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +// ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. + +package main + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "log" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + jwtauth "github.com/OpenCHAMI/jwtauth/v5" + jwtv5 "github.com/golang-jwt/jwt/v5" + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/openchami/tokensmith/pkg/authn" +) + +func TestInitializeAuthClearsStaleStateOnError(t *testing.T) { + s := &SmD{ + jwksURL: "https://example.com/jwks.json", + authBackend: authBackendTokenSmith, + protectedAuthMiddleware: func(next http.Handler) http.Handler { return next }, + legacyTokenAuth: jwtauth.New("HS256", []byte("secret"), nil), + } + + err := s.initializeAuth() + if err == nil { + t.Fatal("expected initializeAuth to fail without TokenSmith issuer and audiences") + } + if s.protectedAuthMiddleware != nil { + t.Fatal("expected initializeAuth to clear stale protected auth middleware on failure") + } + if s.legacyTokenAuth != nil { + t.Fatal("expected initializeAuth to clear stale legacy auth state on failure") + } +} + +func TestProtectedAuthMiddlewareFallsClosed(t *testing.T) { + s := &SmD{jwksURL: "https://example.com/jwks.json"} + middleware := s.ProtectedAuthMiddleware() + if middleware == nil { + t.Fatal("expected protected auth middleware when authentication is enabled") + } + + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + recorder := httptest.NewRecorder() + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + handler.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusServiceUnavailable { + t.Fatalf("expected status %d, got %d", http.StatusServiceUnavailable, recorder.Code) + } +} + +func TestBuildLegacyProtectedAuthMiddlewareRejectsMissingToken(t *testing.T) { + tokenAuth := jwtauth.New("HS256", []byte("secret"), nil) + middleware := buildLegacyProtectedAuthMiddleware(tokenAuth) + + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + recorder := httptest.NewRecorder() + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + handler.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusUnauthorized { + t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, recorder.Code) + } +} + +func TestExtractScopesFromClaims(t *testing.T) { + claims := map[string]any{ + "scp": []any{"alpha", 42, "beta"}, + "scope": "gamma delta", + } + + scopes := extractScopesFromClaims(claims) + expected := []string{"alpha", "beta", "gamma", "delta"} + if len(scopes) != len(expected) { + t.Fatalf("expected %d scopes, got %d: %v", len(expected), len(scopes), scopes) + } + for index, scope := range expected { + if scopes[index] != scope { + t.Fatalf("expected scope %q at index %d, got %q", scope, index, scopes[index]) + } + } +} + +func TestVerifyClaimsUsesLegacyContext(t *testing.T) { + s := &SmD{} + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + token := jwt.New() + if err := token.Set("sub", "node-1"); err != nil { + t.Fatalf("failed to set sub claim: %v", err) + } + if err := token.Set("iss", "issuer"); err != nil { + t.Fatalf("failed to set iss claim: %v", err) + } + req = req.WithContext(context.WithValue(req.Context(), jwtauth.TokenCtxKey, token)) + + ok, err := s.VerifyClaims([]string{"sub", "iss"}, req) + if err != nil { + t.Fatalf("expected VerifyClaims to succeed, got error: %v", err) + } + if !ok { + t.Fatal("expected VerifyClaims to return true") + } +} + +func TestVerifyScopeSupportsMixedScopeClaims(t *testing.T) { + s := &SmD{} + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + token := jwt.New() + if err := token.Set("scp", []string{"alpha", "beta"}); err != nil { + t.Fatalf("failed to set scp claim: %v", err) + } + if err := token.Set("scope", "gamma delta"); err != nil { + t.Fatalf("failed to set scope claim: %v", err) + } + req = req.WithContext(context.WithValue(req.Context(), jwtauth.TokenCtxKey, token)) + + ok, err := s.VerifyScope([]string{"alpha", "delta"}, req) + if err != nil { + t.Fatalf("expected VerifyScope to succeed, got error: %v", err) + } + if !ok { + t.Fatal("expected VerifyScope to return true") + } +} + +func TestTokenSmithAuthRejectionLoggingIncludesClearContext(t *testing.T) { + var logBuffer bytes.Buffer + s := &SmD{ + lg: log.New(&logBuffer, "", 0), + authIssuer: "https://issuer.example.com", + authAudiences: []string{"smd", "admin"}, + } + middleware := s.withAuthRejectionLogging(authBackendTokenSmith, func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`) + http.Error(w, "issuer mismatch", http.StatusUnauthorized) + }) + }) + + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + req.RemoteAddr = "10.2.3.4:12345" + req.Header.Set("Authorization", "Bearer redacted-token") + recorder := httptest.NewRecorder() + + middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })).ServeHTTP(recorder, req) + + output := logBuffer.String() + checks := []string{ + "Auth rejection backend=tokensmith status=401", + "method=GET", + "path=/protected", + "remote=10.2.3.4:12345", + "auth_header=present", + "auth_scheme=bearer", + `expected_issuer="https://issuer.example.com"`, + `expected_audiences="smd,admin"`, + `www_authenticate="Bearer error=\"invalid_token\""`, + `detail="issuer mismatch"`, + } + for _, check := range checks { + if !strings.Contains(output, check) { + t.Fatalf("expected log output to contain %q, got %q", check, output) + } + } + if strings.Contains(output, "redacted-token") { + t.Fatalf("expected auth rejection log not to contain bearer token, got %q", output) + } +} + +func TestTokenSmithAuthRejectionLoggingSkipsSuccessfulRequests(t *testing.T) { + var logBuffer bytes.Buffer + s := &SmD{lg: log.New(&logBuffer, "", 0)} + middleware := s.withAuthRejectionLogging(authBackendTokenSmith, func(next http.Handler) http.Handler { + return next + }) + + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + recorder := httptest.NewRecorder() + middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })).ServeHTTP(recorder, req) + + if output := logBuffer.String(); output != "" { + t.Fatalf("expected no auth rejection log for successful request, got %q", output) + } +} + +func TestTokenSmithJWKSBackedRejectedTokenLogsClearly(t *testing.T) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("failed to generate RSA key: %v", err) + } + + kid := "test-key-1" + jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(smdTestJWKSJSON(t, kid, &privateKey.PublicKey)) + })) + defer jwksServer.Close() + + var logBuffer bytes.Buffer + s := &SmD{ + lg: log.New(&logBuffer, "", 0), + jwksURL: jwksServer.URL, + authBackend: authBackendTokenSmith, + authIssuer: "https://issuer.example.com", + authAudiences: []string{"smd"}, + } + + if err := s.initializeTokenSmithAuth(); err != nil { + t.Fatalf("failed to initialize TokenSmith auth: %v", err) + } + + token := jwtv5.NewWithClaims(jwtv5.SigningMethodRS256, jwtv5.MapClaims{ + "sub": "node-1", + "iss": "https://wrong-issuer.example.com", + "aud": []string{"smd"}, + "iat": time.Now().Add(-time.Minute).Unix(), + "nbf": time.Now().Add(-time.Minute).Unix(), + "exp": time.Now().Add(time.Minute).Unix(), + }) + token.Header["kid"] = kid + signedToken, err := token.SignedString(privateKey) + if err != nil { + t.Fatalf("failed to sign JWT: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + req.RemoteAddr = "10.20.30.40:12345" + req.Header.Set("Authorization", "Bearer "+signedToken) + recorder := httptest.NewRecorder() + + handler := s.ProtectedAuthMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + handler.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusUnauthorized { + t.Fatalf("expected status %d, got %d with body %q", http.StatusUnauthorized, recorder.Code, recorder.Body.String()) + } + + output := logBuffer.String() + checks := []string{ + "Auth rejection backend=tokensmith status=401", + "method=GET", + "path=/protected", + "remote=10.20.30.40:12345", + "auth_header=present", + "auth_scheme=bearer", + `expected_issuer="https://issuer.example.com"`, + `expected_audiences="smd"`, + } + for _, check := range checks { + if !strings.Contains(output, check) { + t.Fatalf("expected log output to contain %q, got %q", check, output) + } + } + if strings.Contains(output, signedToken) { + t.Fatalf("expected log output not to include bearer token, got %q", output) + } +} + +func TestTokenSmithJWKSBackedAcceptedTokenSetsClaimsAndSkipsRejectionLog(t *testing.T) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("failed to generate RSA key: %v", err) + } + + kid := "test-key-2" + jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(smdTestJWKSJSON(t, kid, &privateKey.PublicKey)) + })) + defer jwksServer.Close() + + var logBuffer bytes.Buffer + s := &SmD{ + lg: log.New(&logBuffer, "", 0), + jwksURL: jwksServer.URL, + authBackend: authBackendTokenSmith, + authIssuer: "https://issuer.example.com", + authAudiences: []string{"smd"}, + } + + if err := s.initializeTokenSmithAuth(); err != nil { + t.Fatalf("failed to initialize TokenSmith auth: %v", err) + } + + token := jwtv5.NewWithClaims(jwtv5.SigningMethodRS256, jwtv5.MapClaims{ + "sub": "node-1", + "iss": "https://issuer.example.com", + "aud": []string{"smd"}, + "iat": time.Now().Add(-time.Minute).Unix(), + "nbf": time.Now().Add(-time.Minute).Unix(), + "exp": time.Now().Add(time.Minute).Unix(), + }) + token.Header["kid"] = kid + signedToken, err := token.SignedString(privateKey) + if err != nil { + t.Fatalf("failed to sign JWT: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + req.RemoteAddr = "10.20.30.41:12345" + req.Header.Set("Authorization", "Bearer "+signedToken) + recorder := httptest.NewRecorder() + + handlerCalled := false + handler := s.ProtectedAuthMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handlerCalled = true + + claims, ok := authn.VerifiedClaimsFromContext(r.Context()) + if !ok { + t.Fatal("expected verified claims in TokenSmith request context") + } + if claims["sub"] != "node-1" { + t.Fatalf("expected subject claim node-1, got %#v", claims["sub"]) + } + + verified, err := s.VerifyClaims([]string{"sub", "iss", "aud"}, r) + if err != nil { + t.Fatalf("expected VerifyClaims to succeed, got error: %v", err) + } + if !verified { + t.Fatal("expected VerifyClaims to return true") + } + + w.WriteHeader(http.StatusNoContent) + })) + handler.ServeHTTP(recorder, req) + + if !handlerCalled { + t.Fatal("expected protected handler to be called for valid TokenSmith JWT") + } + if recorder.Code != http.StatusNoContent { + t.Fatalf("expected status %d, got %d with body %q", http.StatusNoContent, recorder.Code, recorder.Body.String()) + } + if output := logBuffer.String(); output != "" { + t.Fatalf("expected no auth rejection log for valid TokenSmith JWT, got %q", output) + } +} + +func smdTestJWKSJSON(t *testing.T, kid string, pub *rsa.PublicKey) []byte { + t.Helper() + + n := base64.RawURLEncoding.EncodeToString(pub.N.Bytes()) + e := base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pub.E)).Bytes()) + + obj := map[string]any{ + "keys": []any{ + map[string]any{ + "kty": "RSA", + "kid": kid, + "alg": "RS256", + "use": "sig", + "n": n, + "e": e, + }, + }, + } + b, err := json.Marshal(obj) + if err != nil { + t.Fatalf("failed to marshal JWKS: %v", err) + } + return b +} + +func TestNewRouterUsesProtectedAuthMiddleware(t *testing.T) { + called := false + s := &SmD{ + jwksURL: "https://example.com/jwks.json", + protectedAuthMiddleware: func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + next.ServeHTTP(w, r) + }) + }, + } + + router := s.NewRouter(nil, Routes{{ + Name: "protected", + Method: http.MethodGet, + Pattern: "/protected", + HandlerFunc: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }, + }}) + + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, req) + + if !called { + t.Fatal("expected protected auth middleware to wrap protected routes") + } + if recorder.Code != http.StatusNoContent { + t.Fatalf("expected status %d, got %d", http.StatusNoContent, recorder.Code) + } +} diff --git a/cmd/smd/main.go b/cmd/smd/main.go index 11c4dbbd..6b0b4c7f 100644 --- a/cmd/smd/main.go +++ b/cmd/smd/main.go @@ -41,6 +41,7 @@ import ( "github.com/Cray-HPE/hms-certs/pkg/hms_certs" compcreds "github.com/Cray-HPE/hms-compcredentials" sstorage "github.com/Cray-HPE/hms-securestorage" + jwtauth "github.com/OpenCHAMI/jwtauth/v5" "github.com/OpenCHAMI/smd/v2/internal/hbtdapi" "github.com/OpenCHAMI/smd/v2/internal/hmsds" "github.com/OpenCHAMI/smd/v2/internal/pgmigrate" @@ -176,14 +177,14 @@ type SmD struct { discMapLock sync.Mutex //router - router *chi.Mux - tokenAuth any - jwksURL string - authBackend string - authIssuer string - authAudiencesCSV string - authAudiences []string - authMiddleware func(http.Handler) http.Handler + router *chi.Mux + legacyTokenAuth *jwtauth.JWTAuth + jwksURL string + authBackend string + authIssuer string + authAudiencesCSV string + authAudiences []string + protectedAuthMiddleware func(http.Handler) http.Handler httpClient *retryablehttp.Client } diff --git a/cmd/smd/routers.go b/cmd/smd/routers.go index 44d0233f..529c34f8 100644 --- a/cmd/smd/routers.go +++ b/cmd/smd/routers.go @@ -28,11 +28,9 @@ import ( "strings" "time" - jwtauth "github.com/OpenCHAMI/jwtauth/v5" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/gorilla/handlers" - openchami_authenticator "github.com/openchami/chi-middleware/auth" openchami_logger "github.com/openchami/chi-middleware/log" "github.com/rs/zerolog" zlog "github.com/rs/zerolog/log" @@ -66,16 +64,9 @@ func (s *SmD) NewRouter(publicRoutes []Route, protectedRoutes []Route) *chi.Mux router.Use(middleware.Timeout(60 * time.Second)) if s.IsUsingAuthentication() { + protectedAuthMiddleware := s.ProtectedAuthMiddleware() router.Group(func(r chi.Router) { - if s.UsingTokenSmithAuth() { - r.Use(s.authMiddleware) - } else { - tokenAuth := s.tokenAuth.(*jwtauth.JWTAuth) - r.Use( - jwtauth.Verifier(tokenAuth), - openchami_authenticator.AuthenticatorWithRequiredClaims(tokenAuth, []string{"sub", "iss", "aud"}), - ) - } + r.Use(protectedAuthMiddleware) // Register protected routes for _, route := range protectedRoutes { diff --git a/docs/authentication.adoc b/docs/authentication.adoc index 98d15aa4..c391d559 100644 --- a/docs/authentication.adoc +++ b/docs/authentication.adoc @@ -65,6 +65,13 @@ policy enforcement. * The TokenSmith backend requires issuer and audience configuration in addition to the JWKS URL. * SMD validates the JWKS endpoint during startup initialization. +* Authentication rejections are logged clearly for protected routes. For `401` + and `403` responses, SMD logs the auth backend, HTTP method, request path, + remote address, whether the `Authorization` header was present, and which + auth scheme was used. For the TokenSmith backend, the log also includes the + expected issuer and audiences so issuer or audience mismatches are easier to + diagnose. +* These rejection logs intentionally do not include bearer token contents. * Existing well-formed clients should not need to change request shape. They still send `Authorization: Bearer ` for protected routes. * Clients may see stricter `401 Unauthorized` responses after enabling the From 98abad10543fd1dab3e115f3fd83000cda123ec6 Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Fri, 3 Apr 2026 18:17:59 -0400 Subject: [PATCH 04/10] Enhance authentication rejection logging with detailed challenge and reason classification Signed-off-by: Alex Lovell-Troy --- cmd/smd/auth.go | 71 ++++++++++++++++++++++++++++++++++++++++---- cmd/smd/auth_test.go | 37 +++++++++++++++++++---- 2 files changed, 97 insertions(+), 11 deletions(-) diff --git a/cmd/smd/auth.go b/cmd/smd/auth.go index 3f63bd42..43681d51 100644 --- a/cmd/smd/auth.go +++ b/cmd/smd/auth.go @@ -160,13 +160,20 @@ func (s *SmD) logAuthRejection(backend string, r *http.Request, recorder *authSt ) if backend == authBackendTokenSmith { - message += fmt.Sprintf(" expected_issuer=%q expected_audiences=%q", s.authIssuer, strings.Join(s.authAudiences, ",")) + message += fmt.Sprintf( + " auth_constraints=configured issuer_configured=%t audiences_configured=%t", + strings.TrimSpace(s.authIssuer) != "", + len(s.authAudiences) > 0, + ) } - if challenge := compactWhitespace(recorder.Header().Get("WWW-Authenticate")); challenge != "" { - message += fmt.Sprintf(" www_authenticate=%q", challenge) + if challengeState, challengeScheme, challengeError := summarizeWWWAuthenticate(recorder.Header().Get("WWW-Authenticate")); challengeState != "missing" { + message += fmt.Sprintf(" challenge=%s challenge_scheme=%s", challengeState, challengeScheme) + if challengeError != "none" { + message += fmt.Sprintf(" challenge_error=%s", challengeError) + } } - if detail := recorder.BodySnippet(); detail != "" { - message += fmt.Sprintf(" detail=%q", detail) + if detailClass := classifyAuthFailureDetail(recorder.BodySnippet(), statusCode); detailClass != "none" { + message += fmt.Sprintf(" auth_reason=%s", detailClass) } s.LogAlways("%s", message) @@ -190,6 +197,60 @@ func compactWhitespace(value string) string { return strings.Join(strings.Fields(value), " ") } +func summarizeWWWAuthenticate(header string) (state string, scheme string, errorCode string) { + challenge := compactWhitespace(header) + if challenge == "" { + return "missing", "none", "none" + } + + fields := strings.Fields(challenge) + if len(fields) == 0 { + return "present", "unknown", "none" + } + + scheme = strings.ToLower(fields[0]) + errorCode = "none" + if index := strings.Index(strings.ToLower(challenge), `error="`); index >= 0 { + value := challenge[index+len(`error="`):] + if end := strings.Index(value, `"`); end >= 0 { + parsed := strings.TrimSpace(value[:end]) + if parsed != "" { + errorCode = strings.ToLower(parsed) + } + } + } + + return "present", scheme, errorCode +} + +func classifyAuthFailureDetail(detail string, statusCode int) string { + if statusCode == http.StatusForbidden { + return "forbidden" + } + + normalized := strings.ToLower(compactWhitespace(detail)) + if normalized == "" { + return "none" + } + + switch { + case strings.Contains(normalized, "issuer"): + return "issuer" + case strings.Contains(normalized, "audience") || strings.Contains(normalized, " aud"): + return "audience" + case strings.Contains(normalized, "scope"): + return "scope" + case strings.Contains(normalized, "claim"): + return "claims" + case strings.Contains(normalized, "expir"): + return "expired" + case strings.Contains(normalized, "token"): + return "token" + default: + return "rejected" + } +} + type authStatusRecorder struct { http.ResponseWriter statusCode int diff --git a/cmd/smd/auth_test.go b/cmd/smd/auth_test.go index 157f8bba..92f1922b 100644 --- a/cmd/smd/auth_test.go +++ b/cmd/smd/auth_test.go @@ -191,10 +191,13 @@ func TestTokenSmithAuthRejectionLoggingIncludesClearContext(t *testing.T) { "remote=10.2.3.4:12345", "auth_header=present", "auth_scheme=bearer", - `expected_issuer="https://issuer.example.com"`, - `expected_audiences="smd,admin"`, - `www_authenticate="Bearer error=\"invalid_token\""`, - `detail="issuer mismatch"`, + "auth_constraints=configured", + "issuer_configured=true", + "audiences_configured=true", + "challenge=present", + "challenge_scheme=bearer", + "challenge_error=invalid_token", + "auth_reason=issuer", } for _, check := range checks { if !strings.Contains(output, check) { @@ -204,6 +207,18 @@ func TestTokenSmithAuthRejectionLoggingIncludesClearContext(t *testing.T) { if strings.Contains(output, "redacted-token") { t.Fatalf("expected auth rejection log not to contain bearer token, got %q", output) } + blocked := []string{ + "https://issuer.example.com", + "smd,admin", + `www_authenticate="Bearer error=\"invalid_token\""`, + `detail="issuer mismatch"`, + "issuer mismatch", + } + for _, blockedValue := range blocked { + if strings.Contains(output, blockedValue) { + t.Fatalf("expected auth rejection log not to contain %q, got %q", blockedValue, output) + } + } } func TestTokenSmithAuthRejectionLoggingSkipsSuccessfulRequests(t *testing.T) { @@ -285,8 +300,9 @@ func TestTokenSmithJWKSBackedRejectedTokenLogsClearly(t *testing.T) { "remote=10.20.30.40:12345", "auth_header=present", "auth_scheme=bearer", - `expected_issuer="https://issuer.example.com"`, - `expected_audiences="smd"`, + "auth_constraints=configured", + "issuer_configured=true", + "audiences_configured=true", } for _, check := range checks { if !strings.Contains(output, check) { @@ -296,6 +312,15 @@ func TestTokenSmithJWKSBackedRejectedTokenLogsClearly(t *testing.T) { if strings.Contains(output, signedToken) { t.Fatalf("expected log output not to include bearer token, got %q", output) } + blocked := []string{ + s.authIssuer, + strings.Join(s.authAudiences, ","), + } + for _, blockedValue := range blocked { + if blockedValue != "" && strings.Contains(output, blockedValue) { + t.Fatalf("expected log output not to include %q, got %q", blockedValue, output) + } + } } func TestTokenSmithJWKSBackedAcceptedTokenSetsClaimsAndSkipsRejectionLog(t *testing.T) { From 88527f9fae5d867f6a668706e91e0a7900b5337a Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Mon, 6 Apr 2026 12:08:08 -0400 Subject: [PATCH 05/10] Implement TokenSmith authorization support and enhance related middleware Signed-off-by: Alex Lovell-Troy --- cmd/smd/auth.go | 206 +++++++++++++++++++++++++++- cmd/smd/auth_test.go | 281 ++++++++++++++++++++++++++++++++++++--- cmd/smd/main.go | 46 +++++-- cmd/smd/routers.go | 4 + docs/authentication.adoc | 39 ++++-- go.mod | 4 +- go.sum | 2 + policy/grouping.csv | 8 ++ policy/model.conf | 20 +++ policy/policy.csv | 20 +++ 10 files changed, 587 insertions(+), 43 deletions(-) create mode 100644 policy/grouping.csv create mode 100644 policy/model.conf create mode 100644 policy/policy.csv diff --git a/cmd/smd/auth.go b/cmd/smd/auth.go index 43681d51..eaec659e 100644 --- a/cmd/smd/auth.go +++ b/cmd/smd/auth.go @@ -8,14 +8,18 @@ import ( "fmt" "io" "net/http" + "path/filepath" "slices" "strings" "time" jwtauth "github.com/OpenCHAMI/jwtauth/v5" + jwtv5 "github.com/golang-jwt/jwt/v5" "github.com/lestrrat-go/jwx/v2/jwk" openchami_authenticator "github.com/openchami/chi-middleware/auth" "github.com/openchami/tokensmith/pkg/authn" + "github.com/openchami/tokensmith/pkg/authz" + "github.com/openchami/tokensmith/pkg/authz/engine" ) var requiredLegacyClaims = []string{"sub", "iss", "aud"} @@ -28,6 +32,14 @@ func (s *SmD) UsingTokenSmithAuth() bool { return s.IsUsingAuthentication() && s.authBackend == authBackendTokenSmith } +func (s *SmD) IsUsingAuthorization() bool { + if !s.IsUsingAuthentication() { + return false + } + mode, err := parseAuthzMode(s.authzMode) + return err == nil && mode != authz.ModeOff +} + func parseCSVValues(raw string) []string { if strings.TrimSpace(raw) == "" { return nil @@ -49,16 +61,29 @@ func parseCSVValues(raw string) []string { func (s *SmD) initializeAuth() error { s.clearAuthState() + var err error if s.UsingTokenSmithAuth() { - return s.initializeTokenSmithAuth() + err = s.initializeTokenSmithAuth() + } else { + err = s.initializeLegacyAuth() + } + if err != nil { + return err + } + if s.IsUsingAuthorization() { + if err := s.initializeAuthz(); err != nil { + s.clearAuthState() + return err + } } - return s.initializeLegacyAuth() + return nil } func (s *SmD) clearAuthState() { s.legacyTokenAuth = nil s.protectedAuthMiddleware = nil + s.protectedAuthzMiddleware = nil } func (s *SmD) initializeLegacyAuth() error { @@ -68,7 +93,7 @@ func (s *SmD) initializeLegacyAuth() error { } s.legacyTokenAuth = tokenAuth - s.protectedAuthMiddleware = s.withAuthRejectionLogging(authBackendLegacy, buildLegacyProtectedAuthMiddleware(tokenAuth)) + s.protectedAuthMiddleware = s.withAuthRejectionLogging(authBackendLegacy, withLegacyPrincipal(buildLegacyProtectedAuthMiddleware(tokenAuth))) return nil } @@ -91,6 +116,9 @@ func (s *SmD) initializeTokenSmithAuth() error { Audiences: append([]string(nil), s.authAudiences...), JWKSURLs: []string{s.jwksURL}, HTTPClient: client, + Mapper: func(ctx context.Context, token *jwtv5.Token, claims jwtv5.MapClaims) (authz.Principal, error) { + return principalFromClaims(map[string]any(claims)), nil + }, }) if err != nil { return fmt.Errorf("failed to initialize TokenSmith middleware: %w", err) @@ -100,6 +128,39 @@ func (s *SmD) initializeTokenSmithAuth() error { return nil } +func (s *SmD) initializeAuthz() error { + mode, err := parseAuthzMode(s.authzMode) + if err != nil { + return err + } + if mode == authz.ModeOff { + return nil + } + + policyDir := strings.TrimSpace(s.authzPolicyDir) + if policyDir == "" { + return errors.New("authz-policy-dir is required when authz-mode is enabled") + } + + authorizer, err := engine.NewBuilder(). + WithModelPath(filepath.Join(policyDir, "model.conf")). + WithPolicyPath(filepath.Join(policyDir, "policy.csv")). + WithGroupingPath(filepath.Join(policyDir, "grouping.csv")). + Build() + if err != nil { + return fmt.Errorf("failed to initialize TokenSmith authorization: %w", err) + } + + s.protectedAuthzMiddleware = authz.NewMiddleware( + authorizer, + authz.PathMethodMapper{MethodToAction: authz.MethodToActionREST()}, + authz.WithMode(mode), + authz.WithRequireAuthn(true), + ).Handler + + return nil +} + func (s *SmD) ProtectedAuthMiddleware() func(http.Handler) http.Handler { if !s.IsUsingAuthentication() { return nil @@ -111,6 +172,17 @@ func (s *SmD) ProtectedAuthMiddleware() func(http.Handler) http.Handler { return unavailableAuthMiddleware() } +func (s *SmD) ProtectedAuthzMiddleware() func(http.Handler) http.Handler { + if !s.IsUsingAuthorization() { + return nil + } + if s.protectedAuthzMiddleware != nil { + return s.protectedAuthzMiddleware + } + + return unavailableAuthzMiddleware() +} + func unavailableAuthMiddleware() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -119,6 +191,14 @@ func unavailableAuthMiddleware() func(http.Handler) http.Handler { } } +func unavailableAuthzMiddleware() func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sendJsonError(w, http.StatusServiceUnavailable, "authorization is enabled but not initialized") + }) + } +} + func buildLegacyProtectedAuthMiddleware(tokenAuth *jwtauth.JWTAuth) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return jwtauth.Verifier(tokenAuth)( @@ -127,6 +207,27 @@ func buildLegacyProtectedAuthMiddleware(tokenAuth *jwtauth.JWTAuth) func(http.Ha } } +func withLegacyPrincipal(base func(http.Handler) http.Handler) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return base(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims, err := legacyVerifiedClaims(r.Context()) + if err != nil { + http.Error(w, "invalid token", http.StatusUnauthorized) + return + } + + principal := principalFromClaims(claims) + if strings.TrimSpace(principal.ID) == "" { + http.Error(w, "invalid token", http.StatusUnauthorized) + return + } + + ctx := authz.SetPrincipal(r.Context(), &principal) + next.ServeHTTP(w, r.WithContext(ctx)) + })) + } +} + func (s *SmD) withAuthRejectionLogging(backend string, base func(http.Handler) http.Handler) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { handler := base(next) @@ -300,7 +401,15 @@ func (s *SmD) verifiedClaimsFromRequest(r *http.Request) (map[string]any, error) return claims, nil } - _, claims, err := jwtauth.FromContext(r.Context()) + claims, err := legacyVerifiedClaims(r.Context()) + if err != nil { + return nil, err + } + return claims, nil +} + +func legacyVerifiedClaims(ctx context.Context) (map[string]any, error) { + _, claims, err := jwtauth.FromContext(ctx) if err != nil { return nil, err } @@ -369,6 +478,95 @@ func extractScopesFromClaims(claims map[string]any) []string { return scopes } +func extractStringClaimValues(claims map[string]any, keys ...string) []string { + var values []string + appendValues := func(raw any) { + switch typed := raw.(type) { + case []any: + for _, value := range typed { + if asString, ok := value.(string); ok && strings.TrimSpace(asString) != "" { + values = append(values, strings.TrimSpace(asString)) + } + } + case []string: + for _, value := range typed { + if strings.TrimSpace(value) != "" { + values = append(values, strings.TrimSpace(value)) + } + } + case string: + for _, value := range strings.Fields(typed) { + if strings.TrimSpace(value) != "" { + values = append(values, strings.TrimSpace(value)) + } + } + } + } + + for _, key := range keys { + if raw, ok := claims[key]; ok { + appendValues(raw) + } + } + + return values +} + +func principalFromClaims(claims map[string]any) authz.Principal { + principal := authz.Principal{} + if sub, ok := claims["sub"].(string); ok { + principal.ID = strings.TrimSpace(sub) + } + principal.Roles = append(principal.Roles, extractStringClaimValues(claims, "roles", "role")...) + if hasServiceAuthEvent(claims) { + principal.Roles = append(principal.Roles, "service") + } + principal.Roles = dedupeStrings(principal.Roles) + return principal +} + +func hasServiceAuthEvent(claims map[string]any) bool { + for _, event := range extractStringClaimValues(claims, "auth_events") { + if strings.EqualFold(event, "service_auth") { + return true + } + } + return false +} + +func dedupeStrings(values []string) []string { + if len(values) == 0 { + return nil + } + out := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + out = append(out, trimmed) + } + return out +} + +func parseAuthzMode(raw string) (authz.Mode, error) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "", "off": + return authz.ModeOff, nil + case "shadow": + return authz.ModeShadow, nil + case "enforce": + return authz.ModeEnforce, nil + default: + return authz.ModeOff, fmt.Errorf("unsupported authz mode %q", raw) + } +} + type statusCheckTransport struct { http.RoundTripper } diff --git a/cmd/smd/auth_test.go b/cmd/smd/auth_test.go index 92f1922b..bfad533e 100644 --- a/cmd/smd/auth_test.go +++ b/cmd/smd/auth_test.go @@ -33,6 +33,8 @@ import ( "math/big" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" "time" @@ -41,14 +43,16 @@ import ( jwtv5 "github.com/golang-jwt/jwt/v5" "github.com/lestrrat-go/jwx/v2/jwt" "github.com/openchami/tokensmith/pkg/authn" + "github.com/openchami/tokensmith/pkg/authz" ) func TestInitializeAuthClearsStaleStateOnError(t *testing.T) { s := &SmD{ - jwksURL: "https://example.com/jwks.json", - authBackend: authBackendTokenSmith, - protectedAuthMiddleware: func(next http.Handler) http.Handler { return next }, - legacyTokenAuth: jwtauth.New("HS256", []byte("secret"), nil), + jwksURL: "https://example.com/jwks.json", + authBackend: authBackendTokenSmith, + protectedAuthMiddleware: func(next http.Handler) http.Handler { return next }, + protectedAuthzMiddleware: func(next http.Handler) http.Handler { return next }, + legacyTokenAuth: jwtauth.New("HS256", []byte("secret"), nil), } err := s.initializeAuth() @@ -61,6 +65,9 @@ func TestInitializeAuthClearsStaleStateOnError(t *testing.T) { if s.legacyTokenAuth != nil { t.Fatal("expected initializeAuth to clear stale legacy auth state on failure") } + if s.protectedAuthzMiddleware != nil { + t.Fatal("expected initializeAuth to clear stale protected authz middleware on failure") + } } func TestProtectedAuthMiddlewareFallsClosed(t *testing.T) { @@ -264,14 +271,7 @@ func TestTokenSmithJWKSBackedRejectedTokenLogsClearly(t *testing.T) { t.Fatalf("failed to initialize TokenSmith auth: %v", err) } - token := jwtv5.NewWithClaims(jwtv5.SigningMethodRS256, jwtv5.MapClaims{ - "sub": "node-1", - "iss": "https://wrong-issuer.example.com", - "aud": []string{"smd"}, - "iat": time.Now().Add(-time.Minute).Unix(), - "nbf": time.Now().Add(-time.Minute).Unix(), - "exp": time.Now().Add(time.Minute).Unix(), - }) + token := jwtv5.NewWithClaims(jwtv5.SigningMethodRS256, validTokenSmithServiceClaims("https://wrong-issuer.example.com", []string{"smd"}, "node-1")) token.Header["kid"] = kid signedToken, err := token.SignedString(privateKey) if err != nil { @@ -348,14 +348,7 @@ func TestTokenSmithJWKSBackedAcceptedTokenSetsClaimsAndSkipsRejectionLog(t *test t.Fatalf("failed to initialize TokenSmith auth: %v", err) } - token := jwtv5.NewWithClaims(jwtv5.SigningMethodRS256, jwtv5.MapClaims{ - "sub": "node-1", - "iss": "https://issuer.example.com", - "aud": []string{"smd"}, - "iat": time.Now().Add(-time.Minute).Unix(), - "nbf": time.Now().Add(-time.Minute).Unix(), - "exp": time.Now().Add(time.Minute).Unix(), - }) + token := jwtv5.NewWithClaims(jwtv5.SigningMethodRS256, validTokenSmithServiceClaims("https://issuer.example.com", []string{"smd"}, "node-1")) token.Header["kid"] = kid signedToken, err := token.SignedString(privateKey) if err != nil { @@ -378,6 +371,16 @@ func TestTokenSmithJWKSBackedAcceptedTokenSetsClaimsAndSkipsRejectionLog(t *test if claims["sub"] != "node-1" { t.Fatalf("expected subject claim node-1, got %#v", claims["sub"]) } + principal, ok := authz.PrincipalFromContext(r.Context()) + if !ok { + t.Fatal("expected TokenSmith principal in request context") + } + if principal.ID != "node-1" { + t.Fatalf("expected principal ID node-1, got %q", principal.ID) + } + if !containsString(principal.Roles, "service") { + t.Fatalf("expected TokenSmith principal roles to include service, got %v", principal.Roles) + } verified, err := s.VerifyClaims([]string{"sub", "iss", "aud"}, r) if err != nil { @@ -459,3 +462,241 @@ func TestNewRouterUsesProtectedAuthMiddleware(t *testing.T) { t.Fatalf("expected status %d, got %d", http.StatusNoContent, recorder.Code) } } + +func TestProtectedAuthzMiddlewareFallsClosed(t *testing.T) { + s := &SmD{jwksURL: "https://example.com/jwks.json", authzMode: "enforce"} + middleware := s.ProtectedAuthzMiddleware() + if middleware == nil { + t.Fatal("expected protected authz middleware when authorization is enabled") + } + + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + recorder := httptest.NewRecorder() + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + handler.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusServiceUnavailable { + t.Fatalf("expected status %d, got %d", http.StatusServiceUnavailable, recorder.Code) + } +} + +func TestLegacyProtectedAuthMiddlewareSetsPrincipal(t *testing.T) { + tokenAuth := jwtauth.New("HS256", []byte("secret"), nil) + middleware := withLegacyPrincipal(buildLegacyProtectedAuthMiddleware(tokenAuth)) + _, signedToken, err := tokenAuth.Encode(map[string]any{ + "sub": "legacy-user", + "iss": "issuer", + "aud": "smd", + "roles": []string{"viewer"}, + }) + if err != nil { + t.Fatalf("failed to sign legacy token: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + req.Header.Set("Authorization", "Bearer "+signedToken) + recorder := httptest.NewRecorder() + + handler := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + principal, ok := authz.PrincipalFromContext(r.Context()) + if !ok { + t.Fatal("expected legacy principal in context") + } + if principal.ID != "legacy-user" { + t.Fatalf("expected principal ID legacy-user, got %q", principal.ID) + } + if !containsString(principal.Roles, "viewer") { + t.Fatalf("expected principal roles to include viewer, got %v", principal.Roles) + } + w.WriteHeader(http.StatusNoContent) + })) + + handler.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusNoContent { + t.Fatalf("expected status %d, got %d with body %q", http.StatusNoContent, recorder.Code, recorder.Body.String()) + } +} + +func TestTokenSmithAuthzEnforceAllowsServiceTokenAndDeniesViewerWrite(t *testing.T) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("failed to generate RSA key: %v", err) + } + + kid := "test-key-authz" + jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(smdTestJWKSJSON(t, kid, &privateKey.PublicKey)) + })) + defer jwksServer.Close() + + policyDir := writeAuthzPolicyDir(t, strings.Join([]string{ + "p, role:viewer, /*, read", + "p, role:service, /*, read", + "p, role:service, /*, write", + }, "\n")+"\n") + + s := &SmD{ + jwksURL: jwksServer.URL, + authBackend: authBackendTokenSmith, + authIssuer: "https://issuer.example.com", + authAudiences: []string{"smd"}, + authzMode: "enforce", + authzPolicyDir: policyDir, + } + + if err := s.initializeAuth(); err != nil { + t.Fatalf("failed to initialize auth stack: %v", err) + } + + serviceToken := signToken(t, privateKey, kid, validTokenSmithServiceClaims("https://issuer.example.com", []string{"smd"}, "svc-a")) + viewerToken := signToken(t, privateKey, kid, validTokenSmithRoleClaims("https://issuer.example.com", []string{"smd"}, "user-a", []string{"viewer"})) + + protectedHandler := s.ProtectedAuthMiddleware()(s.ProtectedAuthzMiddleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }))) + + allowReq := httptest.NewRequest(http.MethodPost, "/hsm/v2/State/Components", nil) + allowReq.Header.Set("Authorization", "Bearer "+serviceToken) + allowRecorder := httptest.NewRecorder() + protectedHandler.ServeHTTP(allowRecorder, allowReq) + if allowRecorder.Code != http.StatusNoContent { + t.Fatalf("expected service token write to be allowed, got %d with body %q", allowRecorder.Code, allowRecorder.Body.String()) + } + + denyReq := httptest.NewRequest(http.MethodPost, "/hsm/v2/State/Components", nil) + denyReq.Header.Set("Authorization", "Bearer "+viewerToken) + denyRecorder := httptest.NewRecorder() + protectedHandler.ServeHTTP(denyRecorder, denyReq) + if denyRecorder.Code != http.StatusForbidden { + t.Fatalf("expected viewer write to be denied, got %d with body %q", denyRecorder.Code, denyRecorder.Body.String()) + } + if !strings.Contains(denyRecorder.Body.String(), "AUTHZ_DENIED") { + t.Fatalf("expected authz deny response, got %q", denyRecorder.Body.String()) + } +} + +func TestNewRouterUsesProtectedAuthAndAuthzMiddleware(t *testing.T) { + authCalled := false + authzCalled := false + s := &SmD{ + jwksURL: "https://example.com/jwks.json", + authzMode: "enforce", + protectedAuthMiddleware: func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authCalled = true + next.ServeHTTP(w, r) + }) + }, + protectedAuthzMiddleware: func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authzCalled = true + next.ServeHTTP(w, r) + }) + }, + } + + router := s.NewRouter(nil, Routes{{ + Name: "protected", + Method: http.MethodGet, + Pattern: "/protected", + HandlerFunc: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }, + }}) + + req := httptest.NewRequest(http.MethodGet, "/protected", nil) + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, req) + + if !authCalled { + t.Fatal("expected protected auth middleware to wrap protected routes") + } + if !authzCalled { + t.Fatal("expected protected authz middleware to wrap protected routes") + } + if recorder.Code != http.StatusNoContent { + t.Fatalf("expected status %d, got %d", http.StatusNoContent, recorder.Code) + } +} + +func validTokenSmithServiceClaims(issuer string, audience []string, subject string) jwtv5.MapClaims { + now := time.Now().UTC() + iat := now.Add(-time.Minute) + return jwtv5.MapClaims{ + "sub": subject, + "iss": issuer, + "aud": audience, + "iat": iat.Unix(), + "nbf": iat.Unix(), + "exp": now.Add(time.Minute).Unix(), + "auth_level": "IAL2", + "auth_factors": 2, + "auth_methods": []string{"service", "certificate"}, + "session_id": "service-" + subject, + "session_exp": iat.Add(24 * time.Hour).Unix(), + "auth_events": []string{"service_auth"}, + } +} + +func validTokenSmithRoleClaims(issuer string, audience []string, subject string, roles []string) jwtv5.MapClaims { + claims := validTokenSmithServiceClaims(issuer, audience, subject) + claims["auth_events"] = []string{"login"} + claims["auth_methods"] = []string{"password", "mfa"} + claims["roles"] = roles + return claims +} + +func signToken(t *testing.T, privateKey *rsa.PrivateKey, kid string, claims jwtv5.MapClaims) string { + t.Helper() + token := jwtv5.NewWithClaims(jwtv5.SigningMethodRS256, claims) + token.Header["kid"] = kid + signedToken, err := token.SignedString(privateKey) + if err != nil { + t.Fatalf("failed to sign JWT: %v", err) + } + return signedToken +} + +func writeAuthzPolicyDir(t *testing.T, policyCSV string) string { + t.Helper() + dir := t.TempDir() + files := map[string]string{ + "model.conf": strings.Join([]string{ + "[request_definition]", + "r = sub, obj, act", + "", + "[policy_definition]", + "p = sub, obj, act", + "", + "[role_definition]", + "g = _, _", + "", + "[policy_effect]", + "e = some(where (p.eft == allow))", + "", + "[matchers]", + "m = g(r.sub, p.sub) && keyMatch2(r.obj, p.obj) && r.act == p.act", + }, "\n") + "\n", + "policy.csv": policyCSV, + "grouping.csv": "# no role inheritance in test policy\n", + } + for name, content := range files { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { + t.Fatalf("failed to write %s: %v", name, err) + } + } + return dir +} + +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/cmd/smd/main.go b/cmd/smd/main.go index 6b0b4c7f..0171116d 100644 --- a/cmd/smd/main.go +++ b/cmd/smd/main.go @@ -177,14 +177,17 @@ type SmD struct { discMapLock sync.Mutex //router - router *chi.Mux - legacyTokenAuth *jwtauth.JWTAuth - jwksURL string - authBackend string - authIssuer string - authAudiencesCSV string - authAudiences []string - protectedAuthMiddleware func(http.Handler) http.Handler + router *chi.Mux + legacyTokenAuth *jwtauth.JWTAuth + jwksURL string + authBackend string + authIssuer string + authAudiencesCSV string + authAudiences []string + protectedAuthMiddleware func(http.Handler) http.Handler + authzMode string + authzPolicyDir string + protectedAuthzMiddleware func(http.Handler) http.Handler httpClient *retryablehttp.Client } @@ -607,6 +610,8 @@ func (s *SmD) parseCmdLine() { flag.StringVar(&s.authBackend, "auth-backend", authBackendLegacy, "Authentication backend to use with jwks-url: legacy or tokensmith") flag.StringVar(&s.authIssuer, "auth-issuer", "", "Expected JWT issuer when auth-backend=tokensmith") flag.StringVar(&s.authAudiencesCSV, "auth-audiences", "", "Comma-separated expected JWT audiences when auth-backend=tokensmith") + flag.StringVar(&s.authzMode, "authz-mode", "off", "Authorization mode for protected routes when authentication is enabled: off, shadow, or enforce") + flag.StringVar(&s.authzPolicyDir, "authz-policy-dir", "./policy", "Directory containing TokenSmith authorization policy files (model.conf, policy.csv, grouping.csv)") flag.BoolVar(&applyMigrations, "migrate", false, "Apply all database migrations before starting") flag.BoolVar(&s.enableDiscovery, "enable-discovery", enableDiscoveryDefault, "Enable discovery-related subroutines") flag.BoolVar(&s.openchami, "openchami", OPENCHAMI_DEFAULT, "Enabled OpenCHAMI features") @@ -694,6 +699,18 @@ func (s *SmD) parseCmdLine() { s.authAudiencesCSV = val } } + envvar = "SMD_AUTHZ_MODE" + if s.authzMode == "off" { + if val := os.Getenv(envvar); val != "" { + s.authzMode = val + } + } + envvar = "SMD_AUTHZ_POLICY_DIR" + if s.authzPolicyDir == "./policy" { + if val := os.Getenv(envvar); val != "" { + s.authzPolicyDir = val + } + } s.authBackend = strings.ToLower(strings.TrimSpace(s.authBackend)) if s.authBackend == "" { @@ -705,6 +722,19 @@ func (s *SmD) parseCmdLine() { os.Exit(1) } s.authAudiences = parseCSVValues(s.authAudiencesCSV) + s.authzMode = strings.ToLower(strings.TrimSpace(s.authzMode)) + if s.authzMode == "" { + s.authzMode = "off" + } + if _, err := parseAuthzMode(s.authzMode); err != nil { + fmt.Printf("Bad authz-mode %q: %v\n", s.authzMode, err) + flag.Usage() + os.Exit(1) + } + s.authzPolicyDir = strings.TrimSpace(s.authzPolicyDir) + if s.authzPolicyDir == "" { + s.authzPolicyDir = "./policy" + } port, err := strconv.ParseInt(s.dbPortStr, 10, 64) if err != nil { diff --git a/cmd/smd/routers.go b/cmd/smd/routers.go index 529c34f8..e0db1759 100644 --- a/cmd/smd/routers.go +++ b/cmd/smd/routers.go @@ -65,8 +65,12 @@ func (s *SmD) NewRouter(publicRoutes []Route, protectedRoutes []Route) *chi.Mux router.Use(middleware.Timeout(60 * time.Second)) if s.IsUsingAuthentication() { protectedAuthMiddleware := s.ProtectedAuthMiddleware() + protectedAuthzMiddleware := s.ProtectedAuthzMiddleware() router.Group(func(r chi.Router) { r.Use(protectedAuthMiddleware) + if protectedAuthzMiddleware != nil { + r.Use(protectedAuthzMiddleware) + } // Register protected routes for _, route := range protectedRoutes { diff --git a/docs/authentication.adoc b/docs/authentication.adoc index c391d559..c76bedf3 100644 --- a/docs/authentication.adoc +++ b/docs/authentication.adoc @@ -1,7 +1,7 @@ == Security and Authentication === Authentication Mechanisms -SMD supports optional JWT authentication for protected routes. +SMD supports optional JWT authentication and authorization for protected routes. Authentication is enabled when `jwks-url` is configured. If `jwks-url` is unset, all routes remain unauthenticated. @@ -17,7 +17,7 @@ When authentication is enabled, SMD supports two backends: === Configuration -The following flags and environment variables control authentication: +The following flags and environment variables control authentication and authorization: * `-jwks-url` or `SMD_JWKS_URL`: JWKS endpoint used for JWT validation. @@ -30,6 +30,12 @@ The following flags and environment variables control authentication: * `-auth-audiences` or `SMD_AUTH_AUDIENCES`: Required when `auth-backend=tokensmith`. Comma-separated expected JWT audiences. +* `-authz-mode` or `SMD_AUTHZ_MODE`: + Authorization mode for protected routes. Supported values are `off`, + `shadow`, and `enforce`. The default is `off`. +* `-authz-policy-dir` or `SMD_AUTHZ_POLICY_DIR`: + Directory containing TokenSmith authorization policy files named + `model.conf`, `policy.csv`, and `grouping.csv`. The default is `./policy`. These controls are applied at startup. Changing them requires restarting SMD. @@ -43,7 +49,7 @@ The current implementation does not support: * hot reloading of authentication settings * switching auth backends without restarting the process * a separate `auth-enabled` toggle independent of `jwks-url` -* request-time overrides of issuer, audience, or backend behavior +* request-time overrides of issuer, audience, auth backend, or authorization mode Per request, clients only control the standard Bearer token they send in the `Authorization` header. Public versus protected route behavior remains fixed by @@ -56,14 +62,26 @@ SMD continues to split routes into public and protected groups: * Public routes remain accessible without a token. * Protected routes require a valid Bearer JWT when authentication is enabled. -The current TokenSmith integration is AuthN-only. It validates tokens and places -verified claims in request context, but does not yet add route-level scope or -policy enforcement. +When `authz-mode` is enabled, SMD applies TokenSmith authorization middleware +to the protected route group after authentication succeeds. Public routes remain +outside the authorization pipeline. + +SMD derives the authorization principal from verified JWT claims: + +* TokenSmith service tokens with `auth_events` containing `service_auth` map to + the `service` role. +* User-oriented tokens must include explicit role claims for authorization. +* Legacy-authenticated requests can participate in authorization when their + verified claims include role data. === Notes * The TokenSmith backend requires issuer and audience configuration in addition to the JWKS URL. +* Authorization requires authentication to be enabled and successfully + initialized. +* SMD ships a starter TokenSmith policy tree in `./policy`. Operators can use + it directly or point `authz-policy-dir` at a different policy directory. * SMD validates the JWKS endpoint during startup initialization. * Authentication rejections are logged clearly for protected routes. For `401` and `403` responses, SMD logs the auth backend, HTTP method, request path, @@ -72,10 +90,13 @@ policy enforcement. expected issuer and audiences so issuer or audience mismatches are easier to diagnose. * These rejection logs intentionally do not include bearer token contents. +* In `shadow` mode, authorization is evaluated but requests continue. In + `enforce` mode, denied protected requests receive the standard TokenSmith + AuthZ denial response. * Existing well-formed clients should not need to change request shape. They still send `Authorization: Bearer ` for protected routes. * Clients may see stricter `401 Unauthorized` responses after enabling the TokenSmith backend if their tokens have issuer, audience, time-claim, or - JWKS/algorithm inconsistencies that were previously tolerated. -* Existing deployments using `jwks-url` can continue using the `legacy` - backend until they are ready to switch to `tokensmith`. + JWKS/algorithm inconsistencies. +* Requests may receive `403 Forbidden` responses after enabling authorization + if their verified role set does not allow the requested protected route. diff --git a/go.mod b/go.mod index 00a9a0f5..cf5b999e 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/Masterminds/squirrel v1.5.4 github.com/OpenCHAMI/jwtauth/v5 v5.0.0-20240321222802-e6cb468a2a18 github.com/go-chi/chi/v5 v5.2.3 + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang-migrate/migrate/v4 v4.18.2 github.com/google/uuid v1.6.0 github.com/hashicorp/go-retryablehttp v0.7.7 @@ -22,7 +23,7 @@ require ( github.com/openchami/chi-middleware/auth v0.0.0-20240812224658-b16b83c70700 github.com/openchami/chi-middleware/log v0.0.0-20240812224658-b16b83c70700 github.com/openchami/schemas v0.0.0-20250625220233-9aad17a286c4 - github.com/openchami/tokensmith v0.0.2 + github.com/openchami/tokensmith v0.0.3-0.20260406155037-28ca3e689493 github.com/rs/zerolog v1.34.0 github.com/sirupsen/logrus v1.9.3 ) @@ -41,7 +42,6 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-jose/go-jose/v4 v4.1.0 // indirect github.com/goccy/go-json v0.10.3 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/lestrrat-go/blackmagic v1.0.2 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect diff --git a/go.sum b/go.sum index 6d64470b..9bcbce9b 100644 --- a/go.sum +++ b/go.sum @@ -337,6 +337,8 @@ github.com/openchami/schemas v0.0.0-20250625220233-9aad17a286c4 h1:89rudSw0Teedl github.com/openchami/schemas v0.0.0-20250625220233-9aad17a286c4/go.mod h1:3dridLqXvAdO0ypPXuxnXRgaK2h/dItVKGseCgFQ13k= github.com/openchami/tokensmith v0.0.2 h1:Nh/6X/0KPcAD6Hb9xmSh64ktDzlDYHCmU+s7C+qG/iU= github.com/openchami/tokensmith v0.0.2/go.mod h1:L4ZCMX/vPGwXUUn9otw+UdfFTbarv+ZVO/FjhZmoOAE= +github.com/openchami/tokensmith v0.0.3-0.20260406155037-28ca3e689493 h1:35BbJiohxq++ORGj2xETU51bW+uu5eUsp4iDAGxHlEw= +github.com/openchami/tokensmith v0.0.3-0.20260406155037-28ca3e689493/go.mod h1:L4ZCMX/vPGwXUUn9otw+UdfFTbarv+ZVO/FjhZmoOAE= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= diff --git a/policy/grouping.csv b/policy/grouping.csv new file mode 100644 index 00000000..4a194ed2 --- /dev/null +++ b/policy/grouping.csv @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: 2026 OpenCHAMI Contributors +# +# SPDX-License-Identifier: MIT + +# Starter grouping file kept for the standard TokenSmith Casbin layout. +# +# SMD currently derives authorization roles from verified JWT claims. Operators +# can extend this file if they introduce role inheritance in their policy model. \ No newline at end of file diff --git a/policy/model.conf b/policy/model.conf new file mode 100644 index 00000000..05c2e5c7 --- /dev/null +++ b/policy/model.conf @@ -0,0 +1,20 @@ +# Copyright © 2026 OpenCHAMI a Series of LF Projects, LLC +# +# SPDX-License-Identifier: MIT + +[request_definition] +r = sub, obj, act + +[policy_definition] +p = sub, obj, act + +[role_definition] +g = _, _ + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +# obj is expected to be a normalized URL path. keyMatch2 allows patterns like: +# /v1/nodes/:id +m = g(r.sub, p.sub) && keyMatch2(r.obj, p.obj) && r.act == p.act \ No newline at end of file diff --git a/policy/policy.csv b/policy/policy.csv new file mode 100644 index 00000000..0b69d8e8 --- /dev/null +++ b/policy/policy.csv @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: 2026 OpenCHAMI Contributors +# +# SPDX-License-Identifier: MIT + +# Starter policy for SMD protected routes. +# +# SMD applies TokenSmith AuthZ only to the protected route group, so broad path +# patterns here affect protected routes only. + +p, role:viewer, /*, read + +p, role:operator, /*, read +p, role:operator, /*, write + +p, role:service, /*, read +p, role:service, /*, write + +p, role:admin, /*, read +p, role:admin, /*, write +p, role:admin, /*, delete \ No newline at end of file From 53f810e76fa2135ec227d2b80bb7464d465e3fca Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Mon, 6 Apr 2026 12:44:44 -0400 Subject: [PATCH 06/10] Update tokensmith dependency to latest version Signed-off-by: Alex Lovell-Troy --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index cf5b999e..30ce80df 100644 --- a/go.mod +++ b/go.mod @@ -23,7 +23,7 @@ require ( github.com/openchami/chi-middleware/auth v0.0.0-20240812224658-b16b83c70700 github.com/openchami/chi-middleware/log v0.0.0-20240812224658-b16b83c70700 github.com/openchami/schemas v0.0.0-20250625220233-9aad17a286c4 - github.com/openchami/tokensmith v0.0.3-0.20260406155037-28ca3e689493 + github.com/openchami/tokensmith v0.0.3-0.20260406164334-3dd5af553b4b github.com/rs/zerolog v1.34.0 github.com/sirupsen/logrus v1.9.3 ) diff --git a/go.sum b/go.sum index 9bcbce9b..8ff1a134 100644 --- a/go.sum +++ b/go.sum @@ -339,6 +339,8 @@ github.com/openchami/tokensmith v0.0.2 h1:Nh/6X/0KPcAD6Hb9xmSh64ktDzlDYHCmU+s7C+ github.com/openchami/tokensmith v0.0.2/go.mod h1:L4ZCMX/vPGwXUUn9otw+UdfFTbarv+ZVO/FjhZmoOAE= github.com/openchami/tokensmith v0.0.3-0.20260406155037-28ca3e689493 h1:35BbJiohxq++ORGj2xETU51bW+uu5eUsp4iDAGxHlEw= github.com/openchami/tokensmith v0.0.3-0.20260406155037-28ca3e689493/go.mod h1:L4ZCMX/vPGwXUUn9otw+UdfFTbarv+ZVO/FjhZmoOAE= +github.com/openchami/tokensmith v0.0.3-0.20260406164334-3dd5af553b4b h1:aRXW6NoiggT4Cq9Lu+gNl0r2ADUENMiS3ttFYdjYNrk= +github.com/openchami/tokensmith v0.0.3-0.20260406164334-3dd5af553b4b/go.mod h1:L4ZCMX/vPGwXUUn9otw+UdfFTbarv+ZVO/FjhZmoOAE= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= From 45e9bd52bf8fa36888ac7ad5e3d51ddfe355501e Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Mon, 6 Apr 2026 13:35:10 -0400 Subject: [PATCH 07/10] Update tokensmith dependency to version 0.3.0 Signed-off-by: Alex Lovell-Troy --- go.mod | 2 +- go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 30ce80df..c3b934a1 100644 --- a/go.mod +++ b/go.mod @@ -23,7 +23,7 @@ require ( github.com/openchami/chi-middleware/auth v0.0.0-20240812224658-b16b83c70700 github.com/openchami/chi-middleware/log v0.0.0-20240812224658-b16b83c70700 github.com/openchami/schemas v0.0.0-20250625220233-9aad17a286c4 - github.com/openchami/tokensmith v0.0.3-0.20260406164334-3dd5af553b4b + github.com/openchami/tokensmith v0.3.0 github.com/rs/zerolog v1.34.0 github.com/sirupsen/logrus v1.9.3 ) diff --git a/go.sum b/go.sum index 8ff1a134..5d1586d7 100644 --- a/go.sum +++ b/go.sum @@ -341,6 +341,8 @@ github.com/openchami/tokensmith v0.0.3-0.20260406155037-28ca3e689493 h1:35BbJioh github.com/openchami/tokensmith v0.0.3-0.20260406155037-28ca3e689493/go.mod h1:L4ZCMX/vPGwXUUn9otw+UdfFTbarv+ZVO/FjhZmoOAE= github.com/openchami/tokensmith v0.0.3-0.20260406164334-3dd5af553b4b h1:aRXW6NoiggT4Cq9Lu+gNl0r2ADUENMiS3ttFYdjYNrk= github.com/openchami/tokensmith v0.0.3-0.20260406164334-3dd5af553b4b/go.mod h1:L4ZCMX/vPGwXUUn9otw+UdfFTbarv+ZVO/FjhZmoOAE= +github.com/openchami/tokensmith v0.3.0 h1:OdP1sQo4dXfGzWC4NEtUvfOcRxLdqR91P2yy0gRyAXQ= +github.com/openchami/tokensmith v0.3.0/go.mod h1:L4ZCMX/vPGwXUUn9otw+UdfFTbarv+ZVO/FjhZmoOAE= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= From 978aac58357c9569bc603f118c58a30e62c619e5 Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Tue, 7 Apr 2026 16:43:44 -0400 Subject: [PATCH 08/10] Refactor token handling in tests to use RFC7638 thumbprint for key IDs Signed-off-by: Alex Lovell-Troy --- cmd/smd/auth_test.go | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/cmd/smd/auth_test.go b/cmd/smd/auth_test.go index bfad533e..42d4c0ee 100644 --- a/cmd/smd/auth_test.go +++ b/cmd/smd/auth_test.go @@ -44,6 +44,7 @@ import ( "github.com/lestrrat-go/jwx/v2/jwt" "github.com/openchami/tokensmith/pkg/authn" "github.com/openchami/tokensmith/pkg/authz" + "github.com/openchami/tokensmith/pkg/keys" ) func TestInitializeAuthClearsStaleStateOnError(t *testing.T) { @@ -252,7 +253,7 @@ func TestTokenSmithJWKSBackedRejectedTokenLogsClearly(t *testing.T) { t.Fatalf("failed to generate RSA key: %v", err) } - kid := "test-key-1" + kid := rsaThumbprint(t, &privateKey.PublicKey) jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(smdTestJWKSJSON(t, kid, &privateKey.PublicKey)) })) @@ -329,7 +330,7 @@ func TestTokenSmithJWKSBackedAcceptedTokenSetsClaimsAndSkipsRejectionLog(t *test t.Fatalf("failed to generate RSA key: %v", err) } - kid := "test-key-2" + kid := rsaThumbprint(t, &privateKey.PublicKey) jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(smdTestJWKSJSON(t, kid, &privateKey.PublicKey)) })) @@ -527,7 +528,7 @@ func TestTokenSmithAuthzEnforceAllowsServiceTokenAndDeniesViewerWrite(t *testing t.Fatalf("failed to generate RSA key: %v", err) } - kid := "test-key-authz" + kid := rsaThumbprint(t, &privateKey.PublicKey) jwksServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(smdTestJWKSJSON(t, kid, &privateKey.PublicKey)) })) @@ -700,3 +701,12 @@ func containsString(values []string, target string) bool { } return false } + +func rsaThumbprint(t *testing.T, pub *rsa.PublicKey) string { + t.Helper() + kid, err := keys.RFC7638Thumbprint(pub) + if err != nil { + t.Fatalf("failed to compute RFC7638 thumbprint: %v", err) + } + return kid +} From 132ab66a860897967c1bb7f9cdefd7ba24a4224b Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Wed, 8 Apr 2026 18:00:47 -0400 Subject: [PATCH 09/10] Update tokensmith dependency to version v0.3.1 pre-release Signed-off-by: Alex Lovell-Troy --- go.mod | 2 +- go.sum | 10 ++-------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index c3b934a1..cb8e9e3a 100644 --- a/go.mod +++ b/go.mod @@ -23,7 +23,7 @@ require ( github.com/openchami/chi-middleware/auth v0.0.0-20240812224658-b16b83c70700 github.com/openchami/chi-middleware/log v0.0.0-20240812224658-b16b83c70700 github.com/openchami/schemas v0.0.0-20250625220233-9aad17a286c4 - github.com/openchami/tokensmith v0.3.0 + github.com/openchami/tokensmith v0.3.1-0.20260408211730-d305fa0bedb3 github.com/rs/zerolog v1.34.0 github.com/sirupsen/logrus v1.9.3 ) diff --git a/go.sum b/go.sum index 5d1586d7..033bd38b 100644 --- a/go.sum +++ b/go.sum @@ -335,14 +335,8 @@ github.com/openchami/chi-middleware/log v0.0.0-20240812224658-b16b83c70700 h1:Gz github.com/openchami/chi-middleware/log v0.0.0-20240812224658-b16b83c70700/go.mod h1:UuXvr2loD4MtvZeKr57W0WpBs+gm0KM1kdtcXrE8M6s= github.com/openchami/schemas v0.0.0-20250625220233-9aad17a286c4 h1:89rudSw0TeedlHbGr5L9WEW9lJ3yMEtY2EgxoC7EGso= github.com/openchami/schemas v0.0.0-20250625220233-9aad17a286c4/go.mod h1:3dridLqXvAdO0ypPXuxnXRgaK2h/dItVKGseCgFQ13k= -github.com/openchami/tokensmith v0.0.2 h1:Nh/6X/0KPcAD6Hb9xmSh64ktDzlDYHCmU+s7C+qG/iU= -github.com/openchami/tokensmith v0.0.2/go.mod h1:L4ZCMX/vPGwXUUn9otw+UdfFTbarv+ZVO/FjhZmoOAE= -github.com/openchami/tokensmith v0.0.3-0.20260406155037-28ca3e689493 h1:35BbJiohxq++ORGj2xETU51bW+uu5eUsp4iDAGxHlEw= -github.com/openchami/tokensmith v0.0.3-0.20260406155037-28ca3e689493/go.mod h1:L4ZCMX/vPGwXUUn9otw+UdfFTbarv+ZVO/FjhZmoOAE= -github.com/openchami/tokensmith v0.0.3-0.20260406164334-3dd5af553b4b h1:aRXW6NoiggT4Cq9Lu+gNl0r2ADUENMiS3ttFYdjYNrk= -github.com/openchami/tokensmith v0.0.3-0.20260406164334-3dd5af553b4b/go.mod h1:L4ZCMX/vPGwXUUn9otw+UdfFTbarv+ZVO/FjhZmoOAE= -github.com/openchami/tokensmith v0.3.0 h1:OdP1sQo4dXfGzWC4NEtUvfOcRxLdqR91P2yy0gRyAXQ= -github.com/openchami/tokensmith v0.3.0/go.mod h1:L4ZCMX/vPGwXUUn9otw+UdfFTbarv+ZVO/FjhZmoOAE= +github.com/openchami/tokensmith v0.3.1-0.20260408211730-d305fa0bedb3 h1:nJaBWyKECFs6ZEfEsXOszc9M2PITq+n35vAjJcMYY5U= +github.com/openchami/tokensmith v0.3.1-0.20260408211730-d305fa0bedb3/go.mod h1:L4ZCMX/vPGwXUUn9otw+UdfFTbarv+ZVO/FjhZmoOAE= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= From b73b6919c0392e836bf902e35aa9a07f8915bc41 Mon Sep 17 00:00:00 2001 From: Alex Lovell-Troy Date: Thu, 9 Apr 2026 17:24:27 -0400 Subject: [PATCH 10/10] Refactor CI/CD configuration and Dockerfile for improved build process and platform support Signed-off-by: Alex Lovell-Troy --- .github/workflows/PRBuild.yml | 88 +++++++++++++++++++++++++++-------- .goreleaser.yaml | 81 ++++++++++---------------------- Dockerfile | 8 ++-- 3 files changed, 98 insertions(+), 79 deletions(-) diff --git a/.github/workflows/PRBuild.yml b/.github/workflows/PRBuild.yml index 5354b01c..e0e2e765 100644 --- a/.github/workflows/PRBuild.yml +++ b/.github/workflows/PRBuild.yml @@ -1,4 +1,8 @@ -name: Build PR with goreleaser +# Copyright © 2025 OpenCHAMI a Series of LF Projects, LLC +# +# SPDX-License-Identifier: MIT + +name: Build each PR for testing and validation on: pull_request: @@ -6,30 +10,45 @@ on: - main types: [opened, synchronize, reopened, edited] workflow_dispatch: - + inputs: + pr_number: + description: 'PR Number to build (optional, for manual PR builds)' + required: false + type: string + +permissions: write-all # Necessary for the generate-build-provenance action with containers jobs: - prbuild: + + build: + + runs-on: ubuntu-latest - steps: - - name: Install cross-compilation tools - run: | - sudo apt-get update - sudo apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu + steps: - name: Set up latest stable Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v6.4.0 with: go-version: stable - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - + uses: docker/setup-qemu-action@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + with: + driver-opts: | + image=moby/buildkit:master + network=host + - name: Docker Login + uses: docker/login-action@v4.1.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6.0.2 with: fetch-tags: 1 - fetch-depth: 1 - + fetch-depth: 0 # Set environment variables required by GoReleaser - name: Set build environment variables run: | @@ -37,13 +56,42 @@ jobs: echo "BUILD_HOST=$(hostname)" >> $GITHUB_ENV echo "GO_VERSION=$(go version | awk '{print $3}')" >> $GITHUB_ENV echo "BUILD_USER=$(whoami)" >> $GITHUB_ENV - echo "CGO_ENABLED=1" >> $GITHUB_ENV + echo "CGO_ENABLED=0" >> $GITHUB_ENV + echo "IS_PR_BUILD=true" >> $GITHUB_ENV + + - name: Docker Login + uses: docker/login-action@v4.1.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create Tag for PR + if: github.event_name == 'pull_request' || (github.event_name == 'workflow_dispatch' && inputs.pr_number != '') + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + PR_NUM="${{ github.event.number }}" + if [[ "${{ inputs.pr_number }}" != "" ]]; then + PR_NUM="${{ inputs.pr_number }}" + fi + git tag -f -a pr-${PR_NUM} -m "PR Release" - - name: Build with goreleaser - uses: goreleaser/goreleaser-action@v6 + - name: Build/Push container with goreleaser + uses: goreleaser/goreleaser-action@v7 env: GITHUB_TOKEN: ${{ github.token }} with: - version: '~> v2' - args: build --clean --snapshot - id: goreleaser \ No newline at end of file + version: '~> 2' + args: release --clean --skip=announce,validate,archive + id: goreleaser + - name: Process goreleaser output + id: process_goreleaser_output + run: | + echo "const fs = require('fs');" > process.js + echo 'const artifacts = ${{ steps.goreleaser.outputs.artifacts }}' >> process.js + echo "const firstNonNullDigest = artifacts.find(artifact => artifact.extra && artifact.extra.Digest != null)?.extra.Digest;" >> process.js + echo "console.log(firstNonNullDigest);" >> process.js + echo "fs.writeFileSync('digest.txt', firstNonNullDigest);" >> process.js + node process.js + echo "digest=$(cat digest.txt)" >> $GITHUB_OUTPUT diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 6025b45c..f05b5846 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,8 +1,9 @@ -version: 2.4 +version: 2 project_name: smd before: hooks: # You may remove this if you don't use go modules. + - go mod download - go mod tidy builds: @@ -90,66 +91,34 @@ builds: env: - CGO_ENABLED=0 -dockers: - - image_templates: - - &amd64_linux_image ghcr.io/openchami/{{.ProjectName}}:{{ .Tag }}-amd64 - - ghcr.io/openchami/{{.ProjectName}}:{{ .Major }}-amd64 - - ghcr.io/openchami/{{.ProjectName}}:{{ .Major }}.{{ .Minor }}-amd64 - use: buildx - build_flag_templates: - - "--pull" - - "--platform=linux/amd64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.version={{.Version}}" - goarch: amd64 - goamd64: v3 - - extra_files: - - LICENSE - - CHANGELOG.md - - README.md - - migrations/ - - image_templates: - - &arm64v8_linux_image ghcr.io/openchami/{{.ProjectName}}:{{ .Tag }}-arm64 - - ghcr.io/openchami/{{.ProjectName}}:{{ .Major }}-arm64 - - ghcr.io/openchami/{{.ProjectName}}:{{ .Major }}.{{ .Minor }}-arm64 - use: buildx - build_flag_templates: - - "--pull" - - "--platform=linux/arm64" - - "--label=org.opencontainers.image.created={{.Date}}" - - "--label=org.opencontainers.image.title={{.ProjectName}}" - - "--label=org.opencontainers.image.revision={{.FullCommit}}" - - "--label=org.opencontainers.image.version={{.Version}}" +dockers_v2: + - id: smd + ids: + - smd + - smd-init + - smd-loader + images: + - ghcr.io/openchami/{{.ProjectName}} + tags: + - latest + - "{{ .Tag }}" + - "{{ .Major }}" + - "{{ .Major }}.{{ .Minor }}" + labels: + org.opencontainers.image.created: "{{.Date}}" + org.opencontainers.image.title: "{{.ProjectName}}" + org.opencontainers.image.revision: "{{.FullCommit}}" + org.opencontainers.image.version: "{{.Version}}" + platforms: + - linux/amd64 + - linux/arm64 + flags: + - --pull extra_files: - LICENSE - CHANGELOG.md - README.md - migrations/ - goarch: arm64 - -docker_manifests: - - name_template: "ghcr.io/openchami/{{.ProjectName}}:latest" - image_templates: - - *amd64_linux_image - - *arm64v8_linux_image - - - name_template: "ghcr.io/openchami/{{.ProjectName}}:{{ .Tag }}" - image_templates: - - *amd64_linux_image - - *arm64v8_linux_image - - - name_template: "ghcr.io/openchami/{{.ProjectName}}:{{ .Major }}" - image_templates: - - *amd64_linux_image - - *arm64v8_linux_image - - - name_template: "ghcr.io/openchami/{{.ProjectName}}:{{ .Major }}.{{ .Minor }}" - image_templates: - - *amd64_linux_image - - *arm64v8_linux_image archives: - format: tar.gz diff --git a/Dockerfile b/Dockerfile index 5f47394d..a6422d9f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,9 +9,11 @@ RUN set -ex \ && rm -rf /var/cache/apk/* \ && rm -rf /tmp/* -COPY smd / -COPY smd-loader / -COPY smd-init / +ARG TARGETPLATFORM + +COPY $TARGETPLATFORM/smd / +COPY $TARGETPLATFORM/smd-loader / +COPY $TARGETPLATFORM/smd-init / RUN mkdir /persistent_migrations COPY migrations/* /persistent_migrations/