diff --git a/.github/workflows/push-pr-lint.yaml b/.github/workflows/push-pr-lint.yaml index 6197340..a4ced01 100644 --- a/.github/workflows/push-pr-lint.yaml +++ b/.github/workflows/push-pr-lint.yaml @@ -21,6 +21,8 @@ jobs: - name: Test run: go test ./... + with: + args: -tags testtools build: runs-on: ubuntu-latest diff --git a/.golangci.yml b/.golangci.yml index 5cfb288..b5456fc 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -3,11 +3,14 @@ service: golangci-lint-version: 1.55.2 # use the fixed version to not introduce new linters unexpectedly +run: + build-tags: + - testtools + linters-settings: govet: enable: auto-fix: true - check-shadowing: true settings: printf: funcs: @@ -70,12 +73,9 @@ linters: enable-all: false disable-all: true -run: - # build-tags: - skip-dirs: - - internal/fixtures - issues: + exclude-dirs: + - internal/fixtures exclude-rules: - linters: - gosec @@ -108,3 +108,5 @@ issues: # EXC0010 gosec: False positive is triggered by 'src, err := ioutil.ReadFile(filename)' - Potential file inclusion via variable exclude-use-default: false + +shadow: true \ No newline at end of file diff --git a/.mockery.yaml b/.mockery.yaml index 45f48b8..548e29d 100644 --- a/.mockery.yaml +++ b/.mockery.yaml @@ -1,19 +1,6 @@ testonly: False with-expecter: True packages: - github.com/metal-toolbox/rivets/events/controller: - config: - dir: events/controller - fileName: "mock_{{.InterfaceName | firstLower}}.go" - inpackage: True - interfaces: - TaskHandler: - Publisher: - StatusPublisher: - ConditionStatusQueryor: - ConditionStatusPublisher: - eventStatusAcknowleger: - LivenessCheckin: github.com/metal-toolbox/rivets/events: config: dir: events/ diff --git a/Makefile b/Makefile index 77830c7..7d36407 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ lint: ## Go test test: lint - CGO_ENABLED=0 go test -timeout 1m -v -covermode=atomic ./... + CGO_ENABLED=0 go test -tags testtools -timeout 1m -v -covermode=atomic ./... ## Generate mocks gen-mock: diff --git a/ginauth/doc.go b/ginauth/doc.go new file mode 100644 index 0000000..e51f828 --- /dev/null +++ b/ginauth/doc.go @@ -0,0 +1,2 @@ +// Package ginauth provides a authentication and authorization middleware for use with a gin server +package ginauth diff --git a/ginauth/errors.go b/ginauth/errors.go new file mode 100644 index 0000000..dfa4f18 --- /dev/null +++ b/ginauth/errors.go @@ -0,0 +1,104 @@ +package ginauth + +import ( + "errors" + "fmt" + "net/http" +) + +var ( + // ErrInvalidMiddlewareReference the middleware added was invalid + ErrInvalidMiddlewareReference = errors.New("invalid middleware") + + // ErrMiddlewareRemote is the error returned when the middleware couldn't contact the remote endpoint + ErrMiddlewareRemote = errors.New("middleware setup") + + // ErrAuthentication defines a generic authentication error. This specifies that we couldn't + // validate a token for some reason. This is not to be used as-is but is useful for type + // comparison with the `AuthError` struct. + ErrAuthentication = errors.New("authentication error") + + // ErrInvalidSigningKey is the error returned when a token can not be verified because the signing key in invalid + // NOTE(jaosorior): The fact that this is in this package is a little hacky... but it's to not have a + // circular dependency with the ginjwt package. + ErrInvalidSigningKey = errors.New("invalid token signing key") +) + +// AuthError represents an auth error coming from a middleware function +type AuthError struct { + HTTPErrorCode int + err error +} + +// NewAuthenticationError returns an authentication error which is due +// to not being able to determine who's the requestor (e.g. authentication error) +func NewAuthenticationError(msg string) *AuthError { + return &AuthError{ + HTTPErrorCode: http.StatusUnauthorized, + //nolint:goerr113 // it must be dynamic here + err: errors.New(msg), + } +} + +// NewAuthenticationErrorFrom returns an authentication error which is due +// to not being able to determine who's the requestor (e.g. authentication error). +// The error is based on another one (it wraps it). +func NewAuthenticationErrorFrom(err error) *AuthError { + return &AuthError{ + HTTPErrorCode: http.StatusUnauthorized, + err: err, + } +} + +// NewAuthorizationError returns an authorization error which is due to +// not being able to determine what the requestor can do (e.g. authorization error) +func NewAuthorizationError(msg string) *AuthError { + return &AuthError{ + HTTPErrorCode: http.StatusForbidden, + //nolint:goerr113 // it must be dynamic here + err: errors.New(msg), + } +} + +// Error ensures AuthenticationError implements the error interface +func (ae *AuthError) Error() string { + return ae.err.Error() +} + +// Unwrap ensures that we're able to verify that this is indeed +// an authentication error +func (ae *AuthError) Unwrap() error { + return ErrAuthentication +} + +// TokenValidationError specifies that there was an authentication error +// due to the token being invalid +type TokenValidationError struct { + AuthError +} + +// Error ensures AuthenticationError implements the error interface +func (tve *TokenValidationError) Error() string { + return fmt.Sprintf("invalid auth token: %s", &tve.AuthError) +} + +// Unwrap allows TokenValidationError to be detected as an AuthError. +func (tve *TokenValidationError) Unwrap() error { + return &tve.AuthError +} + +// NewTokenValidationError returns a TokenValidationError that wraps the given error +func NewTokenValidationError(err error) error { + return &TokenValidationError{ + AuthError: AuthError{ + HTTPErrorCode: http.StatusUnauthorized, + err: err, + }, + } +} + +// NewInvalidSigningKeyError returns an AuthError that indicates +// that the signing key used to validate the token was not valid +func NewInvalidSigningKeyError() error { + return NewAuthenticationErrorFrom(ErrInvalidSigningKey) +} diff --git a/ginauth/genericmiddleware.go b/ginauth/genericmiddleware.go new file mode 100644 index 0000000..847cab5 --- /dev/null +++ b/ginauth/genericmiddleware.go @@ -0,0 +1,20 @@ +package ginauth + +import ( + "github.com/gin-gonic/gin" +) + +// ClaimMetadata returns the minimal relevant information so middleware +// can set the appropriate metadata to a context (e.g. a gin.Context) +type ClaimMetadata struct { + Subject string + User string + Roles []string +} + +// GenericAuthMiddleware defines middleware that verifies a token coming from a gin.Context. +// Note that this can be stacked together using the MultiTokenMiddleware construct. +type GenericAuthMiddleware interface { + VerifyTokenWithScopes(*gin.Context, []string) (ClaimMetadata, error) + SetMetadata(*gin.Context, ClaimMetadata) +} diff --git a/ginauth/middleware.go b/ginauth/middleware.go new file mode 100644 index 0000000..5a588bb --- /dev/null +++ b/ginauth/middleware.go @@ -0,0 +1,25 @@ +package ginauth + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" +) + +// AbortBecauseOfError aborts a gin context based on a given error +func AbortBecauseOfError(c *gin.Context, err error) { + var authErr *AuthError + + var validationErr *TokenValidationError + + switch { + case errors.As(err, &validationErr): + c.AbortWithStatusJSON(validationErr.HTTPErrorCode, gin.H{"message": "invalid auth token", "error": validationErr.Error()}) + case errors.As(err, &authErr): + c.AbortWithStatusJSON(authErr.HTTPErrorCode, gin.H{"message": authErr.Error()}) + default: + // If we can't cast it, unauthorize anyway + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"message": err.Error()}) + } +} diff --git a/ginauth/multitokenmiddleware.go b/ginauth/multitokenmiddleware.go new file mode 100644 index 0000000..5755e44 --- /dev/null +++ b/ginauth/multitokenmiddleware.go @@ -0,0 +1,100 @@ +package ginauth + +import ( + "errors" + "fmt" + "sync" + + "github.com/gin-gonic/gin" +) + +// MultiTokenMiddleware Allows for concurrently verifying a token +// using different middleware implementations. This relies on implementing +// the GenericAuthMiddleware interface. +// Only the first detected success will be taken into account. +// Note that middleware objects don't have to be of Middleware type, that's +// only one object that implements the interface. +type MultiTokenMiddleware struct { + verifiers []GenericAuthMiddleware +} + +// NewMultiTokenMiddleware builds a MultiTokenMiddleware object from multiple AuthConfigs. +func NewMultiTokenMiddleware() (*MultiTokenMiddleware, error) { + mtm := &MultiTokenMiddleware{} + mtm.verifiers = make([]GenericAuthMiddleware, 0) + + return mtm, nil +} + +// Add will append another middleware object (or verifier) to the list +// which we'll use to check concurrently +func (mtm *MultiTokenMiddleware) Add(middleware GenericAuthMiddleware) error { + if middleware == nil { + return fmt.Errorf("%w: %s", ErrInvalidMiddlewareReference, "The middleware reference can't be nil") + } + + mtm.verifiers = append(mtm.verifiers, middleware) + + return nil +} + +// AuthRequired is similar to the `AuthRequired` function from the Middleware type +// in the sense that it'll evaluate the scopes and the token coming from the context. +// However, this will concurrently evaluate them with the middlewares configured in this +// struct +func (mtm *MultiTokenMiddleware) AuthRequired(scopes []string) gin.HandlerFunc { + return func(c *gin.Context) { + var wg sync.WaitGroup + + res := make(chan error, len(mtm.verifiers)) + + wg.Add(len(mtm.verifiers)) + + for _, verifier := range mtm.verifiers { + go func(v GenericAuthMiddleware, c *gin.Context, r chan<- error) { + defer wg.Done() + + cm, err := v.VerifyTokenWithScopes(c, scopes) + + if err != nil { + v.SetMetadata(c, cm) + } + + r <- err + }(verifier, c, res) + } + + wg.Wait() + close(res) + + var surfacingErr error + + for err := range res { + if err == nil { + // NOTE(jaosorior): This takes the first non-error as a success. + // It would be quite strange if we would get multiple successes. + return + } + + // initialize surfacingErr. + if surfacingErr == nil { + surfacingErr = err + continue + } + + // If we previously had an error related to having an invalid signing key + // we overwrite the error to be surfaced. We care more about other types of + // errors, such as not having the appropriate scope + // Also, if we previously had an error with the remote endpoint, we override the error. + // This might be a very general error and more specific ones are preferred + // for surfacing. + if errors.Is(surfacingErr, ErrMiddlewareRemote) || errors.Is(surfacingErr, ErrInvalidSigningKey) { + surfacingErr = err + } + } + + if surfacingErr != nil { + AbortBecauseOfError(c, surfacingErr) + } + } +} diff --git a/ginauth/multitokenmiddleware_test.go b/ginauth/multitokenmiddleware_test.go new file mode 100644 index 0000000..80d0f88 --- /dev/null +++ b/ginauth/multitokenmiddleware_test.go @@ -0,0 +1,282 @@ +package ginauth_test + +import ( + "crypto/rsa" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/square/go-jose.v2" + "gopkg.in/square/go-jose.v2/jwt" + + "github.com/metal-toolbox/rivets/ginauth" + "github.com/metal-toolbox/rivets/ginjwt" +) + +func TestMultitokenMiddlewareValidatesTokens(t *testing.T) { + var testCases = []struct { + testName string + middlewareAud string + middlewareIss string + middlewareScopes []string + signingKey *rsa.PrivateKey + signingKeyID string + claims jwt.Claims + claimScopes []string + responseCode int + responseBody string + }{ + { + "unknown keyid", + "ginjwt.test", + "ginjwt.test.issuer2", + []string{"testScope"}, + ginjwt.TestPrivRSAKey1, + "randomUnknownID", + jwt.Claims{ + Subject: "test-user", + Issuer: "ginjwt.test.issuer", + NotBefore: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)), + Audience: jwt.Audience{"ginjwt.test", "another.test.service"}, + }, + []string{"testScope", "anotherScope", "more-scopes"}, + http.StatusUnauthorized, + "invalid token signing key", + }, + { + "incorrect keyid", + "ginjwt.test", + "ginjwt.test.issuer2", + []string{"testScope"}, + ginjwt.TestPrivRSAKey1, + ginjwt.TestPrivRSAKey2ID, + jwt.Claims{ + Subject: "test-user", + Issuer: "ginjwt.test.issuer", + NotBefore: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)), + Audience: jwt.Audience{"ginjwt.test", "another.test.service"}, + }, + []string{"testScope", "anotherScope", "more-scopes"}, + http.StatusUnauthorized, + "unable to validate auth token", + }, + { + "incorrect issuer", + "ginjwt.test", + "ginjwt.test.issuer2", + []string{"testScope"}, + ginjwt.TestPrivRSAKey1, + ginjwt.TestPrivRSAKey1ID, + jwt.Claims{ + Subject: "test-user", + Issuer: "ginjwt.test.issuer", + NotBefore: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)), + Audience: jwt.Audience{"ginjwt.test", "another.test.service"}, + }, + []string{"testScope", "anotherScope", "more-scopes"}, + http.StatusUnauthorized, + "invalid issuer claim", + }, + { + "incorrect audience", + "ginjwt.testFail", + "ginjwt.test.issuer", + []string{"testScope"}, + ginjwt.TestPrivRSAKey1, + ginjwt.TestPrivRSAKey1ID, + jwt.Claims{ + Subject: "test-user", + Issuer: "ginjwt.test.issuer", + NotBefore: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)), + Audience: jwt.Audience{"ginjwt.test", "another.test.service"}, + }, + []string{"testScope", "anotherScope", "more-scopes"}, + http.StatusUnauthorized, + "invalid audience claim", + }, + { + "incorrect scopes", + "ginjwt.test", + "ginjwt.test.issuer", + []string{"adminscope"}, + ginjwt.TestPrivRSAKey1, + ginjwt.TestPrivRSAKey1ID, + jwt.Claims{ + Subject: "test-user", + Issuer: "ginjwt.test.issuer", + NotBefore: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)), + Audience: jwt.Audience{"ginjwt.test", "another.test.service"}, + }, + []string{"testScope", "anotherScope", "more-scopes"}, + http.StatusForbidden, + "missing required scope", + }, + { + "expired token", + "ginjwt.test", + "ginjwt.test.issuer", + []string{"testScope"}, + ginjwt.TestPrivRSAKey1, + ginjwt.TestPrivRSAKey1ID, + jwt.Claims{ + Subject: "test-user", + Issuer: "ginjwt.test.issuer", + NotBefore: jwt.NewNumericDate(time.Now().Add(-6 * time.Hour)), + Expiry: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)), + Audience: jwt.Audience{"ginjwt.test", "another.test.service"}, + }, + []string{"testScope", "anotherScope", "more-scopes"}, + http.StatusUnauthorized, + "token is expired", + }, + { + "future token", + "ginjwt.test", + "ginjwt.test.issuer", + []string{"testScope"}, + ginjwt.TestPrivRSAKey1, + ginjwt.TestPrivRSAKey1ID, + jwt.Claims{ + Subject: "test-user", + Issuer: "ginjwt.test.issuer", + NotBefore: jwt.NewNumericDate(time.Now().Add(6 * time.Hour)), + Audience: jwt.Audience{"ginjwt.test", "another.test.service"}, + }, + []string{"testScope", "anotherScope", "more-scopes"}, + http.StatusUnauthorized, + "token not valid yet", + }, + { + "happy path from first provider", + "ginjwt.test", + "ginjwt.test.issuer", + []string{"testScope"}, + ginjwt.TestPrivRSAKey1, + ginjwt.TestPrivRSAKey1ID, + jwt.Claims{ + Subject: "test-user", + Issuer: "ginjwt.test.issuer", + NotBefore: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)), + Audience: jwt.Audience{"ginjwt.test", "another.test.service"}, + }, + []string{"testScope", "anotherScope", "more-scopes"}, + http.StatusOK, + "ok", + }, + { + "happy path from second provider", + "ginjwt.test", + "ginjwt.test.issuer", + []string{"testScope"}, + ginjwt.TestPrivRSAKey3, + ginjwt.TestPrivRSAKey3ID, + jwt.Claims{ + Subject: "test-user", + Issuer: "ginjwt.test.issuer", + NotBefore: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)), + Audience: jwt.Audience{"ginjwt.test", "another.test.service"}, + }, + []string{"testScope", "anotherScope", "more-scopes"}, + http.StatusOK, + "ok", + }, + } + + for _, tt := range testCases { + t.Run(tt.testName, func(t *testing.T) { + jwksURI1 := ginjwt.TestHelperJWKSProvider(ginjwt.TestPrivRSAKey1ID, ginjwt.TestPrivRSAKey2ID) + jwksURI2 := ginjwt.TestHelperJWKSProvider(ginjwt.TestPrivRSAKey3ID, ginjwt.TestPrivRSAKey4ID) + + cfg1 := ginjwt.AuthConfig{Enabled: true, Audience: tt.middlewareAud, Issuer: tt.middlewareIss, JWKSURI: jwksURI1} + cfg2 := ginjwt.AuthConfig{Enabled: true, Audience: tt.middlewareAud, Issuer: tt.middlewareIss, JWKSURI: jwksURI2} + authMW, err := ginjwt.NewMultiTokenMiddlewareFromConfigs(cfg1, cfg2) + require.NoError(t, err) + + // We add an extra failing remote middleware, these errors shouldn't surface. + addErr := authMW.Add(ginauth.NewRemoteMiddleware("http://foo-bar.unexistent", 0)) + require.NoError(t, addErr) + + r := gin.New() + r.Use(authMW.AuthRequired(tt.middlewareScopes)) + r.GET("/", func(c *gin.Context) { + c.JSON(http.StatusOK, "ok") + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "http://test/", http.NoBody) + + signer := ginjwt.TestHelperMustMakeSigner(jose.RS256, tt.signingKeyID, tt.signingKey) + rawToken := ginjwt.TestHelperGetToken(signer, tt.claims, "scope", strings.Join(tt.claimScopes, " ")) + req.Header.Set("Authorization", fmt.Sprintf("bearer %s", rawToken)) + + r.ServeHTTP(w, req) + + assert.Equal(t, tt.responseCode, w.Code) + assert.Contains(t, w.Body.String(), tt.responseBody) + }) + } +} + +func TestMultitokenInvalidAuthHeader(t *testing.T) { + var testCases = []struct { + testName string + authHeader string + responseCode int + responseContains string + }{ + { + "no auth header", + "", + http.StatusUnauthorized, + "missing authorization header", + }, + { + "wrong format", + "notbearer token", + http.StatusUnauthorized, + "invalid authorization header", + }, + { + "invalid token", + "bearer token", + http.StatusUnauthorized, + "unable to parse auth token", + }, + { + "token with no kid", + "bearer eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJpc3N1ZXIiLCJzY29wZXMiOlsiczEiLCJzMiJdLCJzdWIiOiJzdWJqZWN0In0.UDDtyK9gC9kyHltcP7E_XODsnqcJWZIiXeGmSAH7SE9YKy3N0KSfFIN85dCNjTfs6zvy4rkrCHzLB7uKAtzMearh3q7jL4nxbhUMhlUcs_9QDVoN4q_j58XmRqBqRnBk-RmDu9TgcV8RbErP4awpIhwWb5UU-hR__4_iNbHdKqwSUPDKYGlf5eicuiYrPxH8mxivk4LRD-vyRdBZZKBt0XIDnEU4TdcNCzAXojkftqcFWYsczwS8R4JHd1qYsMyiaWl4trdHZkO4QkeLe34z4ZAaPMt3wE-gcU-VoqYTGxz-K3Le2VaZ0r3j_z6bOInsv0yngC_cD1dCXMyQJWnWjQ", + http.StatusUnauthorized, + "unable to parse auth token header", + }, + } + + for _, tt := range testCases { + t.Run(tt.testName, func(t *testing.T) { + jwksURI := ginjwt.TestHelperJWKSProvider(ginjwt.TestPrivRSAKey1ID, ginjwt.TestPrivRSAKey2ID) + cfg := ginjwt.AuthConfig{Enabled: true, Audience: "aud", Issuer: "iss", JWKSURI: jwksURI} + authMW, err := ginjwt.NewMultiTokenMiddlewareFromConfigs(cfg) + require.NoError(t, err) + + r := gin.New() + r.Use(authMW.AuthRequired([]string{"auth"})) + r.GET("/", func(c *gin.Context) { + c.JSON(http.StatusOK, "ok") + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "http://test/", http.NoBody) + + req.Header.Set("Authorization", tt.authHeader) + r.ServeHTTP(w, req) + + assert.Equal(t, tt.responseCode, w.Code) + assert.Contains(t, w.Body.String(), tt.responseContains) + }) + } +} diff --git a/ginauth/remote.go b/ginauth/remote.go new file mode 100644 index 0000000..316bc98 --- /dev/null +++ b/ginauth/remote.go @@ -0,0 +1,139 @@ +package ginauth + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/gin-gonic/gin" +) + +const ( + // We might want to standardize these into exportable constants + contextKeySubject = "jwt.subject" + contextKeyUser = "jwt.user" +) + +// NewAuthRequestV1FromScopes creates an AuthRequest structure from the given scopes +func NewAuthRequestV1FromScopes(scopes []string) *AuthRequestV1 { + return &AuthRequestV1{ + AuthMeta: AuthMeta{ + Version: AuthRequestVersion1, + }, + Scopes: scopes, + } +} + +// RemoteMiddleware defines middleware that relies on a remote endpoint +// in order to get an authorization decision +type RemoteMiddleware struct { + url string + timeout time.Duration +} + +// NewRemoteMiddleware returns an instance of RemoteMiddleware +// TODO(jaosorior) Pass in TLS parameters +func NewRemoteMiddleware(url string, timeout time.Duration) *RemoteMiddleware { + return &RemoteMiddleware{ + url: url, + timeout: timeout, + } +} + +// SetMetadata ensures metadata is set in the gin Context +func (rm *RemoteMiddleware) SetMetadata(c *gin.Context, cm ClaimMetadata) { + if cm.Subject != "" { + c.Set(contextKeySubject, cm.Subject) + } + + if cm.User != "" { + c.Set(contextKeyUser, cm.User) + } +} + +// VerifyTokenWithScopes verifies a given token (from the gin Context) against the given scope +// using a remote server +func (rm *RemoteMiddleware) VerifyTokenWithScopes(c *gin.Context, scopes []string) (ClaimMetadata, error) { + cli := &http.Client{ + Timeout: rm.timeout, + } + origRequest := c.Request + areq := NewAuthRequestV1FromScopes(scopes) + + reqbody, merr := json.Marshal(areq) + if merr != nil { + return ClaimMetadata{}, fmt.Errorf("%w: %s", ErrMiddlewareRemote, merr) + } + + // We forward the original request method that was done to the target service. + // That's part of what we're authorizing. + req, reqerr := http.NewRequestWithContext(c.Request.Context(), origRequest.Method, rm.url, bytes.NewBuffer(reqbody)) + if reqerr != nil { + return ClaimMetadata{}, fmt.Errorf("%w: %s", ErrMiddlewareRemote, reqerr) + } + + req.Header.Add("Accept", `application/json`) + + // Forward authorization header + req.Header.Set("Authorization", origRequest.Header.Get("Authorization")) + + resp, resperr := cli.Do(req) + if resperr != nil { + return ClaimMetadata{}, fmt.Errorf("%w: %s", ErrMiddlewareRemote, resperr) + } + + defer resp.Body.Close() + + body, readerr := io.ReadAll(resp.Body) + if readerr != nil { + return ClaimMetadata{}, fmt.Errorf("%w: %s", ErrMiddlewareRemote, readerr) + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusForbidden && resp.StatusCode != http.StatusUnauthorized { + return ClaimMetadata{}, fmt.Errorf("%w: %s", ErrMiddlewareRemote, body) + } + + authResp := AuthResponseV1{} + + unmarshallerr := json.Unmarshal(body, &authResp) + if unmarshallerr != nil { + return ClaimMetadata{}, NewAuthenticationError(unmarshallerr.Error()) + } + + if !authResp.Authed { + return ClaimMetadata{}, NewAuthenticationError(authResp.Message) + } + + // TODO(jaosorior): Should we fail the request if no appropriate + // response is provided? + if authResp.Details == nil { + // The request was approved but no metadata was given back + return ClaimMetadata{}, nil + } + + cm := ClaimMetadata{ + Subject: authResp.Details.Subject, + User: authResp.Details.User, + } + if authResp.Details.User == "" { + cm.User = authResp.Details.Subject + } + + return cm, nil +} + +// AuthRequired provides a middleware that ensures a request has authentication +func (rm *RemoteMiddleware) AuthRequired(scopes []string) gin.HandlerFunc { + return func(c *gin.Context) { + cm, err := rm.VerifyTokenWithScopes(c, scopes) + if err != nil { + AbortBecauseOfError(c, err) + return + } + + rm.SetMetadata(c, cm) + } +} diff --git a/ginauth/remote_api.go b/ginauth/remote_api.go new file mode 100644 index 0000000..3217f1b --- /dev/null +++ b/ginauth/remote_api.go @@ -0,0 +1,34 @@ +package ginauth + +const ( + // AuthRequestVersion1 defines version 1 of the AuthRequest message format + AuthRequestVersion1 = "v1" +) + +// AuthMeta holds metdata for an AuthRequest +type AuthMeta struct { + Version string `json:"version"` +} + +// AuthRequestV1 holds a simple auth request which asks a remote +// endpoint for an authorization decision based on the given scopes +type AuthRequestV1 struct { + AuthMeta `json:",inline"` + Scopes []string `json:"scopes"` +} + +// AuthResponseV1 holds a simple auth response which denotes +// the auth decision. Note that the decision will also be +// reflected in the HTTP status code. +type AuthResponseV1 struct { + AuthMeta `json:",inline"` + Authed bool `json:"auth"` + Message string `json:"message"` + Details *SuccessAuthDetailsV1 `json:"details,omitempty"` +} + +// SuccessAuthDetailsV1 holds a simple and successful auth response. +type SuccessAuthDetailsV1 struct { + Subject string `json:"subject"` + User string `json:"user,omitempty"` +} diff --git a/ginauth/remote_test.go b/ginauth/remote_test.go new file mode 100644 index 0000000..ea7fdfd --- /dev/null +++ b/ginauth/remote_test.go @@ -0,0 +1,134 @@ +package ginauth_test + +import ( + "fmt" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + + "github.com/metal-toolbox/rivets/ginauth" +) + +func getNewTestRemoteAuthServer(resp *ginauth.AuthResponseV1, forcedSleep time.Duration) string { + gin.SetMode(gin.TestMode) + + r := gin.New() + + statusResp := http.StatusUnauthorized + + if resp.Authed { + statusResp = http.StatusOK + } + + r.GET("/v1", func(c *gin.Context) { + time.Sleep(forcedSleep) + c.JSON(statusResp, resp) + }) + + //nolint:gosec // its a test, we dont care + listener, err := net.Listen("tcp", ":0") + if err != nil { + panic(err) + } + + //nolint:gosec // its a test, we dont care + s := &http.Server{ + Handler: r, + } + + go func() { + if err := s.Serve(listener); err != nil { + panic(err) + } + }() + + return fmt.Sprintf("http://localhost:%d/v1", listener.Addr().(*net.TCPAddr).Port) +} + +func TestRemoteMiddleware(t *testing.T) { + tests := []struct { + name string + expectedResponse *ginauth.AuthResponseV1 + responseCode int + shouldTimeout bool + }{ + { + "test happy path", + &ginauth.AuthResponseV1{ + AuthMeta: ginauth.AuthMeta{ + Version: "v1", + }, + Authed: true, + Message: "authenticated", + Details: &ginauth.SuccessAuthDetailsV1{ + Subject: "foo", + }, + }, + http.StatusOK, + false, + }, + { + "test rejection", + &ginauth.AuthResponseV1{ + AuthMeta: ginauth.AuthMeta{ + Version: "v1", + }, + Authed: false, + Message: "operation not permitted", + }, + http.StatusUnauthorized, + false, + }, + { + "test rejection due to timeout", + &ginauth.AuthResponseV1{ + AuthMeta: ginauth.AuthMeta{ + Version: "v1", + }, + Authed: true, + Message: "operation not permitted", + }, + // If the server times out we treat it as a rejection + http.StatusUnauthorized, + true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + serverLag := 0 * time.Second + middlewareTimeout := 0 * time.Second + + if tt.shouldTimeout { + serverLag = 5 * time.Second + middlewareTimeout = 1 * time.Second + } + + authServerURL := getNewTestRemoteAuthServer(tt.expectedResponse, serverLag) + rm := ginauth.NewRemoteMiddleware(authServerURL, middlewareTimeout) + r := gin.New() + + // Scopes are kind of irrelevant right now as they are to be + // handled on the server side. So we can just hard-code them here for now. + r.Use(rm.AuthRequired([]string{"auth"})) + r.GET("/", func(c *gin.Context) { + c.JSON(http.StatusOK, "ok") + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "http://test/", http.NoBody) + + // We'll be testing explicitly expected responses. It's up to the server to + // actually validate this token. + req.Header.Set("Authorization", "bearer foo") + + r.ServeHTTP(w, req) + + assert.Equal(t, tt.responseCode, w.Code) + }) + } +} diff --git a/ginjwt/doc.go b/ginjwt/doc.go new file mode 100644 index 0000000..18e5329 --- /dev/null +++ b/ginjwt/doc.go @@ -0,0 +1,2 @@ +// Package ginjwt provides a JWT authentication and authorization middleware for use with a gin server +package ginjwt diff --git a/ginjwt/errors.go b/ginjwt/errors.go new file mode 100644 index 0000000..402749f --- /dev/null +++ b/ginjwt/errors.go @@ -0,0 +1,28 @@ +package ginjwt + +import ( + "errors" +) + +var ( + // ErrInvalidAudience is the error returned when the audience of the token isn't what we expect + ErrInvalidAudience = errors.New("invalid JWT audience") + + // ErrInvalidIssuer is the error returned when the issuer of the token isn't what we expect + ErrInvalidIssuer = errors.New("invalid JWT issuer") + + // ErrInvalidAuthConfig is an error returned when the oidc auth config isn't able to be unmarshaled + ErrInvalidAuthConfig = errors.New("invalid oidc config provided") + + // ErrMissingAuthConfig is an error returned when the oidc auth config isn't provided via a command line flag. + ErrMissingAuthConfig = errors.New("oidc auth config wasn't provided") + + // ErrMissingIssuerFlag is an error returned when the issuer isn't provided via a command line flag. + ErrMissingIssuerFlag = errors.New("issuer wasn't provided") + + // ErrMissingJWKURIFlag is an error returned when the JWK URI isn't provided via a command line flag. + ErrMissingJWKURIFlag = errors.New("JWK URI wasn't provided") + + // ErrJWKSConfigConflict is an error when both JWKSURI and JWKS are set + ErrJWKSConfigConflict = errors.New("JWKS and JWKSURI can't both be set at the same time") +) diff --git a/ginjwt/helpers.go b/ginjwt/helpers.go new file mode 100644 index 0000000..ac46b02 --- /dev/null +++ b/ginjwt/helpers.go @@ -0,0 +1,176 @@ +package ginjwt + +import ( + "time" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "github.com/spf13/viper" +) + +// OIDCConfig provides the configuration for the oidc provider auth configuration +type OIDCConfig struct { + Enabled bool `yaml:"enabled"` + Audience string `yaml:"audience"` + Issuer string `yaml:"issuer"` + JWKSURI string `yaml:"jwsuri"` + JWKSRemoteTimeout time.Duration `yaml:"jwksremotetimeout"` + RoleValidationStrategy RoleValidationStrategy `yaml:"rolevalidationstrategy"` + Claims Claims `yaml:"claims"` +} + +// Claims defines the roles and username claims for the given oidc provider +type Claims struct { + Roles string `yaml:"roles"` + Username string `yaml:"username"` +} + +// RegisterViperOIDCFlags ensures that the given Viper and cobra.Command instances +// have the following command line/configuration flags registered: +// +// - oidc: Enables/disables OIDC authentication. +// +// - oidc-aud: Specifies the expected audience for the JWT token. +// +// - oidc-issuer: Specifies the expected issuer for the JWT token (can be more than one value). +// +// - oidc-jwksuri: Specifies the JSON Web Key Set (JWKS) URI (can be more than one value). +// +// - oidc-roles-claim: Specifies the roles to be accepted for the JWT claim. +// +// - oidc-username-claim: Specifies a username to use for the JWT claim. +// +// - oidc-jwks-remotetimeout: Specifies a timeout for the JWKS URI. +// +// A call to this would normally look as follows: +// +// ginjwt.RegisterViperOIDCFlags(viper.GetViper(), serveCmd) +// +// The oidc configuration should be passed in through a yaml file due to the nested +// structure of the fields, however, if only one oidc provider is used the flag parameters would work. +func RegisterViperOIDCFlags(v *viper.Viper, cmd *cobra.Command) { + cmd.Flags().Bool("oidc", true, "use oidc auth") + BindFlagFromViperInst(v, "oidc.enabled", cmd.Flags().Lookup("oidc")) + cmd.Flags().String("oidc-aud", "", "expected audience on OIDC JWT") + BindFlagFromViperInst(v, "oidc.audience", cmd.Flags().Lookup("oidc-aud")) + cmd.Flags().StringSlice("oidc-issuer", []string{}, "expected issuer of OIDC JWT") + BindFlagFromViperInst(v, "oidc.issuer", cmd.Flags().Lookup("oidc-issuer")) + cmd.Flags().StringSlice("oidc-jwksuri", []string{}, "URI for JWKS listing for JWTs") + BindFlagFromViperInst(v, "oidc.jwksuri", cmd.Flags().Lookup("oidc-jwksuri")) + cmd.Flags().String("oidc-roles-claim", "claim", "field containing the permissions of an OIDC JWT") + BindFlagFromViperInst(v, "oidc.claims.roles", cmd.Flags().Lookup("oidc-roles-claim")) + cmd.Flags().String("oidc-username-claim", "", "additional fields to output in logs from the JWT token, ex (email)") + BindFlagFromViperInst(v, "oidc.claims.username", cmd.Flags().Lookup("oidc-username-claim")) + cmd.Flags().Duration("oidc-jwks-remote-timeout", 1*time.Minute, "timeout for remote JWKS fetching") + BindFlagFromViperInst(v, "oidc.jwksremotetimeout", cmd.Flags().Lookup("oidc-jwks-remote-timeout")) + cmd.Flags().String("oidc-role-validation-strategy", string(RoleValidationStrategyAny), "validation strategy for roles (any or all)") + BindFlagFromViperInst(v, "oidc.rolevalidationstrategy", cmd.Flags().Lookup("oidc-role-validation-strategy")) +} + +// GetAuthConfigFromFlags builds an AuthConfig object from flags provided by +// the viper tooling. This utility function assumes that the +// `RegisterViperOIDCFlags` function was called beforehand. +// +// A call to this would normally look as follows: +// +// ginjwt.GetAuthConfigFromFlags(viper.GetViper()) +// +// Note that when using this function configuration +func GetAuthConfigFromFlags(v *viper.Viper) (AuthConfig, error) { + var authConfigs []OIDCConfig + if err := v.UnmarshalKey("oidc", &authConfigs); err != nil { + return AuthConfig{}, ErrInvalidAuthConfig + } + + if len(authConfigs) == 0 { + return AuthConfig{}, ErrMissingAuthConfig + } + + config := authConfigs[0] + + if !config.Enabled { + return AuthConfig{}, nil + } + + if config.Issuer == "" { + return AuthConfig{}, ErrMissingIssuerFlag + } + + if config.JWKSURI == "" { + return AuthConfig{}, ErrMissingJWKURIFlag + } + + return AuthConfig{ + Enabled: config.Enabled, + Audience: config.Audience, + Issuer: config.Issuer, + JWKSURI: config.JWKSURI, + JWKSRemoteTimeout: config.JWKSRemoteTimeout, + RoleValidationStrategy: config.RoleValidationStrategy, + RolesClaim: config.Claims.Roles, + UsernameClaim: config.Claims.Username, + }, nil +} + +// GetAuthConfigsFromFlags builds AuthConfig objects from flags provided by +// the viper tooling. This utility function assumes that the +// `RegisterViperOIDCFlags` function was called beforehand. +// +// A call to this would normally look as follows: +// +// ginjwt.GetAuthConfigsFromFlags(viper.GetViper()) +// +// Note that this function will retrieve as many AuthConfigs as the number +// of issuers and JWK URIs given (which must match) +func GetAuthConfigsFromFlags(v *viper.Viper) ([]AuthConfig, error) { + var authConfigs []OIDCConfig + if err := v.UnmarshalKey("oidc", &authConfigs); err != nil { + return []AuthConfig{}, ErrInvalidAuthConfig + } + + if len(authConfigs) == 0 { + return []AuthConfig{}, ErrMissingAuthConfig + } + + var authcfgs []AuthConfig + + for _, c := range authConfigs { + if c.Enabled { + if c.Issuer == "" { + return []AuthConfig{}, ErrMissingIssuerFlag + } + + if c.JWKSURI == "" { + return []AuthConfig{}, ErrMissingJWKURIFlag + } + + authcfgs = append(authcfgs, + AuthConfig{ + Enabled: c.Enabled, + Audience: c.Audience, + Issuer: c.Issuer, + JWKSURI: c.JWKSURI, + JWKSRemoteTimeout: c.JWKSRemoteTimeout, + RoleValidationStrategy: c.RoleValidationStrategy, + RolesClaim: c.Claims.Roles, + UsernameClaim: c.Claims.Username, + }, + ) + } + } + + return authcfgs, nil +} + +// ViperBindFlag provides a wrapper around the viper bindings that handles error checks +func ViperBindFlag(name string, flag *pflag.Flag) { + BindFlagFromViperInst(viper.GetViper(), name, flag) +} + +// BindFlagFromViperInst provides a wrapper around the viper bindings that handles error checks +func BindFlagFromViperInst(v *viper.Viper, name string, flag *pflag.Flag) { + err := v.BindPFlag(name, flag) + if err != nil { + panic(err) + } +} diff --git a/ginjwt/helpers_test.go b/ginjwt/helpers_test.go new file mode 100644 index 0000000..1f95105 --- /dev/null +++ b/ginjwt/helpers_test.go @@ -0,0 +1,480 @@ +package ginjwt_test + +import ( + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + + "github.com/metal-toolbox/rivets/ginjwt" +) + +func TestRegisterViperOIDCFlagsSingleProvider(t *testing.T) { + tests := []struct { + name string + expectedAuthConfig ginjwt.AuthConfig + wantErr bool + }{ + { + name: "Get AuthConfig from parameters scenario 1", + expectedAuthConfig: ginjwt.AuthConfig{ + Enabled: true, + Audience: "tacos", + Issuer: "are", + JWKSURI: "https://bit.ly/3HlVmWp", + JWKSRemoteTimeout: 10 * time.Second, + RolesClaim: "pretty", + UsernameClaim: "awesome", + RoleValidationStrategy: ginjwt.RoleValidationStrategyAny, + }, + }, + { + name: "Get AuthConfig from parameters scenario 2", + expectedAuthConfig: ginjwt.AuthConfig{ + Enabled: true, + Audience: "beer", + Issuer: "is", + JWKSURI: "https://bit.ly/3HlVmWp", + JWKSRemoteTimeout: 11 * time.Second, + RolesClaim: "quite", + UsernameClaim: "tasty", + RoleValidationStrategy: ginjwt.RoleValidationStrategyAll, + }, + }, + { + name: "Get AuthConfig fails due to missing issuer", + expectedAuthConfig: ginjwt.AuthConfig{ + Enabled: true, + Audience: "beer", + Issuer: "", + JWKSURI: "https://bit.ly/3HlVmWp", + JWKSRemoteTimeout: 12 * time.Second, + RolesClaim: "quite", + UsernameClaim: "tasty", + }, + wantErr: true, + }, + + { + name: "Get AuthConfig fails due to missing JWK URI", + expectedAuthConfig: ginjwt.AuthConfig{ + Enabled: true, + Audience: "beer", + Issuer: "is", + JWKSURI: "", + RolesClaim: "quite", + UsernameClaim: "tasty", + }, + wantErr: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + v := viper.New() + cmd := &cobra.Command{} + + ginjwt.RegisterViperOIDCFlags(v, cmd) + + v.Set("oidc.enabled", tc.expectedAuthConfig.Enabled) + v.Set("oidc.audience", tc.expectedAuthConfig.Audience) + v.Set("oidc.issuer", tc.expectedAuthConfig.Issuer) + v.Set("oidc.jwksuri", tc.expectedAuthConfig.JWKSURI) + v.Set("oidc.claims.roles", tc.expectedAuthConfig.RolesClaim) + v.Set("oidc.claims.username", tc.expectedAuthConfig.UsernameClaim) + v.Set("oidc.jwksremotetimeout", tc.expectedAuthConfig.JWKSRemoteTimeout) + v.Set("oidc.rolevalidationstrategy", tc.expectedAuthConfig.RoleValidationStrategy) + + gotAT, err := ginjwt.GetAuthConfigFromFlags(v) + if tc.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tc.expectedAuthConfig.Enabled, gotAT.Enabled) + assert.Equal(t, tc.expectedAuthConfig.Audience, gotAT.Audience) + assert.Equal(t, tc.expectedAuthConfig.Issuer, gotAT.Issuer) + assert.Equal(t, tc.expectedAuthConfig.JWKSURI, gotAT.JWKSURI) + assert.Equal(t, tc.expectedAuthConfig.JWKSRemoteTimeout, gotAT.JWKSRemoteTimeout) + assert.Equal(t, tc.expectedAuthConfig.RolesClaim, gotAT.RolesClaim) + assert.Equal(t, tc.expectedAuthConfig.UsernameClaim, gotAT.UsernameClaim) + assert.Equal(t, tc.expectedAuthConfig.RoleValidationStrategy, gotAT.RoleValidationStrategy) + } + }) + } +} + +func TestRegisterViperOIDCFlags(t *testing.T) { + tests := []struct { + name string + config []ginjwt.OIDCConfig + expectedAuthConfig []ginjwt.AuthConfig + wantErr bool + }{ + { + name: "Get AuthConfig from parameters scenario 1", + config: []ginjwt.OIDCConfig{ + { + Enabled: true, + Audience: "tacos", + Issuer: "are", + JWKSURI: "https://bit.ly/3HlVmWp", + JWKSRemoteTimeout: 10 * time.Second, + RoleValidationStrategy: ginjwt.RoleValidationStrategyAny, + Claims: ginjwt.Claims{ + Roles: "pretty", + Username: "awesome", + }, + }, + }, + expectedAuthConfig: []ginjwt.AuthConfig{ + { + Enabled: true, + Audience: "tacos", + Issuer: "are", + JWKSURI: "https://bit.ly/3HlVmWp", + JWKSRemoteTimeout: 10 * time.Second, + RolesClaim: "pretty", + UsernameClaim: "awesome", + RoleValidationStrategy: ginjwt.RoleValidationStrategyAny, + }, + }, + }, + { + name: "Get AuthConfig from parameters scenario 2", + config: []ginjwt.OIDCConfig{ + { + Enabled: true, + Audience: "beer", + Issuer: "is", + JWKSURI: "https://bit.ly/3HlVmWp", + JWKSRemoteTimeout: 11 * time.Second, + RoleValidationStrategy: ginjwt.RoleValidationStrategyAll, + Claims: ginjwt.Claims{ + Roles: "quite", + Username: "tasty", + }, + }, + }, + expectedAuthConfig: []ginjwt.AuthConfig{ + { + Enabled: true, + Audience: "beer", + Issuer: "is", + JWKSURI: "https://bit.ly/3HlVmWp", + JWKSRemoteTimeout: 11 * time.Second, + RolesClaim: "quite", + UsernameClaim: "tasty", + RoleValidationStrategy: ginjwt.RoleValidationStrategyAll, + }, + }, + }, + { + name: "Get AuthConfig from parameters returns valid JWKS remote timeout even if missing", + config: []ginjwt.OIDCConfig{ + { + Enabled: true, + Audience: "beer", + Issuer: "is", + JWKSURI: "https://bit.ly/3HlVmWp", + Claims: ginjwt.Claims{ + Roles: "quite", + Username: "tasty", + }, + }, + }, + expectedAuthConfig: []ginjwt.AuthConfig{ + { + Enabled: true, + Audience: "beer", + Issuer: "is", + JWKSURI: "https://bit.ly/3HlVmWp", + JWKSRemoteTimeout: 0, + RolesClaim: "quite", + UsernameClaim: "tasty", + }, + }, + }, + { + name: "Get AuthConfig from parameters only return first", + config: []ginjwt.OIDCConfig{ + { + Enabled: true, + Audience: "beer", + Issuer: "is", + JWKSURI: "https://bit.ly/3HlVmWp", + JWKSRemoteTimeout: 12 * time.Second, + Claims: ginjwt.Claims{ + Roles: "quite", + Username: "tasty", + }, + }, + { + Enabled: true, + Audience: "beer", + Issuer: "isnt", + JWKSURI: "https://bit.ly/3HlVmAc", + JWKSRemoteTimeout: 12 * time.Second, + Claims: ginjwt.Claims{ + Roles: "that", + Username: "tasty", + }, + }, + }, + expectedAuthConfig: []ginjwt.AuthConfig{ + { + Enabled: true, + Audience: "beer", + Issuer: "is", + JWKSURI: "https://bit.ly/3HlVmWp", + JWKSRemoteTimeout: 12 * time.Second, + RolesClaim: "quite", + UsernameClaim: "tasty", + }, + }, + }, + { + name: "Get AuthConfig fails due to missing issuer", + config: []ginjwt.OIDCConfig{ + { + Enabled: true, + Audience: "beer", + Issuer: "", + JWKSURI: "https://bit.ly/3HlVmWp", + JWKSRemoteTimeout: 13 * time.Second, + Claims: ginjwt.Claims{ + Roles: "quite", + Username: "tasty", + }, + }, + }, + expectedAuthConfig: []ginjwt.AuthConfig{ + { + Enabled: true, + Audience: "beer", + Issuer: "", + JWKSURI: "https://bit.ly/3HlVmWp", + JWKSRemoteTimeout: 13 * time.Second, + RolesClaim: "quite", + UsernameClaim: "tasty", + }, + }, + wantErr: true, + }, + { + name: "Get AuthConfig fails due to missing JWK URI", + config: []ginjwt.OIDCConfig{ + { + Enabled: true, + Audience: "beer", + Issuer: "is", + JWKSURI: "", + JWKSRemoteTimeout: 14 * time.Second, + Claims: ginjwt.Claims{ + Roles: "quite", + Username: "tasty", + }, + }, + }, + expectedAuthConfig: []ginjwt.AuthConfig{ + { + Enabled: true, + Audience: "beer", + Issuer: "is", + JWKSURI: "", + JWKSRemoteTimeout: 14 * time.Second, + RolesClaim: "quite", + UsernameClaim: "tasty", + }, + }, + wantErr: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + v := viper.New() + cmd := &cobra.Command{} + + ginjwt.RegisterViperOIDCFlags(v, cmd) + + v.Set("oidc", tc.config) + + gotAT, err := ginjwt.GetAuthConfigFromFlags(v) + if tc.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + + assert.Equal(t, tc.expectedAuthConfig[0].Enabled, gotAT.Enabled) + assert.Equal(t, tc.expectedAuthConfig[0].Audience, gotAT.Audience) + assert.Equal(t, tc.expectedAuthConfig[0].Issuer, gotAT.Issuer) + assert.Equal(t, tc.expectedAuthConfig[0].JWKSURI, gotAT.JWKSURI) + assert.Equal(t, tc.expectedAuthConfig[0].JWKSRemoteTimeout, gotAT.JWKSRemoteTimeout) + assert.Equal(t, tc.expectedAuthConfig[0].RolesClaim, gotAT.RolesClaim) + assert.Equal(t, tc.expectedAuthConfig[0].UsernameClaim, gotAT.UsernameClaim) + assert.Equal(t, tc.expectedAuthConfig[0].RoleValidationStrategy, gotAT.RoleValidationStrategy) + } + }) + } +} + +func TestRegisterViperOIDCFlagsForMultipleConfigs(t *testing.T) { + tests := []struct { + name string + config []ginjwt.OIDCConfig + expectedAuthConfigs []ginjwt.AuthConfig + wantErr bool + }{ + { + name: "Get AuthConfig from parameters with one issuer and JWK URI", + config: []ginjwt.OIDCConfig{ + { + Enabled: true, + Audience: "tacos", + Issuer: "are", + JWKSURI: "https://bit.ly/3HlVmWp", + JWKSRemoteTimeout: 10 * time.Second, + Claims: ginjwt.Claims{ + Roles: "pretty", + Username: "awesome", + }, + }, + }, + expectedAuthConfigs: []ginjwt.AuthConfig{ + { + Enabled: true, + Audience: "tacos", + Issuer: "are", + JWKSURI: "https://bit.ly/3HlVmWp", + JWKSRemoteTimeout: 10 * time.Second, + RolesClaim: "pretty", + UsernameClaim: "awesome", + }, + }, + }, + { + name: "Get AuthConfig from parameters with two valid configs", + config: []ginjwt.OIDCConfig{ + { + Enabled: true, + Audience: "Hey Jude", + Issuer: "don't make it bad", + JWKSURI: "take a sad song and make it better", + JWKSRemoteTimeout: 11 * time.Second, + Claims: ginjwt.Claims{ + Roles: "Na na na nananana", + Username: "nannana, hey Jude...", + }, + }, + { + Enabled: true, + Audience: "Hey Jude", + Issuer: "don't be afraid", + JWKSURI: "You were made to go out and get her", + JWKSRemoteTimeout: 12 * time.Second, + Claims: ginjwt.Claims{ + Roles: "Na na na nananana", + Username: "nannana, hey Jude...", + }, + }, + }, + expectedAuthConfigs: []ginjwt.AuthConfig{ + { + Enabled: true, + Audience: "Hey Jude", + Issuer: "don't make it bad", + JWKSURI: "take a sad song and make it better", + JWKSRemoteTimeout: 11 * time.Second, + RolesClaim: "Na na na nananana", + UsernameClaim: "nannana, hey Jude...", + }, + { + Enabled: true, + Audience: "Hey Jude", + Issuer: "don't be afraid", + JWKSURI: "You were made to go out and get her", + JWKSRemoteTimeout: 12 * time.Second, + RolesClaim: "Na na na nananana", + UsernameClaim: "nannana, hey Jude...", + }, + }, + }, + { + name: "Get AuthConfig fails due to missing issuer", + config: []ginjwt.OIDCConfig{ + { + Enabled: true, + Audience: "beer", + Issuer: "", + JWKSURI: "https://bit.ly/3HlVmWp", + Claims: ginjwt.Claims{ + Roles: "quite", + Username: "tasty", + }, + }, + }, + wantErr: true, + }, + { + name: "Get AuthConfig fails due to missing JWK URI", + config: []ginjwt.OIDCConfig{ + { + Enabled: true, + Audience: "beer", + Issuer: "is", + JWKSURI: "", + Claims: ginjwt.Claims{ + Roles: "quite", + Username: "tasty", + }, + }, + }, + wantErr: true, + }, + { + name: "Get no AuthConfigs if OIDC is diabled", + config: []ginjwt.OIDCConfig{ + { + Enabled: false, + Audience: "", + Issuer: "", + JWKSURI: "", + Claims: ginjwt.Claims{ + Roles: "", + Username: "", + }, + }, + }, + wantErr: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + v := viper.New() + cmd := &cobra.Command{} + + ginjwt.RegisterViperOIDCFlags(v, cmd) + + v.Set("oidc", tc.config) + + gotACs, err := ginjwt.GetAuthConfigsFromFlags(v) + if tc.wantErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + + for idx, ac := range gotACs { + assert.Equal(t, tc.config[idx].Enabled, ac.Enabled) + assert.Equal(t, tc.config[idx].Audience, ac.Audience) + assert.Equal(t, tc.config[idx].Issuer, ac.Issuer) + assert.Equal(t, tc.config[idx].JWKSURI, ac.JWKSURI) + assert.Equal(t, tc.config[idx].JWKSRemoteTimeout, ac.JWKSRemoteTimeout) + assert.Equal(t, tc.config[idx].Claims.Roles, ac.RolesClaim) + assert.Equal(t, tc.config[idx].Claims.Username, ac.UsernameClaim) + } + }) + } +} diff --git a/ginjwt/jwt.go b/ginjwt/jwt.go new file mode 100644 index 0000000..14d0369 --- /dev/null +++ b/ginjwt/jwt.go @@ -0,0 +1,373 @@ +package ginjwt + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/pkg/errors" + "golang.org/x/net/context" + "gopkg.in/square/go-jose.v2" + "gopkg.in/square/go-jose.v2/jwt" + + "github.com/metal-toolbox/rivets/ginauth" +) + +const ( + contextKeySubject = "jwt.subject" + contextKeyUser = "jwt.user" + contextKeyRoles = "jwt.roles" + expectedAuthHeaderParts = 2 +) + +// RoleValidationStrategy represents a validation strategy for roles. +type RoleValidationStrategy string + +const ( + // RoleValidationStrategyAny represents validation that any required role exists in the roles claim. + RoleValidationStrategyAny RoleValidationStrategy = "any" + // RoleValidationStrategyAll represents validation that all required roles exist in the roles claim. + RoleValidationStrategyAll RoleValidationStrategy = "all" +) + +// Middleware provides a gin compatible middleware that will authenticate JWT requests +type Middleware struct { + config AuthConfig + cachedJWKS jose.JSONWebKeySet +} + +// AuthConfig provides the configuration for the authentication service +type AuthConfig struct { + Enabled bool + Audience string + Issuer string + JWKSURI string + + // JWKS allows the user to specify the JWKS directly instead of through URI + JWKS jose.JSONWebKeySet + LogFields []string + RolesClaim string + UsernameClaim string + JWKSRemoteTimeout time.Duration + // Role validation strategy for roles claim. Defaults to any if unspecified. + RoleValidationStrategy RoleValidationStrategy +} + +// NewAuthMiddleware will return an auth middleware configured with the jwt parameters passed in +// +//nolint:gocritic // Not replacing cfg with a pointer, its an API, so would require us to fix its use in other repos +func NewAuthMiddleware(cfg AuthConfig) (*Middleware, error) { + if cfg.RolesClaim == "" { + cfg.RolesClaim = "scope" + } + + if cfg.UsernameClaim == "" { + cfg.UsernameClaim = "sub" + } + + mw := &Middleware{ + config: cfg, + } + + if !cfg.Enabled { + return mw, nil + } + + if cfg.Audience == "" { + return nil, errors.Wrap(ErrInvalidAudience, "empty value") + } + + if cfg.Issuer == "" { + return nil, errors.Wrap(ErrInvalidIssuer, "empty value") + } + + uriProvided := (cfg.JWKSURI != "") + jwksProvided := len(cfg.JWKS.Keys) > 0 + + // Either they were both provided, or neither was provided + if uriProvided == jwksProvided { + return nil, fmt.Errorf("%w: either JWKSURI or JWKS must be provided", ErrInvalidAuthConfig) + } + + // Only refresh JWKSURI if static one isn't provided + if len(cfg.JWKS.Keys) > 0 { + mw.cachedJWKS = cfg.JWKS + } else { + // Fetch JWKS from URI + if err := mw.refreshJWKS(); err != nil { + return nil, err + } + } + + return mw, nil +} + +// SetMetadata sets the needed metadata to the gin context which came from the token +func (m *Middleware) SetMetadata(c *gin.Context, cm ginauth.ClaimMetadata) { + if cm.Subject != "" { + c.Set(contextKeySubject, cm.Subject) + } + + if cm.User != "" { + c.Set(contextKeyUser, cm.User) + } +} + +// VerifyTokenWithScopes satisfies the goauth.GenericAuthMiddleware interface and exists only for +// backwards compatibility with that interface. +func (m *Middleware) VerifyTokenWithScopes(c *gin.Context, scopes []string) (ginauth.ClaimMetadata, error) { + cm, err := m.VerifyToken(c) + if err != nil { + return ginauth.ClaimMetadata{}, err + } + + c.Set(contextKeySubject, cm.Subject) + c.Set(contextKeyUser, cm.User) + c.Set(contextKeyRoles, cm.Roles) + + if err := m.VerifyScopes(c, scopes); err != nil { + return ginauth.ClaimMetadata{}, err + } + + return cm, nil +} + +// VerifyToken verifies a JWT token gotten from the gin.Context object. This does not validate roles claims/scopes. +// This implements the GenericMiddleware interface +// +//nolint:gocyclo // we should reduce the complexity of this function at some point +func (m *Middleware) VerifyToken(c *gin.Context) (ginauth.ClaimMetadata, error) { + authHeader := c.Request.Header.Get("Authorization") + + if authHeader == "" { + return ginauth.ClaimMetadata{}, ginauth.NewAuthenticationError("missing authorization header, expected format: \"Bearer token\"") + } + + authHeaderParts := strings.SplitN(authHeader, " ", expectedAuthHeaderParts) + + if !(len(authHeaderParts) == expectedAuthHeaderParts && strings.EqualFold(authHeaderParts[0], "bearer")) { + return ginauth.ClaimMetadata{}, ginauth.NewAuthenticationError("invalid authorization header, expected format: \"Bearer token\"") + } + + rawToken := authHeaderParts[1] + + tok, err := jwt.ParseSigned(rawToken) + if err != nil { + return ginauth.ClaimMetadata{}, ginauth.NewAuthenticationError("unable to parse auth token") + } + + if tok.Headers[0].KeyID == "" { + return ginauth.ClaimMetadata{}, ginauth.NewAuthenticationError("unable to parse auth token header") + } + + key := m.getJWKS(tok.Headers[0].KeyID) + if key == nil { + return ginauth.ClaimMetadata{}, ginauth.NewInvalidSigningKeyError() + } + + cl := jwt.Claims{} + sc := map[string]interface{}{} + + if err := tok.Claims(key, &cl, &sc); err != nil { + return ginauth.ClaimMetadata{}, ginauth.NewAuthenticationError("unable to validate auth token") + } + + err = cl.Validate(jwt.Expected{ + Issuer: m.config.Issuer, + Audience: jwt.Audience{m.config.Audience}, + Time: time.Now(), + }) + if err != nil { + return ginauth.ClaimMetadata{}, ginauth.NewTokenValidationError(err) + } + + var roles []string + switch r := sc[m.config.RolesClaim].(type) { + case string: + roles = strings.Split(r, " ") + case []interface{}: + for _, i := range r { + roles = append(roles, i.(string)) + } + } + + var user string + switch u := sc[m.config.UsernameClaim].(type) { + case string: + user = u + default: + user = cl.Subject + } + + return ginauth.ClaimMetadata{Subject: cl.Subject, User: user, Roles: roles}, nil +} + +// AuthRequired provides a middleware that ensures a request has authentication. In order to +// validate scopes, you also need to call RequireScopes(). +func (m *Middleware) AuthRequired() gin.HandlerFunc { + return func(c *gin.Context) { + if !m.config.Enabled { + return + } + + cm, err := m.VerifyToken(c) + if err != nil { + ginauth.AbortBecauseOfError(c, err) + return + } + + c.Set(contextKeySubject, cm.Subject) + c.Set(contextKeyUser, cm.User) + c.Set(contextKeyRoles, cm.Roles) + } +} + +// RequiredScopes provides middleware that validates that the passed list of scopes +// are included in the role claims by checking the values on context. +func (m *Middleware) RequiredScopes(scopes []string) gin.HandlerFunc { + return func(c *gin.Context) { + if !m.config.Enabled { + return + } + + if err := m.VerifyScopes(c, scopes); err != nil { + ginauth.AbortBecauseOfError(c, err) + return + } + } +} + +// VerifyScopes verifies role claims added to the gin.Context object. +// This implements the GenericMiddleware interface +func (m *Middleware) VerifyScopes(c *gin.Context, scopes []string) error { + roles := c.GetStringSlice("jwt.roles") + + var rolesSatisfied bool + + switch m.config.RoleValidationStrategy { + case "", RoleValidationStrategyAny: + rolesSatisfied = hasAnyScope(roles, scopes) + case RoleValidationStrategyAll: + rolesSatisfied = hasAllScopes(roles, scopes) + default: + return ErrInvalidAuthConfig + } + + if !rolesSatisfied { + return ginauth.NewAuthorizationError("not authorized, missing required scope") + } + + return nil +} + +func (m *Middleware) refreshJWKS() error { + var ctx context.Context + + // When using JWKS directly, refresh should be a no-op + if len(m.config.JWKS.Keys) > 0 { + return nil + } + + if m.config.JWKSRemoteTimeout != 0 { + var cancel context.CancelFunc + + ctx, cancel = context.WithTimeout(context.Background(), m.config.JWKSRemoteTimeout) + + defer cancel() + } else { + ctx = context.Background() + } + + req, reqerr := http.NewRequestWithContext(ctx, http.MethodGet, m.config.JWKSURI, http.NoBody) + if reqerr != nil { + return reqerr + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%w: %s", ginauth.ErrMiddlewareRemote, resp.Body) + } + + return json.NewDecoder(resp.Body).Decode(&m.cachedJWKS) +} + +func (m *Middleware) getJWKS(kid string) *jose.JSONWebKey { + keys := m.cachedJWKS.Key(kid) + if len(keys) == 0 { + // couldn't find the signing key in our cache, refresh cache and search again + if err := m.refreshJWKS(); err != nil { + return nil + } + + keys = m.cachedJWKS.Key(kid) + if len(keys) == 0 { + return nil + } + } + + return &keys[0] +} + +func hasAllScopes(have, needed []string) bool { + // Short circuit: If we don't need any scopes, we're good. Return true + if len(needed) == 0 { + return true + } + + haveMap := make(map[string]struct{}) + for _, s := range have { + haveMap[s] = struct{}{} + } + + // Check the scopes we need against what we have. If any are missing, return false + for _, s := range needed { + if _, ok := haveMap[s]; !ok { + return false + } + } + + return true +} + +func hasAnyScope(have, needed []string) bool { + // Short circuit: If we don't need any scopes, we're good. Return true + if len(needed) == 0 { + return true + } + + neededMap := make(map[string]struct{}) + for _, s := range needed { + neededMap[s] = struct{}{} + } + + // Check the scopes we need against what we have. If any are present, return true. + for _, s := range have { + if _, ok := neededMap[s]; ok { + return true + } + } + + return false +} + +// GetSubject will return the JWT subject that is saved in the request. This requires that authentication of the request +// has already occurred. If authentication failed or there isn't a user, an empty string is returned. This returns +// whatever value was in the JWT subject field and might not be a human readable value +func GetSubject(c *gin.Context) string { + return c.GetString(contextKeySubject) +} + +// GetUser will return the JWT user that is saved in the request. This requires that authentication of the request +// has already occurred. If authentication failed or there isn't a user an empty string is returned. +func GetUser(c *gin.Context) string { + return c.GetString(contextKeyUser) +} diff --git a/ginjwt/jwt_test.go b/ginjwt/jwt_test.go new file mode 100644 index 0000000..cb48cd9 --- /dev/null +++ b/ginjwt/jwt_test.go @@ -0,0 +1,772 @@ +package ginjwt_test + +import ( + "bytes" + "crypto/rsa" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/square/go-jose.v2" + "gopkg.in/square/go-jose.v2/jwt" + + "github.com/metal-toolbox/rivets/ginauth" + "github.com/metal-toolbox/rivets/ginjwt" +) + +func TestMiddlewareValidatesTokensWithScopes(t *testing.T) { + var testCases = []struct { + testName string + middlewareAud string + middlewareIss string + middlewareScopes []string + signingKey *rsa.PrivateKey + signingKeyID string + jwksFromURI bool + claims jwt.Claims + claimScopes []string + responseCode int + responseBody string + }{ + { + "unknown keyid", + "ginjwt.test", + "ginjwt.test.issuer2", + []string{"testScope"}, + ginjwt.TestPrivRSAKey1, + "randomUnknownID", + true, + jwt.Claims{ + Subject: "test-user", + Issuer: "ginjwt.test.issuer", + NotBefore: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)), + Audience: jwt.Audience{"ginjwt.test", "another.test.service"}, + }, + []string{"testScope", "anotherScope", "more-scopes"}, + http.StatusUnauthorized, + "invalid token signing key", + }, + { + "incorrect keyid", + "ginjwt.test", + "ginjwt.test.issuer2", + []string{"testScope"}, + ginjwt.TestPrivRSAKey1, + ginjwt.TestPrivRSAKey2ID, + true, + jwt.Claims{ + Subject: "test-user", + Issuer: "ginjwt.test.issuer", + NotBefore: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)), + Audience: jwt.Audience{"ginjwt.test", "another.test.service"}, + }, + []string{"testScope", "anotherScope", "more-scopes"}, + http.StatusUnauthorized, + "unable to validate auth token", + }, + { + "incorrect issuer", + "ginjwt.test", + "ginjwt.test.issuer2", + []string{"testScope"}, + ginjwt.TestPrivRSAKey1, + ginjwt.TestPrivRSAKey1ID, + true, + jwt.Claims{ + Subject: "test-user", + Issuer: "ginjwt.test.issuer", + NotBefore: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)), + Audience: jwt.Audience{"ginjwt.test", "another.test.service"}, + }, + []string{"testScope", "anotherScope", "more-scopes"}, + http.StatusUnauthorized, + "invalid issuer claim", + }, + { + "incorrect audience", + "ginjwt.testFail", + "ginjwt.test.issuer", + []string{"testScope"}, + ginjwt.TestPrivRSAKey1, + ginjwt.TestPrivRSAKey1ID, + true, + jwt.Claims{ + Subject: "test-user", + Issuer: "ginjwt.test.issuer", + NotBefore: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)), + Audience: jwt.Audience{"ginjwt.test", "another.test.service"}, + }, + []string{"testScope", "anotherScope", "more-scopes"}, + http.StatusUnauthorized, + "invalid audience claim", + }, + { + "incorrect scopes", + "ginjwt.test", + "ginjwt.test.issuer", + []string{"adminscope"}, + ginjwt.TestPrivRSAKey1, + ginjwt.TestPrivRSAKey1ID, + true, + jwt.Claims{ + Subject: "test-user", + Issuer: "ginjwt.test.issuer", + NotBefore: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)), + Audience: jwt.Audience{"ginjwt.test", "another.test.service"}, + }, + []string{"testScope", "anotherScope", "more-scopes"}, + http.StatusForbidden, + "missing required scope", + }, + { + "expired token", + "ginjwt.test", + "ginjwt.test.issuer", + []string{"testScope"}, + ginjwt.TestPrivRSAKey1, + ginjwt.TestPrivRSAKey1ID, + true, + jwt.Claims{ + Subject: "test-user", + Issuer: "ginjwt.test.issuer", + NotBefore: jwt.NewNumericDate(time.Now().Add(-6 * time.Hour)), + Expiry: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)), + Audience: jwt.Audience{"ginjwt.test", "another.test.service"}, + }, + []string{"testScope", "anotherScope", "more-scopes"}, + http.StatusUnauthorized, + "token is expired", + }, + { + "future token", + "ginjwt.test", + "ginjwt.test.issuer", + []string{"testScope"}, + ginjwt.TestPrivRSAKey1, + ginjwt.TestPrivRSAKey1ID, + true, + jwt.Claims{ + Subject: "test-user", + Issuer: "ginjwt.test.issuer", + NotBefore: jwt.NewNumericDate(time.Now().Add(6 * time.Hour)), + Audience: jwt.Audience{"ginjwt.test", "another.test.service"}, + }, + []string{"testScope", "anotherScope", "more-scopes"}, + http.StatusUnauthorized, + "token not valid yet", + }, + { + "happy path", + "ginjwt.test", + "ginjwt.test.issuer", + []string{"testScope"}, + ginjwt.TestPrivRSAKey1, + ginjwt.TestPrivRSAKey1ID, + true, + jwt.Claims{ + Subject: "test-user", + Issuer: "ginjwt.test.issuer", + NotBefore: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)), + Audience: jwt.Audience{"ginjwt.test", "another.test.service"}, + }, + []string{"testScope", "anotherScope", "more-scopes"}, + http.StatusOK, + "ok", + }, + { + "invalid key with directly loaded JWKS", + "ginjwt.test", + "ginjwt.test.issuer", + []string{"testScope"}, + ginjwt.TestPrivRSAKey1, + ginjwt.TestPrivRSAKey1ID, + false, + jwt.Claims{ + Subject: "test-user", + Issuer: "ginjwt.test.issuer", + NotBefore: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)), + Audience: jwt.Audience{"ginjwt.test", "another.test.service"}, + }, + []string{"testScope", "anotherScope", "more-scopes"}, + http.StatusOK, + "ok", + }, + { + "valid with directly loaded JWKS", + "ginjwt.test", + "ginjwt.test.issuer", + []string{"testScope"}, + ginjwt.TestPrivRSAKey1, + "invalid-key-id", + false, + jwt.Claims{ + Subject: "test-user", + Issuer: "ginjwt.test.issuer", + NotBefore: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)), + Audience: jwt.Audience{"ginjwt.test", "another.test.service"}, + }, + []string{"testScope", "anotherScope", "more-scopes"}, + http.StatusUnauthorized, + "invalid token signing key", + }, + } + + for _, tt := range testCases { + t.Run(tt.testName, func(t *testing.T) { + var jwksURI string + + var jwks jose.JSONWebKeySet + + if tt.jwksFromURI { + jwksURI = ginjwt.TestHelperJWKSProvider(ginjwt.TestPrivRSAKey1ID, ginjwt.TestPrivRSAKey2ID) + } else { + jwks = ginjwt.TestHelperJoseJWKSProvider(ginjwt.TestPrivRSAKey1ID, ginjwt.TestPrivRSAKey2ID) + } + + cfg := ginjwt.AuthConfig{Enabled: true, Audience: tt.middlewareAud, Issuer: tt.middlewareIss, JWKSURI: jwksURI, JWKS: jwks} + authMW, err := ginjwt.NewAuthMiddleware(cfg) + require.NoError(t, err) + + r := gin.New() + r.Use(authMW.AuthRequired(), authMW.RequiredScopes(tt.middlewareScopes)) + r.GET("/", func(c *gin.Context) { + c.JSON(http.StatusOK, "ok") + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "http://test/", http.NoBody) + + signer := ginjwt.TestHelperMustMakeSigner(jose.RS256, tt.signingKeyID, tt.signingKey) + rawToken := ginjwt.TestHelperGetToken(signer, tt.claims, "scope", strings.Join(tt.claimScopes, " ")) + req.Header.Set("Authorization", fmt.Sprintf("bearer %s", rawToken)) + + r.ServeHTTP(w, req) + + assert.Equal(t, tt.responseCode, w.Code) + assert.Contains(t, w.Body.String(), tt.responseBody) + }) + } +} + +func TestMiddlewareAuthRequired(t *testing.T) { + var testCases = []struct { + testName string + middlewareAud string + middlewareIss string + middlewareScopes []string + signingKey *rsa.PrivateKey + signingKeyID string + claims jwt.Claims + claimScopes []string + responseCode int + responseBody string + }{ + { + "unknown keyid", + "ginjwt.test", + "ginjwt.test.issuer2", + []string{"testScope"}, + ginjwt.TestPrivRSAKey1, + "randomUnknownID", + jwt.Claims{ + Subject: "test-user", + Issuer: "ginjwt.test.issuer", + NotBefore: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)), + Audience: jwt.Audience{"ginjwt.test", "another.test.service"}, + }, + []string{"testScope", "anotherScope", "more-scopes"}, + http.StatusUnauthorized, + "invalid token signing key", + }, + { + "incorrect keyid", + "ginjwt.test", + "ginjwt.test.issuer2", + []string{"testScope"}, + ginjwt.TestPrivRSAKey1, + ginjwt.TestPrivRSAKey2ID, + jwt.Claims{ + Subject: "test-user", + Issuer: "ginjwt.test.issuer", + NotBefore: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)), + Audience: jwt.Audience{"ginjwt.test", "another.test.service"}, + }, + []string{"testScope", "anotherScope", "more-scopes"}, + http.StatusUnauthorized, + "unable to validate auth token", + }, + { + "incorrect issuer", + "ginjwt.test", + "ginjwt.test.issuer2", + []string{"testScope"}, + ginjwt.TestPrivRSAKey1, + ginjwt.TestPrivRSAKey1ID, + jwt.Claims{ + Subject: "test-user", + Issuer: "ginjwt.test.issuer", + NotBefore: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)), + Audience: jwt.Audience{"ginjwt.test", "another.test.service"}, + }, + []string{"testScope", "anotherScope", "more-scopes"}, + http.StatusUnauthorized, + "invalid issuer claim", + }, + { + "incorrect audience", + "ginjwt.testFail", + "ginjwt.test.issuer", + []string{"testScope"}, + ginjwt.TestPrivRSAKey1, + ginjwt.TestPrivRSAKey1ID, + jwt.Claims{ + Subject: "test-user", + Issuer: "ginjwt.test.issuer", + NotBefore: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)), + Audience: jwt.Audience{"ginjwt.test", "another.test.service"}, + }, + []string{"testScope", "anotherScope", "more-scopes"}, + http.StatusUnauthorized, + "invalid audience claim", + }, + { + "expired token", + "ginjwt.test", + "ginjwt.test.issuer", + []string{"testScope"}, + ginjwt.TestPrivRSAKey1, + ginjwt.TestPrivRSAKey1ID, + jwt.Claims{ + Subject: "test-user", + Issuer: "ginjwt.test.issuer", + NotBefore: jwt.NewNumericDate(time.Now().Add(-6 * time.Hour)), + Expiry: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)), + Audience: jwt.Audience{"ginjwt.test", "another.test.service"}, + }, + []string{"testScope", "anotherScope", "more-scopes"}, + http.StatusUnauthorized, + "token is expired", + }, + { + "future token", + "ginjwt.test", + "ginjwt.test.issuer", + []string{"testScope"}, + ginjwt.TestPrivRSAKey1, + ginjwt.TestPrivRSAKey1ID, + jwt.Claims{ + Subject: "test-user", + Issuer: "ginjwt.test.issuer", + NotBefore: jwt.NewNumericDate(time.Now().Add(6 * time.Hour)), + Audience: jwt.Audience{"ginjwt.test", "another.test.service"}, + }, + []string{"testScope", "anotherScope", "more-scopes"}, + http.StatusUnauthorized, + "token not valid yet", + }, + { + "happy path", + "ginjwt.test", + "ginjwt.test.issuer", + []string{"testScope"}, + ginjwt.TestPrivRSAKey1, + ginjwt.TestPrivRSAKey1ID, + jwt.Claims{ + Subject: "test-user", + Issuer: "ginjwt.test.issuer", + NotBefore: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)), + Audience: jwt.Audience{"ginjwt.test", "another.test.service"}, + }, + []string{"testScope", "anotherScope", "more-scopes"}, + http.StatusOK, + "ok", + }, + } + + for _, tt := range testCases { + t.Run(tt.testName, func(t *testing.T) { + jwksURI := ginjwt.TestHelperJWKSProvider(ginjwt.TestPrivRSAKey1ID, ginjwt.TestPrivRSAKey2ID) + + cfg := ginjwt.AuthConfig{Enabled: true, Audience: tt.middlewareAud, Issuer: tt.middlewareIss, JWKSURI: jwksURI} + authMW, err := ginjwt.NewAuthMiddleware(cfg) + require.NoError(t, err) + + r := gin.New() + r.Use(authMW.AuthRequired()) + r.GET("/", func(c *gin.Context) { + c.JSON(http.StatusOK, "ok") + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "http://test/", http.NoBody) + + signer := ginjwt.TestHelperMustMakeSigner(jose.RS256, tt.signingKeyID, tt.signingKey) + rawToken := ginjwt.TestHelperGetToken(signer, tt.claims, "scope", strings.Join(tt.claimScopes, " ")) + req.Header.Set("Authorization", fmt.Sprintf("bearer %s", rawToken)) + + r.ServeHTTP(w, req) + + assert.Equal(t, tt.responseCode, w.Code) + assert.Contains(t, w.Body.String(), tt.responseBody) + }) + } +} + +func TestInvalidAuthHeader(t *testing.T) { + var testCases = []struct { + testName string + authHeader string + responseCode int + responseContains string + }{ + { + "no auth header", + "", + http.StatusUnauthorized, + "missing authorization header", + }, + { + "wrong format", + "notbearer token", + http.StatusUnauthorized, + "invalid authorization header", + }, + { + "invalid token", + "bearer token", + http.StatusUnauthorized, + "unable to parse auth token", + }, + { + "token with no kid", + "bearer eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJpc3N1ZXIiLCJzY29wZXMiOlsiczEiLCJzMiJdLCJzdWIiOiJzdWJqZWN0In0.UDDtyK9gC9kyHltcP7E_XODsnqcJWZIiXeGmSAH7SE9YKy3N0KSfFIN85dCNjTfs6zvy4rkrCHzLB7uKAtzMearh3q7jL4nxbhUMhlUcs_9QDVoN4q_j58XmRqBqRnBk-RmDu9TgcV8RbErP4awpIhwWb5UU-hR__4_iNbHdKqwSUPDKYGlf5eicuiYrPxH8mxivk4LRD-vyRdBZZKBt0XIDnEU4TdcNCzAXojkftqcFWYsczwS8R4JHd1qYsMyiaWl4trdHZkO4QkeLe34z4ZAaPMt3wE-gcU-VoqYTGxz-K3Le2VaZ0r3j_z6bOInsv0yngC_cD1dCXMyQJWnWjQ", + http.StatusUnauthorized, + "unable to parse auth token header", + }, + } + + for _, tt := range testCases { + t.Run(tt.testName, func(t *testing.T) { + jwksURI := ginjwt.TestHelperJWKSProvider(ginjwt.TestPrivRSAKey1ID, ginjwt.TestPrivRSAKey2ID) + cfg := ginjwt.AuthConfig{Enabled: true, Audience: "aud", Issuer: "iss", JWKSURI: jwksURI} + authMW, err := ginjwt.NewAuthMiddleware(cfg) + require.NoError(t, err) + + r := gin.New() + r.Use(authMW.AuthRequired()) + r.GET("/", func(c *gin.Context) { + c.JSON(http.StatusOK, "ok") + }) + + w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "http://test/", http.NoBody) + + req.Header.Set("Authorization", tt.authHeader) + r.ServeHTTP(w, req) + + assert.Equal(t, tt.responseCode, w.Code) + assert.Contains(t, w.Body.String(), tt.responseContains) + }) + } +} + +func TestInvalidJWKURIWithWrongPath(t *testing.T) { + uri := ginjwt.TestHelperJWKSProvider(ginjwt.TestPrivRSAKey1ID, ginjwt.TestPrivRSAKey2ID) + uri += "/some-extra-path" + cfg := ginjwt.AuthConfig{Enabled: true, Audience: "aud", Issuer: "iss", JWKSURI: uri} + _, err := ginjwt.NewAuthMiddleware(cfg) + assert.Error(t, err) + assert.ErrorIs(t, err, ginauth.ErrMiddlewareRemote) +} + +func TestVerifyTokenWithScopes(t *testing.T) { + var testCases = []struct { + testName string + middlewareAud string + middlewareIss string + middlewareScopes []string + middlewareRoleStrat ginjwt.RoleValidationStrategy + signingKey *rsa.PrivateKey + signingKeyID string + claims jwt.Claims + claimScopes []string + wantScopes []string + want ginauth.ClaimMetadata + wantErr bool + }{ + { + testName: "missing all scopes", + middlewareAud: "ginjwt.test", + middlewareIss: "ginjwt.test.issuer", + middlewareScopes: []string{"adminscope"}, + signingKey: ginjwt.TestPrivRSAKey1, + signingKeyID: ginjwt.TestPrivRSAKey1ID, + claims: jwt.Claims{ + Subject: "test-user", + Issuer: "ginjwt.test.issuer", + NotBefore: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)), + Audience: jwt.Audience{"ginjwt.test", "another.test.service"}, + }, + claimScopes: []string{"testScope", "anotherScope", "more-scopes"}, + wantScopes: []string{"admin-scopes"}, + want: ginauth.ClaimMetadata{}, + wantErr: true, + }, + { + testName: "missing some scopes - default", + middlewareAud: "ginjwt.test", + middlewareIss: "ginjwt.test.issuer", + middlewareScopes: []string{"adminscope"}, + signingKey: ginjwt.TestPrivRSAKey1, + signingKeyID: ginjwt.TestPrivRSAKey1ID, + claims: jwt.Claims{ + Subject: "test-user", + Issuer: "ginjwt.test.issuer", + NotBefore: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)), + Audience: jwt.Audience{"ginjwt.test", "another.test.service"}, + }, + claimScopes: []string{"testScope"}, + wantScopes: []string{"testScope", "anotherScope"}, + + want: ginauth.ClaimMetadata{ + Subject: "test-user", + User: "test-user", + Roles: []string{ + "testScope", + }, + }, + wantErr: false, + }, + { + testName: "missing some scopes - any required", + middlewareAud: "ginjwt.test", + middlewareIss: "ginjwt.test.issuer", + middlewareRoleStrat: ginjwt.RoleValidationStrategyAny, + middlewareScopes: []string{"adminscope"}, + signingKey: ginjwt.TestPrivRSAKey1, + signingKeyID: ginjwt.TestPrivRSAKey1ID, + claims: jwt.Claims{ + Subject: "test-user", + Issuer: "ginjwt.test.issuer", + NotBefore: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)), + Audience: jwt.Audience{"ginjwt.test", "another.test.service"}, + }, + claimScopes: []string{"testScope"}, + wantScopes: []string{"testScope", "anotherScope"}, + want: ginauth.ClaimMetadata{ + Subject: "test-user", + User: "test-user", + Roles: []string{ + "testScope", + }, + }, + wantErr: false, + }, + { + testName: "missing some scopes - all required", + middlewareAud: "ginjwt.test", + middlewareIss: "ginjwt.test.issuer", + middlewareRoleStrat: ginjwt.RoleValidationStrategyAll, + middlewareScopes: []string{"adminscope"}, + signingKey: ginjwt.TestPrivRSAKey1, + signingKeyID: ginjwt.TestPrivRSAKey1ID, + claims: jwt.Claims{ + Subject: "test-user", + Issuer: "ginjwt.test.issuer", + NotBefore: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)), + Audience: jwt.Audience{"ginjwt.test", "another.test.service"}, + }, + claimScopes: []string{"testScope"}, + wantScopes: []string{"testScope", "anotherScope"}, + want: ginauth.ClaimMetadata{}, + wantErr: true, + }, + { + testName: "no wanted scopes", + middlewareAud: "ginjwt.test", + middlewareIss: "ginjwt.test.issuer", + middlewareScopes: []string{"adminscope"}, + signingKey: ginjwt.TestPrivRSAKey1, + signingKeyID: ginjwt.TestPrivRSAKey1ID, + claims: jwt.Claims{ + Subject: "test-user", + Issuer: "ginjwt.test.issuer", + NotBefore: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)), + Audience: jwt.Audience{"ginjwt.test", "another.test.service"}, + }, + claimScopes: []string{"admin-scopes"}, + wantScopes: []string{}, + want: ginauth.ClaimMetadata{ + Subject: "test-user", + User: "test-user", + Roles: []string{ + "admin-scopes", + }, + }, + wantErr: false, + }, + { + testName: "happy path", + middlewareAud: "ginjwt.test", + middlewareIss: "ginjwt.test.issuer", + middlewareScopes: []string{"testScope"}, + signingKey: ginjwt.TestPrivRSAKey1, + signingKeyID: ginjwt.TestPrivRSAKey1ID, + claims: jwt.Claims{ + Subject: "test-user", + Issuer: "ginjwt.test.issuer", + NotBefore: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)), + Audience: jwt.Audience{"ginjwt.test", "another.test.service"}, + }, + claimScopes: []string{"testScope", "anotherScope", "more-scopes"}, + wantScopes: []string{"testScope", "anotherScope", "more-scopes"}, + want: ginauth.ClaimMetadata{ + Subject: "test-user", + User: "test-user", + Roles: []string{ + "testScope", + "anotherScope", + "more-scopes", + }, + }, + wantErr: false, + }, + } + + for _, tt := range testCases { + t.Run(tt.testName, func(t *testing.T) { + jwksURI := ginjwt.TestHelperJWKSProvider(ginjwt.TestPrivRSAKey1ID, ginjwt.TestPrivRSAKey2ID) + config := ginjwt.AuthConfig{ + Enabled: true, + Audience: tt.middlewareAud, + Issuer: tt.middlewareIss, + JWKSURI: jwksURI, + RoleValidationStrategy: tt.middlewareRoleStrat, + } + m, err := ginjwt.NewAuthMiddleware(config) + assert.NoError(t, err) + + ctx := &gin.Context{} + signer := ginjwt.TestHelperMustMakeSigner(jose.RS256, tt.signingKeyID, tt.signingKey) + rawToken := ginjwt.TestHelperGetToken(signer, tt.claims, "scope", strings.Join(tt.claimScopes, " ")) + + // dummy http request + req, _ := http.NewRequest(http.MethodGet, "http://foo.bar", bytes.NewReader([]byte{})) + ctx.Request = req + ctx.Request.Header.Set("Authorization", fmt.Sprintf("bearer %s", rawToken)) + + got, err := m.VerifyTokenWithScopes(ctx, tt.wantScopes) + + if tt.wantErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestAuthMiddlewareConfig(t *testing.T) { + jwks := ginjwt.TestHelperJoseJWKSProvider(ginjwt.TestPrivRSAKey1ID, ginjwt.TestPrivRSAKey2ID) + jwksURI := ginjwt.TestHelperJWKSProvider(ginjwt.TestPrivRSAKey1ID, ginjwt.TestPrivRSAKey2ID) + + testCases := []struct { + name string + input ginjwt.AuthConfig + checkFn func(*testing.T, ginauth.GenericAuthMiddleware, error) + }{ + { + name: "ValidWithJWKS", + input: ginjwt.AuthConfig{ + Enabled: true, + Audience: "example-aud", + Issuer: "example-iss", + JWKS: jwks, + RoleValidationStrategy: "all", + }, + checkFn: func(t *testing.T, mw ginauth.GenericAuthMiddleware, err error) { + assert.NoError(t, err) + assert.NotNil(t, mw) + }, + }, + { + name: "ValidWithJWKSURI", + input: ginjwt.AuthConfig{ + Enabled: true, + Audience: "example-aud", + Issuer: "example-iss", + JWKSURI: jwksURI, + RoleValidationStrategy: "all", + }, + checkFn: func(t *testing.T, mw ginauth.GenericAuthMiddleware, err error) { + assert.NoError(t, err) + assert.NotNil(t, mw) + }, + }, + { + name: "InvalidJWKSConfig", + input: ginjwt.AuthConfig{ + Enabled: true, + Audience: "example-aud", + Issuer: "example-iss", + JWKSURI: jwksURI, + JWKS: jwks, + RoleValidationStrategy: "all", + }, + checkFn: func(t *testing.T, _ ginauth.GenericAuthMiddleware, err error) { + assert.ErrorIs(t, err, ginjwt.ErrInvalidAuthConfig) + }, + }, + { + name: "MissingJWKSConfig", + input: ginjwt.AuthConfig{ + Enabled: true, + Audience: "example-aud", + Issuer: "example-iss", + RoleValidationStrategy: "all", + }, + checkFn: func(t *testing.T, _ ginauth.GenericAuthMiddleware, err error) { + assert.ErrorIs(t, err, ginjwt.ErrInvalidAuthConfig) + }, + }, + { + name: "MissingAudience", + input: ginjwt.AuthConfig{ + Enabled: true, + Audience: "", + Issuer: "example-iss", + RoleValidationStrategy: "all", + }, + checkFn: func(t *testing.T, _ ginauth.GenericAuthMiddleware, err error) { + assert.ErrorIs(t, err, ginjwt.ErrInvalidAudience) + }, + }, + { + name: "MissingIssuer", + input: ginjwt.AuthConfig{ + Enabled: true, + Audience: "example-aud", + Issuer: "", + RoleValidationStrategy: "all", + }, + checkFn: func(t *testing.T, _ ginauth.GenericAuthMiddleware, err error) { + assert.ErrorIs(t, err, ginjwt.ErrInvalidIssuer) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(tt *testing.T) { + mw, err := ginjwt.NewAuthMiddleware(tc.input) + tc.checkFn(tt, mw, err) + }) + } +} diff --git a/ginjwt/multitokenmiddleware.go b/ginjwt/multitokenmiddleware.go new file mode 100644 index 0000000..7c23b3d --- /dev/null +++ b/ginjwt/multitokenmiddleware.go @@ -0,0 +1,29 @@ +package ginjwt + +import ( + "github.com/pkg/errors" + + "github.com/metal-toolbox/rivets/ginauth" +) + +// NewMultiTokenMiddlewareFromConfigs builds a MultiTokenMiddleware object from multiple AuthConfigs. +func NewMultiTokenMiddlewareFromConfigs(cfgs ...AuthConfig) (*ginauth.MultiTokenMiddleware, error) { + if len(cfgs) == 0 { + return nil, errors.Wrap(ErrInvalidAuthConfig, "configuration empty") + } + + mtm := &ginauth.MultiTokenMiddleware{} + + for i := range cfgs { + middleware, err := NewAuthMiddleware(cfgs[i]) + if err != nil { + return nil, err + } + + if err := mtm.Add(middleware); err != nil { + return nil, err + } + } + + return mtm, nil +} diff --git a/ginjwt/scopes.go b/ginjwt/scopes.go new file mode 100644 index 0000000..6431da2 --- /dev/null +++ b/ginjwt/scopes.go @@ -0,0 +1,43 @@ +package ginjwt + +import "fmt" + +// CreateScopes will return a list of scopes allowed for creating the items that are passed in +func CreateScopes(items ...string) []string { + s := []string{"write", "create"} + for _, i := range items { + s = append(s, fmt.Sprintf("create:%s", i)) + } + + return s +} + +// ReadScopes will return a list of scopes allowed for creating the items that are passed in. +func ReadScopes(items ...string) []string { + s := []string{"read"} + for _, i := range items { + s = append(s, fmt.Sprintf("read:%s", i)) + } + + return s +} + +// UpdateScopes will return a list of scopes allowed for updating the items that are passed in. +func UpdateScopes(items ...string) []string { + s := []string{"write", "update"} + for _, i := range items { + s = append(s, fmt.Sprintf("update:%s", i)) + } + + return s +} + +// DeleteScopes will return a list of scopes allowed for deleting the items that are passed in. +func DeleteScopes(items ...string) []string { + s := []string{"write", "delete"} + for _, i := range items { + s = append(s, fmt.Sprintf("delete:%s", i)) + } + + return s +} diff --git a/ginjwt/testtools.go b/ginjwt/testtools.go new file mode 100644 index 0000000..61791cb --- /dev/null +++ b/ginjwt/testtools.go @@ -0,0 +1,126 @@ +//go:build testtools +// +build testtools + +package ginjwt + +import ( + "crypto/rand" + "crypto/rsa" + "fmt" + "net" + "net/http" + "sync" + + "github.com/gin-gonic/gin" + "gopkg.in/square/go-jose.v2" + "gopkg.in/square/go-jose.v2/jwt" +) + +var ( + testKeySize = 2048 + + // TestPrivRSAKey1 provides an RSA key used to sign tokens + TestPrivRSAKey1, _ = rsa.GenerateKey(rand.Reader, testKeySize) + // TestPrivRSAKey1ID is the ID of this signing key in tokens + TestPrivRSAKey1ID = "testKey1" + // TestPrivRSAKey2 provides an RSA key used to sign tokens + TestPrivRSAKey2, _ = rsa.GenerateKey(rand.Reader, testKeySize) + // TestPrivRSAKey2ID is the ID of this signing key in tokens + TestPrivRSAKey2ID = "testKey2" + // TestPrivRSAKey3 provides an RSA key used to sign tokens + TestPrivRSAKey3, _ = rsa.GenerateKey(rand.Reader, testKeySize) + // TestPrivRSAKey3ID is the ID of this signing key in tokens + TestPrivRSAKey3ID = "testKey3" + // TestPrivRSAKey4 provides an RSA key used to sign tokens + TestPrivRSAKey4, _ = rsa.GenerateKey(rand.Reader, testKeySize) + // TestPrivRSAKey4ID is the ID of this signing key in tokens + TestPrivRSAKey4ID = "testKey4" + keyMap sync.Map +) + +func init() { + keyMap.Store(TestPrivRSAKey1ID, TestPrivRSAKey1) + keyMap.Store(TestPrivRSAKey2ID, TestPrivRSAKey2) + keyMap.Store(TestPrivRSAKey3ID, TestPrivRSAKey3) + keyMap.Store(TestPrivRSAKey4ID, TestPrivRSAKey4) +} + +// TestHelperMustMakeSigner will return a JWT signer from the given key +func TestHelperMustMakeSigner(alg jose.SignatureAlgorithm, kid string, k interface{}) jose.Signer { + sig, err := jose.NewSigner(jose.SigningKey{Algorithm: alg, Key: k}, (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", kid)) + if err != nil { + panic("failed to create signer:" + err.Error()) + } + + return sig +} + +// TestHelperJoseJWKSProvider returns a JWKS +func TestHelperJoseJWKSProvider(keyIDs ...string) jose.JSONWebKeySet { + jwks := make([]jose.JSONWebKey, len(keyIDs)) + + for idx, keyID := range keyIDs { + rawKey, found := keyMap.Load(keyID) + if !found { + panic("Failed finding private key to create test JWKS provider. Fix the test.") + } + + privKey := rawKey.(*rsa.PrivateKey) + + jwks[idx] = jose.JSONWebKey{ + KeyID: keyID, + Key: &privKey.PublicKey, + } + } + + return jose.JSONWebKeySet{ + Keys: jwks, + } +} + +// TestHelperJWKSProvider returns a url for a webserver that will return JSONWebKeySets +func TestHelperJWKSProvider(keyIDs ...string) string { + gin.SetMode(gin.TestMode) + r := gin.New() + + keySet := TestHelperJoseJWKSProvider(keyIDs...) + + r.GET("/.well-known/jwks.json", func(c *gin.Context) { + c.JSON(http.StatusOK, keySet) + }) + + //nolint:gosec // its a test, we dont care + listener, err := net.Listen("tcp", ":0") + if err != nil { + panic(err) + } + + //nolint:gosec // its a test, we dont care + s := &http.Server{ + Handler: r, + } + + go func() { + if err := s.Serve(listener); err != nil { + panic(err) + } + }() + + return fmt.Sprintf("http://localhost:%d/.well-known/jwks.json", listener.Addr().(*net.TCPAddr).Port) +} + +// TestHelperGetToken will return a signed token +// +//nolint:gocritic // Not replacing cl with a pointer +func TestHelperGetToken(signer jose.Signer, cl jwt.Claims, key string, value interface{}) string { + sc := map[string]interface{}{} + + sc[key] = value + + raw, err := jwt.Signed(signer).Claims(cl).Claims(sc).CompactSerialize() + if err != nil { + panic(err) + } + + return raw +} diff --git a/go.mod b/go.mod index f57d274..6b89ff6 100644 --- a/go.mod +++ b/go.mod @@ -5,43 +5,41 @@ go 1.22 toolchain go1.22.2 require ( - github.com/bmc-toolbox/common v0.0.0-20240510143200-3db7cecbb5a6 + github.com/bmc-toolbox/common v0.0.0-20240723142833-87832458b53b + github.com/gin-gonic/gin v1.10.0 github.com/google/uuid v1.6.0 github.com/hashicorp/go-multierror v1.1.1 - github.com/metal-toolbox/conditionorc v1.0.9-0.20240716090543-6e7e9300b375 - github.com/metal-toolbox/fleetdb v1.18.6 + github.com/metal-toolbox/fleetdb v1.19.5-0.20240913163810-6a9703ca4111 github.com/nats-io/nats-server/v2 v2.10.12 github.com/nats-io/nats.go v1.36.0 github.com/pkg/errors v0.9.1 - github.com/prometheus/client_golang v1.19.1 - github.com/sirupsen/logrus v1.9.3 + github.com/spf13/cobra v1.8.1 + github.com/spf13/pflag v1.0.5 + github.com/spf13/viper v1.19.0 github.com/stretchr/testify v1.9.0 - go.opentelemetry.io/otel v1.27.0 - go.opentelemetry.io/otel/sdk v1.27.0 - go.opentelemetry.io/otel/trace v1.27.0 - go.uber.org/goleak v1.3.0 - golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 + go.opentelemetry.io/otel v1.28.0 + go.opentelemetry.io/otel/sdk v1.28.0 + go.opentelemetry.io/otel/trace v1.28.0 + golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 + golang.org/x/net v0.28.0 + gopkg.in/square/go-jose.v2 v2.6.0 ) require github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect require ( - github.com/banzaicloud/logrus-runtime-formatter v0.0.0-20190729070250-5ae5475bae5e // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/bytedance/sonic v1.11.9 // indirect - github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/bytedance/sonic v1.12.1 // indirect + github.com/bytedance/sonic/loader v0.2.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect github.com/cockroachdb/cockroach-go/v2 v2.3.8 // indirect - github.com/coreos/go-oidc v2.2.1+incompatible // indirect github.com/ericlagergren/decimal v0.0.0-20240411145413-00de7ca16731 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect github.com/friendsofgo/errors v0.9.2 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.4 // indirect + github.com/gabriel-vasile/mimetype v1.4.5 // indirect github.com/gin-contrib/sse v0.1.0 // indirect - github.com/gin-gonic/gin v1.10.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -50,13 +48,10 @@ require ( github.com/goccy/go-json v0.10.3 // indirect github.com/gofrs/uuid v4.4.0+incompatible // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/googleapis/gax-go/v2 v2.12.5 // indirect - github.com/gorilla/websocket v1.5.3 // indirect + github.com/googleapis/gax-go/v2 v2.13.0 // indirect github.com/gosimple/slug v1.14.0 // indirect github.com/gosimple/unidecode v1.0.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/hashicorp/go-cleanhttp v0.5.2 // indirect - github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/hetiansu5/urlquery v1.2.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -86,20 +81,16 @@ require ( github.com/nats-io/nuid v1.0.1 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/pquerna/cachecontrol v0.2.0 // indirect + github.com/prometheus/client_golang v1.20.1 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/sagikazarmark/locafero v0.6.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect - github.com/slack-go/slack v0.13.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect - github.com/spf13/cobra v1.8.1 // indirect - github.com/spf13/pflag v1.0.5 // indirect - github.com/spf13/viper v1.19.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect @@ -111,26 +102,22 @@ require ( github.com/volatiletech/sqlboiler v3.7.1+incompatible // indirect github.com/volatiletech/sqlboiler/v4 v4.16.2 // indirect github.com/volatiletech/strmangle v0.0.6 // indirect - go.hollow.sh/toolbox v0.6.3 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 // indirect - go.opentelemetry.io/otel/metric v1.27.0 // indirect + go.opentelemetry.io/otel/metric v1.28.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect - gocloud.dev v0.37.0 // indirect - golang.org/x/arch v0.8.0 // indirect - golang.org/x/crypto v0.24.0 // indirect - golang.org/x/net v0.26.0 // indirect - golang.org/x/oauth2 v0.21.0 // indirect - golang.org/x/sys v0.21.0 // indirect - golang.org/x/text v0.16.0 // indirect + gocloud.dev v0.38.0 // indirect + golang.org/x/arch v0.9.0 // indirect + golang.org/x/crypto v0.26.0 // indirect + golang.org/x/sys v0.24.0 // indirect + golang.org/x/text v0.17.0 // indirect golang.org/x/time v0.5.0 // indirect - golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect - google.golang.org/api v0.186.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240624140628-dc46fd24d27d // indirect - google.golang.org/grpc v1.64.0 // indirect + golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 // indirect + google.golang.org/api v0.189.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240725223205-93522f1f2a9f // indirect + google.golang.org/grpc v1.65.0 // indirect google.golang.org/protobuf v1.34.2 // indirect gopkg.in/ini.v1 v1.67.0 // indirect - gopkg.in/square/go-jose.v2 v2.6.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 5c90495..c0cb872 100644 --- a/go.sum +++ b/go.sum @@ -30,10 +30,10 @@ cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Ud cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA= cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A= cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14= -cloud.google.com/go/auth v0.6.0 h1:5x+d6b5zdezZ7gmLWD1m/xNjnaQ2YDhmIz/HH3doy1g= -cloud.google.com/go/auth v0.6.0/go.mod h1:b4acV+jLQDyjwm4OXHYjNvRi4jvGBzHWJRtJcy+2P4g= -cloud.google.com/go/auth/oauth2adapt v0.2.2 h1:+TTV8aXpjeChS9M+aTtN/TjdQnzJvmzKFt//oWu7HX4= -cloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q= +cloud.google.com/go/auth v0.7.2 h1:uiha352VrCDMXg+yoBtaD0tUF4Kv9vrtrWPYXwutnDE= +cloud.google.com/go/auth v0.7.2/go.mod h1:VEc4p5NNxycWQTMQEDQF0bd6aTMb6VgYDXEwiJJQAbs= +cloud.google.com/go/auth/oauth2adapt v0.2.3 h1:MlxF+Pd3OmSudg/b1yZ5lJwoXCEaeedAguodky1PcKI= +cloud.google.com/go/auth/oauth2adapt v0.2.3/go.mod h1:tMQXOfZzFuNuUxOypHlQEXgdfX5cuhwU+ffUuXRJE8I= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= @@ -45,16 +45,16 @@ cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJW cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M= cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s= cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLqV/jJ3thU= -cloud.google.com/go/compute v1.25.1 h1:ZRpHJedLtTpKgr3RV1Fx23NuaAEN1Zfx9hw1u4aJdjU= -cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= -cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +cloud.google.com/go/compute v1.24.0 h1:phWcR2eWzRJaL/kOiJwfFsPs4BaKq1j6vnpZrc1YlVg= +cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY= +cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY= -cloud.google.com/go/iam v1.1.6 h1:bEa06k05IO4f4uJonbB5iAgKTPpABy1ayxaIZV/GHVc= -cloud.google.com/go/iam v1.1.6/go.mod h1:O0zxdPeGBoFdWW3HWmBxJsk0pfvNM/p/qa82rWOGTwI= -cloud.google.com/go/kms v1.15.7 h1:7caV9K3yIxvlQPAcaFffhlT7d1qpxjB1wHBtjWa13SM= -cloud.google.com/go/kms v1.15.7/go.mod h1:ub54lbsa6tDkUwnu4W7Yt1aAIFLnspgh0kPGToDukeI= +cloud.google.com/go/iam v1.1.7 h1:z4VHOhwKLF/+UYXAJDFwGtNF0b6gjsW1Pk9Ml0U/IoM= +cloud.google.com/go/iam v1.1.7/go.mod h1:J4PMPg8TtyurAUvSmPj8FF3EDgY1SPRZxcUGrn7WXGA= +cloud.google.com/go/kms v1.15.8 h1:szIeDCowID8th2i8XE4uRev5PMxQFqW+JjwYxL9h6xs= +cloud.google.com/go/kms v1.15.8/go.mod h1:WoUHcDjD9pluCg7pNds131awnH429QGvRM3N/4MyoVs= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= @@ -93,19 +93,18 @@ github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmV github.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= -github.com/banzaicloud/logrus-runtime-formatter v0.0.0-20190729070250-5ae5475bae5e h1:ZOnKnYG1LLgq4W7wZUYj9ntn3RxQ65EZyYqdtFpP2Dw= -github.com/banzaicloud/logrus-runtime-formatter v0.0.0-20190729070250-5ae5475bae5e/go.mod h1:hEvEpPmuwKO+0TbrDQKIkmX0gW2s2waZHF8pIhEEmpM= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= 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/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= -github.com/bmc-toolbox/common v0.0.0-20240510143200-3db7cecbb5a6 h1:qJJDtxYKk/sMfF6F3hVAcM+KDpN1H58gNWpdtaQys0o= -github.com/bmc-toolbox/common v0.0.0-20240510143200-3db7cecbb5a6/go.mod h1:SY//n1PJjZfbFbmAsB6GvEKbc7UXz3d30s3kWxfJQ/c= -github.com/bytedance/sonic v1.11.9 h1:LFHENlIY/SLzDWverzdOvgMztTxcfcF+cqNsz9pK5zg= -github.com/bytedance/sonic v1.11.9/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= -github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bmc-toolbox/common v0.0.0-20240723142833-87832458b53b h1:0LHjikaGWlqEMczrCEZ6w1N/ZqcYlx6WRHkhabRUQEk= +github.com/bmc-toolbox/common v0.0.0-20240723142833-87832458b53b/go.mod h1:Cdnkm+edb6C0pVkyCrwh3JTXAe0iUF9diDG/DztPI9I= +github.com/bytedance/sonic v1.12.1 h1:jWl5Qz1fy7X1ioY74WqO0KjAMtAGQs4sYnjiEBiyX24= +github.com/bytedance/sonic v1.12.1/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/bytedance/sonic/loader v0.2.0 h1:zNprn+lsIP06C/IqCHs3gPQIvnvpKbbxyXQP1iU4kWM= +github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= @@ -134,8 +133,6 @@ github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= github.com/cockroachdb/cockroach-go/v2 v2.3.8 h1:53yoUo4+EtrC1NrAEgnnad4AS3ntNvGup1PAXZ7UmpE= github.com/cockroachdb/cockroach-go/v2 v2.3.8/go.mod h1:9uH5jK4yQ3ZQUT9IXe4I2fHzMIF5+JC/oOdzTRgJYJk= -github.com/coreos/go-oidc v2.2.1+incompatible h1:mh48q/BqXqgjVHpy2ZY7WnWAbenxRjsz9N1i1YxjHAk= -github.com/coreos/go-oidc v2.2.1+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= @@ -167,8 +164,6 @@ github.com/ericlagergren/decimal v0.0.0-20240411145413-00de7ca16731/go.mod h1:M9 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= -github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= -github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= @@ -179,15 +174,15 @@ github.com/friendsofgo/errors v0.9.2/go.mod h1:yCvFW5AkDIL9qn7suHVLiI/gH228n7PC4 github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I= -github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s= +github.com/gabriel-vasile/mimetype v1.4.5 h1:J7wGKdGu33ocBOhGy0z653k/lFKLFDPJMG8Gql0kxn4= +github.com/gabriel-vasile/mimetype v1.4.5/go.mod h1:ibHel+/kbxn9x2407k1izTA1S81ku1z/DlgOW2QE0M4= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw= github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= -github.com/gin-contrib/zap v1.1.3 h1:9e/U9fYd4/OBfmSEBs5hHZq114uACn7bpuzvCkcJySA= -github.com/gin-contrib/zap v1.1.3/go.mod h1:+BD/6NYZKJyUpqVoJEvgeq9GLz8pINEQvak9LHNOTSE= +github.com/gin-contrib/zap v1.1.4 h1:xvxTybg6XBdNtcQLH3Tf0lFr4vhDkwzgLLrIGlNTqIo= +github.com/gin-contrib/zap v1.1.4/go.mod h1:7lgEpe91kLbeJkwBTPgtVBy4zMa6oSBEcvj662diqKQ= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= @@ -216,8 +211,6 @@ github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LB github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= -github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -328,12 +321,9 @@ github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0 github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM= github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM= github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK9wbMD5+iXC6c= -github.com/googleapis/gax-go/v2 v2.12.5 h1:8gw9KZK8TiVKB6q3zHY3SBzLnrGp6HQjyfYBYGmXdxA= -github.com/googleapis/gax-go/v2 v2.12.5/go.mod h1:BUDKcWo+RaKq5SC9vVYL0wLADa3VcfswbOMMRmB9H3E= +github.com/googleapis/gax-go/v2 v2.13.0 h1:yitjD5f7jQHhyDsnhKEBU52NdvvdSeGzlAnDPT0hH1s= +github.com/googleapis/gax-go/v2 v2.13.0/go.mod h1:Z/fvTZXF8/uw7Xu5GuslPw+bplx6SS338j1Is2S+B7A= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= -github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gosimple/slug v1.14.0 h1:RtTL/71mJNDfpUbCOmnf/XFkzKRtD6wL6Uy+3akm4Es= github.com/gosimple/slug v1.14.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ= github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o= @@ -347,12 +337,9 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= -github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= github.com/hashicorp/go-hclog v1.2.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= -github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= -github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= @@ -361,8 +348,6 @@ github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= -github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= -github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= @@ -499,8 +484,6 @@ github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVc github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= -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-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= @@ -516,10 +499,8 @@ github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4 github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/metal-toolbox/conditionorc v1.0.9-0.20240716090543-6e7e9300b375 h1:orfbPy18JNGVqxWNSUWXZB4ubGGR1QUjdkDewmJ/uSA= -github.com/metal-toolbox/conditionorc v1.0.9-0.20240716090543-6e7e9300b375/go.mod h1:3lCqiKLJjw9xHrsbZPtJQoEJY8waY8+lqvouA8FbS+M= -github.com/metal-toolbox/fleetdb v1.18.6 h1:VxTs4T2zKh9KbjPNo18Z/D6OmPqArPK2PNQ8w/VmVz4= -github.com/metal-toolbox/fleetdb v1.18.6/go.mod h1:QoNpDNVXxt7YqrxRIWkWp1hR3LJ2YXMnYavXwefOUnQ= +github.com/metal-toolbox/fleetdb v1.19.5-0.20240913163810-6a9703ca4111 h1:WX236DysSYXrlHueyk8WMxUj/vReFUPumXjulZAhHgo= +github.com/metal-toolbox/fleetdb v1.19.5-0.20240913163810-6a9703ca4111/go.mod h1:jaKeC1iiYjXhEPFoUTWtOM5Ni7+5+XZWIXnHIiBdq94= github.com/microsoft/go-mssqldb v0.17.0/go.mod h1:OkoNGhGEs8EZqchVTtochlXruEhEOaO4S0d2sB5aeGQ= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= @@ -579,15 +560,13 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= -github.com/pquerna/cachecontrol v0.2.0 h1:vBXSNuE5MYP9IJ5kjsdo8uq+w41jSPgvba2DEnkRx9k= -github.com/pquerna/cachecontrol v0.2.0/go.mod h1:NrUG3Z7Rdu85UNR3vm7SOsl1nFIeSiQnrHV5K9mBcUI= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= -github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= -github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= +github.com/prometheus/client_golang v1.20.1 h1:IMJXHOD6eARkQpxo8KkhgEVFlBNm+nkrFUyGlIu7Na8= +github.com/prometheus/client_golang v1.20.1/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= @@ -633,10 +612,6 @@ github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPx github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/slack-go/slack v0.13.0 h1:7my/pR2ubZJ9912p9FtvALYpbt0cQPAqkRy2jaSI1PQ= -github.com/slack-go/slack v0.13.0/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= @@ -712,8 +687,6 @@ go.etcd.io/etcd/api/v3 v3.5.4/go.mod h1:5GB2vv4A4AOn3yk7MftYGHkUfGtDHnEraIjym4dY go.etcd.io/etcd/client/pkg/v3 v3.5.4/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= go.etcd.io/etcd/client/v2 v2.305.4/go.mod h1:Ud+VUwIi9/uQHOMA+4ekToJ12lTxlv0zB/+DHwTGEbU= go.etcd.io/etcd/client/v3 v3.5.4/go.mod h1:ZaRkVgBZC+L+dLCjTcF1hRXpgZXQPOvnA/Ak/gq3kiY= -go.hollow.sh/toolbox v0.6.3 h1:IJOjiGdiwWwXJ2QfOkJuSucSIqrdXJbUBFst3u6T6z4= -go.hollow.sh/toolbox v0.6.3/go.mod h1:nl+5RDDyYY/+wukOUzHHX2mOyWKRjlTOXUcGxny+tns= go.infratographer.com/x v0.5.3 h1:Ul50kwszNWPSf9Tds7RKmSQx+QHZbE8Jy9J38cEztP8= go.infratographer.com/x v0.5.3/go.mod h1:IyZALpwaaviUIN8bGp9cU0hnn1mn0A/6zi70XES4+iE= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= @@ -725,20 +698,20 @@ go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.52.0 h1:vkioc4XBfqnZZ7u40wK3Kgbjj9JYkvW6FY1ghmM/Shk= -go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.52.0/go.mod h1:vsyxiwPzPlijgouF1SRZRGqbuHod8fV6+MRCH7ltxDE= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0= +go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.53.0 h1:ktt8061VV/UU5pdPF6AcEFyuPxMizf/vU6eD1l+13LI= +go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.53.0/go.mod h1:JSRiHPV7E3dbOAP0N6SRPg2nC/cugJnVXRqP018ejtY= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0 h1:A3SayB3rNyt+1S6qpI9mHPkeHTZbD7XILEqWnYZb2l0= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0/go.mod h1:27iA5uvhuRNmalO+iEUdVn5ZMj2qy10Mm+XRIpRmyuU= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0 h1:9l89oX4ba9kHbBol3Xin3leYJ+252h0zszDtBwyKe2A= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.52.0/go.mod h1:XLZfZboOJWHNKUv7eH0inh0E9VV6eWDFB/9yJyTLPp0= -go.opentelemetry.io/otel v1.27.0 h1:9BZoF3yMK/O1AafMiQTVu0YDj5Ea4hPhxCs7sGva+cg= -go.opentelemetry.io/otel v1.27.0/go.mod h1:DMpAK8fzYRzs+bi3rS5REupisuqTheUlSZJ1WnZaPAQ= -go.opentelemetry.io/otel/metric v1.27.0 h1:hvj3vdEKyeCi4YaYfNjv2NUje8FqKqUY8IlF0FxV/ik= -go.opentelemetry.io/otel/metric v1.27.0/go.mod h1:mVFgmRlhljgBiuk/MP/oKylr4hs85GZAylncepAX/ak= -go.opentelemetry.io/otel/sdk v1.27.0 h1:mlk+/Y1gLPLn84U4tI8d3GNJmGT/eXe3ZuOXN9kTWmI= -go.opentelemetry.io/otel/sdk v1.27.0/go.mod h1:Ha9vbLwJE6W86YstIywK2xFfPjbWlCuwPtMkKdz/Y4A= -go.opentelemetry.io/otel/trace v1.27.0 h1:IqYb813p7cmbHk0a5y6pD5JPakbVfftRXABGt5/Rscw= -go.opentelemetry.io/otel/trace v1.27.0/go.mod h1:6RiD1hkAprV4/q+yd2ln1HG9GoPx39SuvvstaLBl+l4= +go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo= +go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4= +go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q= +go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s= +go.opentelemetry.io/otel/sdk v1.28.0 h1:b9d7hIry8yZsgtbmM0DKyPWMMUMlK9NEKuIG4aBqWyE= +go.opentelemetry.io/otel/sdk v1.28.0/go.mod h1:oYj7ClPUA7Iw3m+r7GeEjz0qckQRJK2B8zjcZEfu7Pg= +go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g= +go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= @@ -760,11 +733,10 @@ go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -gocloud.dev v0.37.0 h1:XF1rN6R0qZI/9DYjN16Uy0durAmSlf58DHOcb28GPro= -gocloud.dev v0.37.0/go.mod h1:7/O4kqdInCNsc6LqgmuFnS0GRew4XNNYWpA44yQnwco= -golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= -golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +gocloud.dev v0.38.0 h1:SpxfaOc/Fp4PeO8ui7wRcCZV0EgXZ+IWcVSLn6ZMSw0= +gocloud.dev v0.38.0/go.mod h1:3XjKvd2E5iVNu/xFImRzjN0d/fkNHe4s0RiKidpEUMQ= +golang.org/x/arch v0.9.0 h1:ub9TgUInamJ8mrZIGlBG6/4TqWeMszd4N8lNorbrr6k= +golang.org/x/arch v0.9.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= @@ -786,8 +758,8 @@ golang.org/x/crypto v0.0.0-20220511200225-c6db032c6c88/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ= -golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= -golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= +golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -798,8 +770,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY= -golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -880,8 +852,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= -golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= +golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -918,8 +890,8 @@ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20220513210516-0976fa681c29/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1003,7 +975,6 @@ golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220825204002-c680a09ffe64/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -1011,8 +982,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= -golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= +golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -1031,8 +1002,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -1110,8 +1081,8 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= -golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= -golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 h1:LLhsEBxRTBLuKlQxFBYUOU8xyFgXv6cOTp2HASDlsDk= +golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= @@ -1151,8 +1122,8 @@ google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRR google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA= google.golang.org/api v0.78.0/go.mod h1:1Sg78yoMLOhlQTeF+ARBoytAcH1NNyyl390YMy6rKmw= google.golang.org/api v0.81.0/go.mod h1:FA6Mb/bZxj706H2j+j2d6mHEEaHBmbbWnkfvmorOCko= -google.golang.org/api v0.186.0 h1:n2OPp+PPXX0Axh4GuSsL5QL8xQCTb2oDwyzPnQvqUug= -google.golang.org/api v0.186.0/go.mod h1:hvRbBmgoje49RV3xqVXrmP6w93n6ehGgIVPYrGtBFFc= +google.golang.org/api v0.189.0 h1:equMo30LypAkdkLMBqfeIqtyAnlyig1JSZArl4XPwdI= +google.golang.org/api v0.189.0/go.mod h1:FLWGJKb0hb+pU2j+rJqwbnsF+ym+fQs73rbJ+KAUgy8= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -1238,12 +1209,12 @@ google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX google.golang.org/genproto v0.0.0-20220429170224-98d788798c3e/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= google.golang.org/genproto v0.0.0-20220519153652-3a47de7e79bd/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4= -google.golang.org/genproto v0.0.0-20240617180043-68d350f18fd4 h1:CUiCqkPw1nNrNQzCCG4WA65m0nAmQiwXHpub3dNyruU= -google.golang.org/genproto v0.0.0-20240617180043-68d350f18fd4/go.mod h1:EvuUDCulqGgV80RvP1BHuom+smhX4qtlhnNatHuroGQ= -google.golang.org/genproto/googleapis/api v0.0.0-20240624140628-dc46fd24d27d h1:Aqf0fiIdUQEj0Gn9mKFFXoQfTTEaNopWpfVyYADxiSg= -google.golang.org/genproto/googleapis/api v0.0.0-20240624140628-dc46fd24d27d/go.mod h1:Od4k8V1LQSizPRUK4OzZ7TBE/20k+jPczUDAEyvn69Y= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240624140628-dc46fd24d27d h1:k3zyW3BYYR30e8v3x0bTDdE9vpYFjZHK+HcyqkrppWk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240624140628-dc46fd24d27d/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= +google.golang.org/genproto v0.0.0-20240722135656-d784300faade h1:lKFsS7wpngDgSCeFn7MoLy+wBDQZ1UQIJD4UNM1Qvkg= +google.golang.org/genproto v0.0.0-20240722135656-d784300faade/go.mod h1:FfBgJBJg9GcpPvKIuHSZ/aE1g2ecGL74upMzGZjiGEY= +google.golang.org/genproto/googleapis/api v0.0.0-20240725223205-93522f1f2a9f h1:b1Ln/PG8orm0SsBbHZWke8dDp2lrCD4jSmfglFpTZbk= +google.golang.org/genproto/googleapis/api v0.0.0-20240725223205-93522f1f2a9f/go.mod h1:AHT0dDg3SoMOgZGnZk29b5xTbPHMoEC8qthmBLJCpys= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240725223205-93522f1f2a9f h1:RARaIm8pxYuxyNPbBQf5igT7XdOyCNtat1qAT2ZxjU4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240725223205-93522f1f2a9f/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -1274,8 +1245,8 @@ google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ5 google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk= -google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY= -google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg= +google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc= +google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= @@ -1364,7 +1335,6 @@ modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/z v1.5.1/go.mod h1:eWFB510QWW5Th9YGZT81s+LwvaAs3Q2yr4sP0rmLkv8= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= diff --git a/version/doc.go b/version/doc.go new file mode 100644 index 0000000..514f8b5 --- /dev/null +++ b/version/doc.go @@ -0,0 +1,3 @@ +// Package version provides version strings and version information for the +// application. This is a shared component that all hollow code bases can share +package version // import "go.hollow.sh/utils/version" diff --git a/version/version.go b/version/version.go new file mode 100644 index 0000000..de4db70 --- /dev/null +++ b/version/version.go @@ -0,0 +1,24 @@ +package version + +import ( + "fmt" +) + +// These variables are substituted with real values at build time +var ( + appName = "toolbox" + version = "dev" + commit = "" + date = "" + builtBy = "dev" +) + +// String returns the version as a formatted string +func String() string { + return fmt.Sprintf("%s: %s (%s@%s by %s)", appName, version, commit, date, builtBy) +} + +// Version returns the release version without additional version information +func Version() string { + return version +} diff --git a/version/version_test.go b/version/version_test.go new file mode 100644 index 0000000..bfbff45 --- /dev/null +++ b/version/version_test.go @@ -0,0 +1,18 @@ +package version + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestString(t *testing.T) { + appName = "toolbox test" + version = "0.0.1" + commit = "abc123" + date = "Some point in time" + builtBy = "go test" + + assert.Equal(t, "toolbox test: 0.0.1 (abc123@Some point in time by go test)", String()) + assert.Equal(t, "0.0.1", Version()) +}