Skip to content

Commit b80aa21

Browse files
authored
fix(controlplane): surface token expiry instead of federated error for expired tokens (#3227)
1 parent a783c6c commit b80aa21

2 files changed

Lines changed: 70 additions & 0 deletions

File tree

app/controlplane/internal/usercontext/attjwtmiddleware/attmiddleware.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,20 @@ func WithJWTMulti(l log.Logger, opts ...JWTOption) middleware.Middleware {
275275

276276
// Check if it's the last provider and still failed
277277
if err != nil {
278+
// A well-formed, correctly-signed Chainloop token that has simply
279+
// expired must surface as an expiry error. Trying the remaining
280+
// providers or falling back to the federated path would only mask
281+
// the real reason (e.g. "no issuers configured"), leaving the user
282+
// with a misleading message instead of "token has expired".
283+
//
284+
// runProviderValidator returns the ErrTokenExpired sentinel directly,
285+
// so we compare by identity. We intentionally avoid errors.Is here:
286+
// kratos errors compare only Code and Reason, so every 401/UNAUTHORIZED
287+
// error (e.g. an invalid signature) would wrongly match ErrTokenExpired.
288+
if err == ErrTokenExpired { //nolint:errorlint // sentinel identity, see comment above
289+
return nil, ErrTokenExpired
290+
}
291+
278292
if idx < tokenProviderLen-1 {
279293
continue
280294
}

app/controlplane/internal/usercontext/attjwtmiddleware/attmiddleware_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,19 @@ import (
1919
"context"
2020
"io"
2121
"net/http"
22+
"net/http/httptest"
2223
"testing"
24+
"time"
2325

26+
conf "github.com/chainloop-dev/chainloop/app/controlplane/internal/conf/controlplane/config/v1"
2427
"github.com/chainloop-dev/chainloop/app/controlplane/internal/usercontext/attjwtmiddleware"
28+
"github.com/chainloop-dev/chainloop/app/controlplane/pkg/jwt/user"
29+
"github.com/chainloop-dev/chainloop/pkg/cache"
2530
"github.com/go-kratos/kratos/v2/log"
2631
"github.com/go-kratos/kratos/v2/transport"
32+
"github.com/golang-jwt/jwt/v5"
2733
"github.com/stretchr/testify/assert"
34+
"github.com/stretchr/testify/require"
2835
)
2936

3037
var emptyHandler = func(_ context.Context, _ interface{}) (interface{}, error) { return nil, nil }
@@ -137,3 +144,52 @@ func TestAttestationAPITokenProvider(t *testing.T) {
137144
})
138145
}
139146
}
147+
148+
// newExpiredUserToken builds a Chainloop user token whose expiration is in the
149+
// past, signed with the given key.
150+
func newExpiredUserToken(t *testing.T, key string) string {
151+
t.Helper()
152+
token := jwt.NewWithClaims(user.SigningMethod, jwt.RegisteredClaims{
153+
Audience: jwt.ClaimStrings{user.Audience},
154+
ExpiresAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Hour)),
155+
IssuedAt: jwt.NewNumericDate(time.Now().Add(-2 * time.Hour)),
156+
})
157+
signed, err := token.SignedString([]byte(key))
158+
require.NoError(t, err)
159+
return signed
160+
}
161+
162+
// TestExpiredTokenWithFederatedProvider ensures that an expired Chainloop token
163+
// surfaces the proper "token has expired" error even when the federated
164+
// provider is enabled. Otherwise the expiry is masked by the federated path
165+
// (e.g. "no issuers configured"), leaving the user with a misleading message.
166+
func TestExpiredTokenWithFederatedProvider(t *testing.T) {
167+
// Federated endpoint that mimics the masking behaviour: it rejects the
168+
// expired token with an unrelated authorization error.
169+
federatedSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
170+
w.WriteHeader(http.StatusUnauthorized)
171+
_, _ = w.Write([]byte(`{"code":1,"message":"no issuers configured"}`))
172+
}))
173+
defer federatedSrv.Close()
174+
175+
claimsCache, err := cache.New[*jwt.MapClaims](cache.WithTTL(time.Minute))
176+
require.NoError(t, err)
177+
178+
logger := log.NewStdLogger(io.Discard)
179+
header := newTokenHeader("Authorization", "Bearer "+newExpiredUserToken(t, signingKey))
180+
ctx := transport.NewServerContext(context.Background(), &mockTransport{reqHeader: header})
181+
182+
m := attjwtmiddleware.WithJWTMulti(logger,
183+
attjwtmiddleware.NewRobotAccountProvider(signingKey),
184+
attjwtmiddleware.NewAPITokenProvider(signingKey),
185+
attjwtmiddleware.NewUserTokenProvider(signingKey),
186+
attjwtmiddleware.WithFederatedProvider(&conf.FederatedAuthentication{Enabled: true, Url: federatedSrv.URL}),
187+
attjwtmiddleware.WithClaimsCache(claimsCache),
188+
)
189+
190+
_, err = m(emptyHandler)(ctx, nil)
191+
require.Error(t, err)
192+
// The expiry must be reported, not the masked federated error.
193+
assert.ErrorContains(t, err, "JWT token has expired")
194+
assert.NotContains(t, err.Error(), "no issuers configured")
195+
}

0 commit comments

Comments
 (0)