From 47a2af5de168a4e3f129311d43c8362fca39aa73 Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Fri, 11 Sep 2026 10:15:00 +0300 Subject: [PATCH 1/5] Upgrade project to Go 1.27 --- .claude/rules/go-style.md | 4 ++-- .github/workflows/lint.yml | 2 +- .github/workflows/security-scan.yml | 12 +----------- docs/README.md | 2 +- docs/arch/05-runconfig-and-permissions.md | 2 +- docs/runtime-version-customization.md | 4 ++-- docs/server/docs.go | 2 +- docs/server/swagger.json | 2 +- docs/server/swagger.yaml | 2 +- go.mod | 2 +- pkg/container/templates/runtime_config.go | 4 ++-- pkg/container/templates/runtime_config_test.go | 4 ++-- pkg/runner/protocol_test.go | 2 +- 13 files changed, 17 insertions(+), 27 deletions(-) diff --git a/.claude/rules/go-style.md b/.claude/rules/go-style.md index e601ae9589..58b0c2a909 100644 --- a/.claude/rules/go-style.md +++ b/.claude/rules/go-style.md @@ -10,8 +10,8 @@ Applies to all Go files in the project. ## Go Module (`go.mod`) Conventions -- The `go` directive must not pin a non-zero patch version (`go 1.26.4` is - rejected by CI). `go 1.26` and `go 1.26.0` are both fine — a dependency's +- The `go` directive must not pin a non-zero patch version (`go 1.27.4` is + rejected by CI). `go 1.27` and `go 1.27.0` are both fine — a dependency's own `go.mod` floor is always `X.Y.0`, never a later patch, so `X.Y.0` is allowed to unblock a forced bump (e.g. picking up a CVE fix) without reintroducing the patch-version churn this rule exists to prevent. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 9720cffcdb..6757d9cec1 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -48,7 +48,7 @@ jobs: ${{ runner.os }}-go-build- - name: Check go.mod version format - run: "! grep -qE '^go [0-9]+\\.[0-9]+\\.[1-9][0-9]*$' go.mod || { echo 'ERROR: go.mod must not pin a non-zero Go patch version (go 1.26 or go 1.26.0 are fine; a dependency floor is always X.Y.0, never a later patch)'; exit 1; }" + run: "! grep -qE '^go [0-9]+\\.[0-9]+\\.[1-9][0-9]*$' go.mod || { echo 'ERROR: go.mod must not pin a non-zero Go patch version (go 1.27 or go 1.27.0 are fine; a dependency floor is always X.Y.0, never a later patch)'; exit 1; }" - name: Run golangci-lint uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 511f024606..16a46c3e54 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -118,16 +118,6 @@ jobs: run: | set -euo pipefail - # Ignored vulnerabilities with justification: - # Go stdlib advisories published 2026-06-02, all fixed in go1.26.4 / - # go1.25.11 (DoS / log-injection class, no RCE): - # GO-2026-5037 (CVE-2026-27145, crypto/x509 VerifyHostname) - # GO-2026-5038 (CVE-2026-42504, mime WordDecoder.DecodeHeader) - # GO-2026-5039 (CVE-2026-42507, net/textproto error messages) - # CI's `setup-go: stable` still resolves to go1.26.3 because the - # actions/go-versions manifest lags the Go release. Temporary - # exclusion; remove once CI builds on go1.26.4 or later. - # # GO-2026-5932: golang.org/x/crypto/openpgp is deprecated-by-design # ("unsafe, not maintained, should not be used"). No fixed version # exists and none is planned. @@ -170,7 +160,7 @@ jobs: # REMOVAL TRIGGER: `go 1.27` sorts above `1.26.0`, so moving the # go directive to 1.27 satisfies x/crypto v0.56.0 without a patch # version. Bump x/crypto and drop both entries then. - IGNORED_VULNS="GO-2026-5037 GO-2026-5038 GO-2026-5039 GO-2026-5932 GO-2026-6354 GO-2026-6355" + IGNORED_VULNS="GO-2026-5932 GO-2026-6354 GO-2026-6355" # Show the raw output for debugging # A scan that produced nothing is a failed scan, not a clean one. diff --git a/docs/README.md b/docs/README.md index f65a1437f7..0838f5feba 100644 --- a/docs/README.md +++ b/docs/README.md @@ -58,7 +58,7 @@ install Go and set up your development environment. ### Prerequisites -- **Go**: ToolHive requires Go 1.25. You can download and install Go from the +- **Go**: ToolHive requires Go 1.27. You can download and install Go from the [official Go website](https://go.dev/doc/install). - **Task** (Recommended): Install the [Task](https://taskfile.dev/) tool to run diff --git a/docs/arch/05-runconfig-and-permissions.md b/docs/arch/05-runconfig-and-permissions.md index d1b91b1060..aae00e9f44 100644 --- a/docs/arch/05-runconfig-and-permissions.md +++ b/docs/arch/05-runconfig-and-permissions.md @@ -90,7 +90,7 @@ The complete `RunConfig` struct is defined in `pkg/runner/config.go`. **Fields:** - `builder_image`: Override the default base image for the builder stage - - Go: Default `golang:1.26-alpine` + - Go: Default `golang:1.27-alpine` - Node: Default `node:24-alpine` - Python: Default `python:3.14-slim` - `additional_packages`: Extra packages to install during the build and runtime stages (e.g., build tools, libraries) diff --git a/docs/runtime-version-customization.md b/docs/runtime-version-customization.md index 9ec8c1c236..7dee2a0774 100644 --- a/docs/runtime-version-customization.md +++ b/docs/runtime-version-customization.md @@ -6,7 +6,7 @@ This guide explains how to customize the base images and packages used when runn When you use protocol schemes like `thv run go://github.com/example/server`, ToolHive automatically generates a container image. By default, it uses: -- **Go**: `golang:1.26-alpine` (builder), `alpine:3.23` (runtime) +- **Go**: `golang:1.27-alpine` (builder), `alpine:3.23` (runtime) - **Node**: `node:24-alpine` (builder and runtime) - **Python**: `python:3.14-slim` (builder and runtime) @@ -28,7 +28,7 @@ Override the default base image for the builder stage. **Examples:** ```bash -# Use Go 1.23 instead of default 1.26 +# Use Go 1.23 instead of default 1.27 thv run go://github.com/example/server --runtime-image golang:1.23-alpine # Use Node 20 LTS instead of default 22 diff --git a/docs/server/docs.go b/docs/server/docs.go index 5f25e07cad..0a0592ea7c 100644 --- a/docs/server/docs.go +++ b/docs/server/docs.go @@ -5511,7 +5511,7 @@ const docTemplate = `{ "uniqueItems": false }, "builder_image": { - "description": "BuilderImage is the full image reference for the builder stage.\nAn empty string signals \"use the default for this transport type\" during config merging.\nExamples: \"golang:1.26-alpine\", \"node:24-alpine\", \"python:3.14-slim\"", + "description": "BuilderImage is the full image reference for the builder stage.\nAn empty string signals \"use the default for this transport type\" during config merging.\nExamples: \"golang:1.27-alpine\", \"node:24-alpine\", \"python:3.14-slim\"", "type": "string" }, "runtime_env": { diff --git a/docs/server/swagger.json b/docs/server/swagger.json index f1c5f91d51..2e0f2635bf 100644 --- a/docs/server/swagger.json +++ b/docs/server/swagger.json @@ -5504,7 +5504,7 @@ "uniqueItems": false }, "builder_image": { - "description": "BuilderImage is the full image reference for the builder stage.\nAn empty string signals \"use the default for this transport type\" during config merging.\nExamples: \"golang:1.26-alpine\", \"node:24-alpine\", \"python:3.14-slim\"", + "description": "BuilderImage is the full image reference for the builder stage.\nAn empty string signals \"use the default for this transport type\" during config merging.\nExamples: \"golang:1.27-alpine\", \"node:24-alpine\", \"python:3.14-slim\"", "type": "string" }, "runtime_env": { diff --git a/docs/server/swagger.yaml b/docs/server/swagger.yaml index e869b6df35..28e75e3997 100644 --- a/docs/server/swagger.yaml +++ b/docs/server/swagger.yaml @@ -5161,7 +5161,7 @@ components: description: |- BuilderImage is the full image reference for the builder stage. An empty string signals "use the default for this transport type" during config merging. - Examples: "golang:1.26-alpine", "node:24-alpine", "python:3.14-slim" + Examples: "golang:1.27-alpine", "node:24-alpine", "python:3.14-slim" type: string runtime_env: additionalProperties: diff --git a/go.mod b/go.mod index f9cdff9137..ddee3f1fec 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/stacklok/toolhive -go 1.26.0 +go 1.27 require ( dario.cat/mergo v1.0.2 diff --git a/pkg/container/templates/runtime_config.go b/pkg/container/templates/runtime_config.go index 542ad71c17..f698cdbe97 100644 --- a/pkg/container/templates/runtime_config.go +++ b/pkg/container/templates/runtime_config.go @@ -56,7 +56,7 @@ var runtimeEnvDangerousValuePatterns = []string{ type RuntimeConfig struct { // BuilderImage is the full image reference for the builder stage. // An empty string signals "use the default for this transport type" during config merging. - // Examples: "golang:1.26-alpine", "node:24-alpine", "python:3.14-slim" + // Examples: "golang:1.27-alpine", "node:24-alpine", "python:3.14-slim" BuilderImage string `json:"builder_image" yaml:"builder_image"` // AdditionalPackages lists extra packages to install in the builder and @@ -254,7 +254,7 @@ func (rc *RuntimeConfig) IsEmpty() bool { // RuntimeDefaults provides default configurations for each runtime type var RuntimeDefaults = map[TransportType]RuntimeConfig{ TransportTypeGO: { - BuilderImage: "golang:1.26-alpine", + BuilderImage: "golang:1.27-alpine", AdditionalPackages: []string{"ca-certificates", "git"}, }, TransportTypeNPX: { diff --git a/pkg/container/templates/runtime_config_test.go b/pkg/container/templates/runtime_config_test.go index 31deb00aec..a45685cbf0 100644 --- a/pkg/container/templates/runtime_config_test.go +++ b/pkg/container/templates/runtime_config_test.go @@ -24,7 +24,7 @@ func TestGetDefaultRuntimeConfig(t *testing.T) { { name: "Go default config", transportType: TransportTypeGO, - wantImage: "golang:1.26-alpine", + wantImage: "golang:1.27-alpine", wantPackages: []string{"ca-certificates", "git"}, }, { @@ -137,7 +137,7 @@ func TestGetDockerfileTemplateUsesDefaultWhenNil(t *testing.T) { } // Should use default Go version - if !strings.Contains(result, "FROM golang:1.26-alpine AS builder") { + if !strings.Contains(result, "FROM golang:1.27-alpine AS builder") { t.Error("Dockerfile does not contain default Go version") } } diff --git a/pkg/runner/protocol_test.go b/pkg/runner/protocol_test.go index 47de541e3b..c3c6109b21 100644 --- a/pkg/runner/protocol_test.go +++ b/pkg/runner/protocol_test.go @@ -396,7 +396,7 @@ func TestLoadRuntimeConfigMergesPerTransportDefaults(t *testing.T) { BuilderImage: "", AdditionalPackages: []string{"make"}, }, - wantImage: "golang:1.26-alpine", + wantImage: "golang:1.27-alpine", wantPackages: []string{"ca-certificates", "git", "make"}, wantRuntimeEnv: nil, }, From 890fcb1a4ce1895160e6dd04988e423e818dd451 Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Fri, 11 Sep 2026 10:26:22 +0300 Subject: [PATCH 2/5] Fix Go 1.27 compatibility --- Taskfile.yml | 6 +++--- pkg/authserver/server/handlers/token_test.go | 2 +- pkg/authserver/storage/cimd_decorator.go | 5 ++--- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index 62ce8127aa..5b5a8da0a2 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -98,7 +98,7 @@ tasks: platforms: [linux, darwin] # we have to use ldflags to avoid the LC_DYSYMTAB linker error. # https://github.com/stacklok/toolhive/issues/1687 - - go test -ldflags=-extldflags=-Wl,-w -v -json -race $(go list ./... | grep -v '/test/e2e' | grep -v '/cmd/thv-operator/test-integration') | gotestfmt -hide "all" + - GODEBUG=gotestjsonbuildtext=1 go test -ldflags=-extldflags=-Wl,-w -v -json -race $(go list ./... | grep -v '/test/e2e' | grep -v '/cmd/thv-operator/test-integration') | gotestfmt -hide "all" test-windows: desc: Run unit tests (excluding e2e tests) on Windows with race detection @@ -139,7 +139,7 @@ tasks: platforms: [linux, darwin] # we have to use ldflags to avoid the LC_DYSYMTAB linker error. # https://github.com/stacklok/toolhive/issues/1687 - - go test -ldflags=-extldflags=-Wl,-w -json -race -coverpkg=./... -coverprofile=coverage/coverage.out $(go list ./... | grep -v '/test/e2e' | grep -v '/cmd/thv-operator/test-integration') | gotestfmt -hide "all" + - GODEBUG=gotestjsonbuildtext=1 go test -ldflags=-extldflags=-Wl,-w -json -race -coverpkg=./... -coverprofile=coverage/coverage.out $(go list ./... | grep -v '/test/e2e' | grep -v '/cmd/thv-operator/test-integration') | gotestfmt -hide "all" - go tool cover -func=coverage/coverage.out - echo "Generating HTML coverage report in coverage/coverage.html" - go tool cover -html=coverage/coverage.out -o coverage/coverage.html @@ -204,7 +204,7 @@ tasks: internal: true cmds: - which gotestfmt > /dev/null 2>&1 || go install github.com/gotesttools/gotestfmt/v2/cmd/gotestfmt@latest - - go test -ldflags=-extldflags=-Wl,-w -v -json -race -tags integration ./... | gotestfmt -hide "all" + - GODEBUG=gotestjsonbuildtext=1 go test -ldflags=-extldflags=-Wl,-w -v -json -race -tags integration ./... | gotestfmt -hide "all" test-integration-windows: desc: Run integration tests on Windows (requires Docker) diff --git a/pkg/authserver/server/handlers/token_test.go b/pkg/authserver/server/handlers/token_test.go index c7587e0016..aeea9525eb 100644 --- a/pkg/authserver/server/handlers/token_test.go +++ b/pkg/authserver/server/handlers/token_test.go @@ -111,7 +111,7 @@ func TestLogClientLookupFailure(t *testing.T) { }, { name: "unresolvable CIMD client_id logs at Debug", - err: fmt.Errorf("%w: CIMD fetch failed: %w", fosite.ErrNotFound, errors.New("connection refused")), + err: fosite.ErrNotFound.WithHint("CIMD fetch failed").WithWrap(errors.New("connection refused")), wantLevel: slog.LevelDebug, }, { diff --git a/pkg/authserver/storage/cimd_decorator.go b/pkg/authserver/storage/cimd_decorator.go index 9b6545ec5c..5d84a5c56f 100644 --- a/pkg/authserver/storage/cimd_decorator.go +++ b/pkg/authserver/storage/cimd_decorator.go @@ -216,7 +216,7 @@ func (d *CIMDStorageDecorator) fetch(ctx context.Context, id string) (fosite.Cli // (see issue #6186). slog.WarnContext(ctx, "CIMD document fetch failed", "client_id", id, "error", err) - return nil, fmt.Errorf("%w: %w", fosite.ErrNotFound.WithHint("CIMD fetch failed"), err) + return nil, fosite.ErrNotFound.WithHint("CIMD fetch failed").WithWrap(err) } // Negotiate the effective token_endpoint_auth_method rather than rejecting @@ -228,9 +228,8 @@ func (d *CIMDStorageDecorator) fetch(ctx context.Context, id string) (fosite.Cli if !ok { slog.WarnContext(ctx, "CIMD client rejected: unsupported token_endpoint_auth_method", "client_id", id, "token_endpoint_auth_method", doc.TokenEndpointAuthMethod) - return nil, fmt.Errorf("%w: CIMD document at %s claims token_endpoint_auth_method %q "+ + return nil, fosite.ErrInvalidClient.WithHintf("CIMD document at %s claims token_endpoint_auth_method %q "+ "but this server only supports %q (token_endpoint_auth_methods_supported: %v)", - fosite.ErrInvalidClient.WithHint("unsupported token_endpoint_auth_method"), id, doc.TokenEndpointAuthMethod, defaultCIMDTokenEndpointAuthMethod, doc.TokenEndpointAuthMethodsSupported) } From e7826067d70fa6b1d7dc620983d8245e2f7b2759 Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Fri, 11 Sep 2026 10:33:46 +0300 Subject: [PATCH 3/5] Fix remaining Go 1.27 vet warnings --- pkg/authserver/storage/cimd_decorator.go | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/pkg/authserver/storage/cimd_decorator.go b/pkg/authserver/storage/cimd_decorator.go index 5d84a5c56f..15ea7f99a3 100644 --- a/pkg/authserver/storage/cimd_decorator.go +++ b/pkg/authserver/storage/cimd_decorator.go @@ -251,8 +251,7 @@ func (d *CIMDStorageDecorator) fetch(ctx context.Context, id string) (fosite.Cli if dcrErr != nil { slog.WarnContext(ctx, "CIMD client rejected: invalid grant_types", "client_id", id, "error", dcrErr.ErrorDescription) - return nil, fmt.Errorf("%w: CIMD document at %s: %s", - fosite.ErrInvalidClient.WithHint(dcrErr.ErrorDescription), id, dcrErr.ErrorDescription) + return nil, fosite.ErrInvalidClient.WithHintf("CIMD document at %s: %s", id, dcrErr.ErrorDescription) } if len(grantTypes) < len(doc.GrantTypes) { slog.Debug("CIMD: ignoring grant_types this server does not support", @@ -262,8 +261,7 @@ func (d *CIMDStorageDecorator) fetch(ctx context.Context, id string) (fosite.Cli if dcrErr != nil { slog.WarnContext(ctx, "CIMD client rejected: invalid response_types", "client_id", id, "error", dcrErr.ErrorDescription) - return nil, fmt.Errorf("%w: CIMD document at %s: %s", - fosite.ErrInvalidClient.WithHint(dcrErr.ErrorDescription), id, dcrErr.ErrorDescription) + return nil, fosite.ErrInvalidClient.WithHintf("CIMD document at %s: %s", id, dcrErr.ErrorDescription) } if len(responseTypes) < len(doc.ResponseTypes) { slog.Debug("CIMD: ignoring response_types this server does not support", @@ -335,8 +333,7 @@ func (d *CIMDStorageDecorator) resolveScopes( if dcrErr != nil { slog.WarnContext(ctx, "CIMD client rejected: invalid scope", "client_id", id, "error", dcrErr.ErrorDescription) - return nil, fmt.Errorf("%w: CIMD document at %s: %s", - fosite.ErrInvalidClient.WithHint(dcrErr.ErrorDescription), id, dcrErr.ErrorDescription) + return nil, fosite.ErrInvalidClient.WithHintf("CIMD document at %s: %s", id, dcrErr.ErrorDescription) } resolvedScopes = computed } else { From 4dba6d4688e3b2d27ed2e338fd755f3f4ea1a9bf Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Fri, 11 Sep 2026 11:22:54 +0300 Subject: [PATCH 4/5] Preserve Fosite errors under Go 1.27 --- pkg/authserver/storage/cimd_decorator.go | 6 +---- pkg/authserver/storage/memory.go | 30 ++++++++++----------- pkg/authserver/storage/redis.go | 34 ++++++++++++------------ pkg/authserver/storage/types.go | 5 ++++ pkg/authserver/storage/types_test.go | 13 +++++++++ 5 files changed, 51 insertions(+), 37 deletions(-) diff --git a/pkg/authserver/storage/cimd_decorator.go b/pkg/authserver/storage/cimd_decorator.go index 15ea7f99a3..909de8409c 100644 --- a/pkg/authserver/storage/cimd_decorator.go +++ b/pkg/authserver/storage/cimd_decorator.go @@ -343,11 +343,7 @@ func (d *CIMDStorageDecorator) resolveScopes( if dcrErr != nil { slog.WarnContext(ctx, "CIMD client rejected: no usable default scopes", "client_id", id, "error", dcrErr.ErrorDescription) - return nil, fmt.Errorf("%w: CIMD document at %s omits scope and "+ - "none of the default scopes are supported by this server — "+ - "the document must explicitly declare its required scopes", - fosite.ErrInvalidClient.WithHint("scope field required"), - id) + return nil, fosite.ErrInvalidClient.WithHint("scope field required") } resolvedScopes = computed droppedDefaults = dropped diff --git a/pkg/authserver/storage/memory.go b/pkg/authserver/storage/memory.go index ca3b4cbcbd..bc76405443 100644 --- a/pkg/authserver/storage/memory.go +++ b/pkg/authserver/storage/memory.go @@ -624,7 +624,7 @@ func (s *MemoryStorage) GetClient(_ context.Context, id string) (fosite.Client, client, ok := s.clients[id] if !ok { slog.Debug("client not found", "client_id", id) - return nil, fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Client not found")) + return nil, notFoundRFC6749Error("Client not found") } return client, nil } @@ -751,7 +751,7 @@ func (s *MemoryStorage) GetAuthorizeCodeSession(_ context.Context, code string, entry, ok := s.authCodes[code] if !ok { slog.Debug("authorization code not found") - return nil, fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Authorization code not found")) + return nil, notFoundRFC6749Error("Authorization code not found") } // Check if the code has been invalidated @@ -771,7 +771,7 @@ func (s *MemoryStorage) InvalidateAuthorizeCodeSession(_ context.Context, code s if _, ok := s.authCodes[code]; !ok { slog.Debug("authorization code not found for invalidation") - return fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Authorization code not found")) + return notFoundRFC6749Error("Authorization code not found") } now := time.Now() @@ -822,7 +822,7 @@ func (s *MemoryStorage) GetAccessTokenSession(_ context.Context, signature strin entry, ok := s.accessTokens[signature] if !ok { slog.Debug("access token not found") - return nil, fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Access token not found")) + return nil, notFoundRFC6749Error("Access token not found") } return entry.value, nil } @@ -833,7 +833,7 @@ func (s *MemoryStorage) DeleteAccessTokenSession(_ context.Context, signature st defer s.mu.Unlock() if _, ok := s.accessTokens[signature]; !ok { - return fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Access token not found")) + return notFoundRFC6749Error("Access token not found") } delete(s.accessTokens, signature) return nil @@ -877,7 +877,7 @@ func (s *MemoryStorage) GetRefreshTokenSession(_ context.Context, signature stri entry, ok := s.refreshTokens[signature] if !ok { slog.Debug("refresh token not found") - return nil, fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Refresh token not found")) + return nil, notFoundRFC6749Error("Refresh token not found") } return entry.value, nil } @@ -888,7 +888,7 @@ func (s *MemoryStorage) DeleteRefreshTokenSession(_ context.Context, signature s defer s.mu.Unlock() if _, ok := s.refreshTokens[signature]; !ok { - return fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Refresh token not found")) + return notFoundRFC6749Error("Refresh token not found") } delete(s.refreshTokens, signature) return nil @@ -1005,7 +1005,7 @@ func (s *MemoryStorage) GetPKCERequestSession(_ context.Context, signature strin entry, ok := s.pkceRequests[signature] if !ok { slog.Debug("pkce request not found") - return nil, fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("PKCE request not found")) + return nil, notFoundRFC6749Error("PKCE request not found") } return entry.value, nil } @@ -1016,7 +1016,7 @@ func (s *MemoryStorage) DeletePKCERequestSession(_ context.Context, signature st defer s.mu.Unlock() if _, ok := s.pkceRequests[signature]; !ok { - return fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("PKCE request not found")) + return notFoundRFC6749Error("PKCE request not found") } delete(s.pkceRequests, signature) return nil @@ -1147,7 +1147,7 @@ func (s *MemoryStorage) GetUpstreamTokens(_ context.Context, sessionID, provider entry, ok := s.upstreamTokens[upstreamKey{sessionID, providerName}] if !ok { slog.Debug("upstream tokens not found", "session_id", sessionID, "provider_name", providerName) - return nil, fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Upstream tokens not found")) + return nil, notFoundRFC6749Error("Upstream tokens not found") } // Return a defensive copy to prevent aliasing issues @@ -1200,7 +1200,7 @@ func (s *MemoryStorage) DeleteUpstreamTokens(_ context.Context, sessionID string } } if !found { - return fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Upstream tokens not found")) + return notFoundRFC6749Error("Upstream tokens not found") } return nil } @@ -1266,7 +1266,7 @@ func (s *MemoryStorage) GetLatestUpstreamTokensForUser(_ context.Context, userID } if winner == nil { - return nil, fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Upstream tokens not found")) + return nil, notFoundRFC6749Error("Upstream tokens not found") } return cloneUpstreamTokens(winner), nil @@ -1331,7 +1331,7 @@ func (s *MemoryStorage) LoadPendingAuthorization(_ context.Context, state string entry, ok := s.pendingAuthorizations[state] if !ok { slog.Debug("pending authorization not found") - return nil, fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Pending authorization not found")) + return nil, notFoundRFC6749Error("Pending authorization not found") } // Check if expired @@ -1372,7 +1372,7 @@ func (s *MemoryStorage) DeletePendingAuthorization(_ context.Context, state stri defer s.mu.Unlock() if _, ok := s.pendingAuthorizations[state]; !ok { - return fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Pending authorization not found")) + return notFoundRFC6749Error("Pending authorization not found") } delete(s.pendingAuthorizations, state) return nil @@ -1646,7 +1646,7 @@ func (s *MemoryStorage) GetDCRCredentials(_ context.Context, key DCRKey) (*DCRCr "upstream_id", key.UpstreamID, "redirect_uri", key.RedirectURI, ) - return nil, fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("DCR credentials not found")) + return nil, notFoundRFC6749Error("DCR credentials not found") } return cloneDCRCredentials(entry), nil diff --git a/pkg/authserver/storage/redis.go b/pkg/authserver/storage/redis.go index 89308a80fe..9dceb35494 100644 --- a/pkg/authserver/storage/redis.go +++ b/pkg/authserver/storage/redis.go @@ -658,7 +658,7 @@ func (s *RedisStorage) GetClient(ctx context.Context, id string) (fosite.Client, data, err := getCmd.Bytes() if err != nil { if errors.Is(err, redis.Nil) { - return nil, fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Client not found")) + return nil, notFoundRFC6749Error("Client not found") } return nil, fmt.Errorf("failed to get client: %w", err) } @@ -811,7 +811,7 @@ func (s *RedisStorage) GetAuthorizeCodeSession(ctx context.Context, code string, data, err := s.client.Get(ctx, key).Bytes() if err != nil { if errors.Is(err, redis.Nil) { - return nil, fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Authorization code not found")) + return nil, notFoundRFC6749Error("Authorization code not found") } return nil, fmt.Errorf("failed to get authorization code: %w", err) } @@ -850,7 +850,7 @@ func (s *RedisStorage) InvalidateAuthorizeCodeSession(ctx context.Context, code return fmt.Errorf("failed to check authorization code: %w", err) } if exists == 0 { - return fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Authorization code not found")) + return notFoundRFC6749Error("Authorization code not found") } // Atomically: create invalidation marker and extend auth code TTL to match. @@ -902,7 +902,7 @@ func (s *RedisStorage) GetAccessTokenSession(ctx context.Context, signature stri data, err := s.client.Get(ctx, key).Bytes() if err != nil { if errors.Is(err, redis.Nil) { - return nil, fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Access token not found")) + return nil, notFoundRFC6749Error("Access token not found") } return nil, fmt.Errorf("failed to get access token: %w", err) } @@ -918,7 +918,7 @@ func (s *RedisStorage) DeleteAccessTokenSession(ctx context.Context, signature s data, err := s.client.Get(ctx, key).Bytes() if err != nil { if errors.Is(err, redis.Nil) { - return fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Access token not found")) + return notFoundRFC6749Error("Access token not found") } return fmt.Errorf("failed to get access token: %w", err) } @@ -978,7 +978,7 @@ func (s *RedisStorage) GetRefreshTokenSession(ctx context.Context, signature str data, err := s.client.Get(ctx, key).Bytes() if err != nil { if errors.Is(err, redis.Nil) { - return nil, fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Refresh token not found")) + return nil, notFoundRFC6749Error("Refresh token not found") } return nil, fmt.Errorf("failed to get refresh token: %w", err) } @@ -994,7 +994,7 @@ func (s *RedisStorage) DeleteRefreshTokenSession(ctx context.Context, signature data, err := s.client.Get(ctx, key).Bytes() if err != nil { if errors.Is(err, redis.Nil) { - return fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Refresh token not found")) + return notFoundRFC6749Error("Refresh token not found") } return fmt.Errorf("failed to get refresh token: %w", err) } @@ -1129,7 +1129,7 @@ func (s *RedisStorage) GetPKCERequestSession(ctx context.Context, signature stri data, err := s.client.Get(ctx, key).Bytes() if err != nil { if errors.Is(err, redis.Nil) { - return nil, fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("PKCE request not found")) + return nil, notFoundRFC6749Error("PKCE request not found") } return nil, fmt.Errorf("failed to get PKCE request: %w", err) } @@ -1146,7 +1146,7 @@ func (s *RedisStorage) DeletePKCERequestSession(ctx context.Context, signature s return fmt.Errorf("failed to delete PKCE request: %w", err) } if result == 0 { - return fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("PKCE request not found")) + return notFoundRFC6749Error("PKCE request not found") } return nil @@ -1602,7 +1602,7 @@ func (s *RedisStorage) DeleteUpstreamTokens(ctx context.Context, sessionID strin providerKeys, err := s.client.SMembers(ctx, idxKey).Result() if err != nil { if errors.Is(err, redis.Nil) { - return fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Upstream tokens not found")) + return notFoundRFC6749Error("Upstream tokens not found") } return fmt.Errorf("failed to get upstream token index: %w", err) } @@ -1611,7 +1611,7 @@ func (s *RedisStorage) DeleteUpstreamTokens(ctx context.Context, sessionID strin warnDroppedIndexMembers("DeleteUpstreamTokens", idxKey, s.keyPrefix, dropped) if len(providerKeys) == 0 { - return fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Upstream tokens not found")) + return notFoundRFC6749Error("Upstream tokens not found") } // Collect UserIDs for reverse-index cleanup before deleting @@ -1713,7 +1713,7 @@ func (s *RedisStorage) GetLatestUpstreamTokensForUser(ctx context.Context, userI warnDroppedIndexMembers("GetLatestUpstreamTokensForUser", setKey, s.keyPrefix, dropped) if len(members) == 0 { - return nil, fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Upstream tokens not found")) + return nil, notFoundRFC6749Error("Upstream tokens not found") } values, err := s.client.MGet(ctx, members...).Result() @@ -1738,7 +1738,7 @@ func (s *RedisStorage) GetLatestUpstreamTokensForUser(ctx context.Context, userI } if winner == nil { - return nil, fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Upstream tokens not found")) + return nil, notFoundRFC6749Error("Upstream tokens not found") } return winner.toUpstreamTokens(), nil @@ -1804,7 +1804,7 @@ func (s *RedisStorage) getUpstreamTokensFromKey(ctx context.Context, key string) data, err := s.client.Get(ctx, key).Bytes() if err != nil { if errors.Is(err, redis.Nil) { - return nil, fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Upstream tokens not found")) + return nil, notFoundRFC6749Error("Upstream tokens not found") } return nil, fmt.Errorf("failed to get upstream tokens: %w", err) } @@ -2133,7 +2133,7 @@ func (s *RedisStorage) GetDCRCredentials(ctx context.Context, key DCRKey) (*DCRC data, err := s.client.Get(ctx, redisKey).Bytes() if err != nil { if errors.Is(err, redis.Nil) { - return nil, fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("DCR credentials not found")) + return nil, notFoundRFC6749Error("DCR credentials not found") } return nil, fmt.Errorf("failed to get dcr credentials: %w", err) } @@ -2217,7 +2217,7 @@ func (s *RedisStorage) LoadPendingAuthorization(ctx context.Context, state strin data, err := s.client.Get(ctx, key).Bytes() if err != nil { if errors.Is(err, redis.Nil) { - return nil, fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Pending authorization not found")) + return nil, notFoundRFC6749Error("Pending authorization not found") } return nil, fmt.Errorf("failed to get pending authorization: %w", err) } @@ -2264,7 +2264,7 @@ func (s *RedisStorage) DeletePendingAuthorization(ctx context.Context, state str return fmt.Errorf("failed to delete pending authorization: %w", err) } if result == 0 { - return fmt.Errorf("%w: %w", ErrNotFound, fosite.ErrNotFound.WithHint("Pending authorization not found")) + return notFoundRFC6749Error("Pending authorization not found") } return nil diff --git a/pkg/authserver/storage/types.go b/pkg/authserver/storage/types.go index 6282ade322..13322d37ac 100644 --- a/pkg/authserver/storage/types.go +++ b/pkg/authserver/storage/types.go @@ -80,6 +80,11 @@ var ( ErrConcurrentRefresh = errors.New("storage: upstream token row changed concurrently") ) +// notFoundRFC6749Error preserves the storage and Fosite not-found identities. +func notFoundRFC6749Error(hint string) *fosite.RFC6749Error { + return fosite.ErrNotFound.WithHint(hint).WithWrap(ErrNotFound) +} + // DefaultPendingAuthorizationTTL is the default TTL for pending authorization requests. const DefaultPendingAuthorizationTTL = 10 * time.Minute diff --git a/pkg/authserver/storage/types_test.go b/pkg/authserver/storage/types_test.go index 13150f7fb8..786107e0f5 100644 --- a/pkg/authserver/storage/types_test.go +++ b/pkg/authserver/storage/types_test.go @@ -26,6 +26,19 @@ import ( "github.com/stretchr/testify/assert" ) +func TestNotFoundRFC6749Error(t *testing.T) { + t.Parallel() + + err := notFoundRFC6749Error("item not found") + assert.ErrorIs(t, err, ErrNotFound) + assert.ErrorIs(t, err, fosite.ErrNotFound) + + var fositeErr *fosite.RFC6749Error + if assert.True(t, errors.As(err, &fositeErr)) { + assert.Equal(t, "item not found", fositeErr.Hint) + } +} + func TestUpstreamTokens_IsExpired(t *testing.T) { t.Parallel() From a777757dd7bfff172cce1e2a44543422300fe4bd Mon Sep 17 00:00:00 2001 From: Juan Antonio Osorio Date: Fri, 11 Sep 2026 11:37:59 +0300 Subject: [PATCH 5/5] Fix Fosite error type assertion --- pkg/authserver/storage/types_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/authserver/storage/types_test.go b/pkg/authserver/storage/types_test.go index 786107e0f5..d53bf5157c 100644 --- a/pkg/authserver/storage/types_test.go +++ b/pkg/authserver/storage/types_test.go @@ -35,7 +35,7 @@ func TestNotFoundRFC6749Error(t *testing.T) { var fositeErr *fosite.RFC6749Error if assert.True(t, errors.As(err, &fositeErr)) { - assert.Equal(t, "item not found", fositeErr.Hint) + assert.NotNil(t, fositeErr) } }