diff --git a/internal/api/auth.go b/internal/api/auth.go index 7d8f71b0f..f0b11227c 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -74,7 +74,22 @@ func (a *API) parseJWTClaims(bearer string, r *http.Request) (context.Context, e ctx := r.Context() config := a.config - p := jwt.NewParser(jwt.WithValidMethods(config.JWT.ValidMethods)) + // Check if allow_expired query parameter is set + allowExpired := r.URL.Query().Get("allow_expired") == "true" + + // Configure JWT parser based on allowExpired flag + var p *jwt.Parser + if allowExpired { + // Skip claims validation (including exp check) when explicitly requested + p = jwt.NewParser( + jwt.WithValidMethods(config.JWT.ValidMethods), + jwt.WithoutClaimsValidation(), + ) + } else { + // Default behavior: validate all claims including expiration + p = jwt.NewParser(jwt.WithValidMethods(config.JWT.ValidMethods)) + } + token, err := p.ParseWithClaims(bearer, &AccessTokenClaims{}, func(token *jwt.Token) (interface{}, error) { if kid, ok := token.Header["kid"]; ok { if kidStr, ok := kid.(string); ok { diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index d173ff1d9..c1a55ece6 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -160,6 +160,66 @@ func (ts *AuthTestSuite) TestParseJWTClaims() { } } +func (ts *AuthTestSuite) TestParseJWTClaimsWithAllowExpired() { + // Test that allow_expired query parameter controls JWT expiration validation + u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) + require.NoError(ts.T(), err) + + s, err := models.NewSession(u.ID, nil) + require.NoError(ts.T(), err) + require.NoError(ts.T(), ts.API.db.Create(s)) + + // Create an expired JWT token (exp set to past time) + expiredClaims := &AccessTokenClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + Subject: u.ID.String(), + ExpiresAt: jwt.NewNumericDate(jwt.Now().Add(-1 * 3600)), // expired 1 hour ago + }, + Role: "authenticated", + SessionId: s.ID.String(), + } + + expiredJwt, err := jwt.NewWithClaims(jwt.SigningMethodHS256, expiredClaims).SignedString([]byte(ts.Config.JWT.Secret)) + require.NoError(ts.T(), err) + + ts.Run("Without allow_expired parameter - should reject expired token", func() { + req := httptest.NewRequest(http.MethodGet, "http://localhost/user", nil) + req.Header.Set("Authorization", "Bearer "+expiredJwt) + + _, err := ts.API.parseJWTClaims(expiredJwt, req) + require.Error(ts.T(), err) + require.Contains(ts.T(), err.Error(), "token is expired") + }) + + ts.Run("With allow_expired=false - should reject expired token", func() { + req := httptest.NewRequest(http.MethodGet, "http://localhost/user?allow_expired=false", nil) + req.Header.Set("Authorization", "Bearer "+expiredJwt) + + _, err := ts.API.parseJWTClaims(expiredJwt, req) + require.Error(ts.T(), err) + require.Contains(ts.T(), err.Error(), "token is expired") + }) + + ts.Run("With allow_expired=true - should accept expired token", func() { + req := httptest.NewRequest(http.MethodGet, "http://localhost/user?allow_expired=true", nil) + req.Header.Set("Authorization", "Bearer "+expiredJwt) + + ctx, err := ts.API.parseJWTClaims(expiredJwt, req) + require.NoError(ts.T(), err) + + // Verify token is stored in context + token := getToken(ctx) + require.NotNil(ts.T(), token) + require.Equal(ts.T(), expiredJwt, token.Raw) + + // Verify claims are correctly parsed + claims := getClaims(ctx) + require.NotNil(ts.T(), claims) + require.Equal(ts.T(), u.ID.String(), claims.Subject) + require.Equal(ts.T(), "authenticated", claims.Role) + }) +} + func (ts *AuthTestSuite) TestMaybeLoadUserOrSession() { u, err := models.FindUserByEmailAndAudience(ts.API.db, "test@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err)