datetimejwtauthorizationbearer-tokenspring-boot-security

JWT Token still accepted after expiry Spring Boot 3.2 OAuth2 resource server


I am learning how to use JWT to build a Stateless backend API, This is a dummy learning project. I am using Spring Boot's OAuth 2 resource server.

Since I just have a single backend, there is no need for public keys, I just have one secret key generated with openssl and appended to the application.properties file.

The documentation has not been good to say the least and it has taken a long time to figure out basic things.

I have managed to successfully log the users through a basic UsernamePasswordAuthentication and then issued a token that for testing purposes should expire in 2 minutes.

However, it is still valid many times for up to 10 minutes after the expiry, Why is this ?

Here is the code:

@Configuration
@EnableWebSecurity(debug = true)
@EnableMethodSecurity
public class SecurityConfig {

    @Autowired
    private MuserDetailsService muserDetailsService;

    private JwtPropHolder jwtPropHolder;

    public SecurityConfig(JwtPropHolder jwtPropHolder) {
        this.jwtPropHolder = jwtPropHolder;
    }

    @Bean
    public SecurityFilterChain createSecurityFilterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .oauth2ResourceServer(configurer -> {
                configurer.jwt(jwtConfigurer -> {
                    jwtConfigurer.jwtAuthenticationConverter(getMyJwtAuthenticationConverter());
                });
            })

            .build();
    }

    @Bean
    public PasswordEncoder createPasswordEncoder() {
        return NoOpPasswordEncoder.getInstance();
    }

    @Bean
    public JwtDecoder createJwtDecoder() {

        NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder.withSecretKey(jwtPropHolder.getSecretKey()).build();

        OAuth2TokenValidator<Jwt> withClockSkwe = new DelegatingOAuth2TokenValidator<>(
            new JwtTimestampValidator(Duration.ofSeconds(0))
        );

        jwtDecoder.setJwtValidator(withClockSkwe);

        return jwtDecoder;
    }

    @Bean
    public AuthenticationManager getAuthenticationManager(JwtDecoder jwtDecoder) {
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setPasswordEncoder(NoOpPasswordEncoder.getInstance());
        daoAuthenticationProvider.setUserDetailsService(muserDetailsService);
        JwtAuthenticationProvider jwtAuthenticationProvider = new JwtAuthenticationProvider(createJwtDecoder());
        return new ProviderManager(
            daoAuthenticationProvider,
            jwtAuthenticationProvider
        );
    }

    @Bean
    public Converter<Jwt, AbstractAuthenticationToken> getMyJwtAuthenticationConverter() {
        return new MyJwtConverter();
    }

    @Bean
    public RoleHierarchy roleHierarchy() {
        RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
        roleHierarchy.setHierarchy("ROLE_MYTHICAL_USER > ROLE_GRANDPARENT > ROLE_PARENT > ROLE_CHILD");
        return roleHierarchy;
    }

    @Bean
    public MethodSecurityExpressionHandler getMethodSecurityExpressionHandler() {
        DefaultMethodSecurityExpressionHandler expressionHandler = new DefaultMethodSecurityExpressionHandler();
        expressionHandler.setRoleHierarchy(roleHierarchy());
        return expressionHandler;
    }

    public JwtAuthenticationConverter oldJwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
        //This line throws the class cast exception but then
        grantedAuthoritiesConverter.setAuthoritiesClaimName("authorities");
        JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
        jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
        return jwtAuthenticationConverter;
    }

}

@Configuration
@Getter
@Setter
@ConfigurationProperties(prefix = "jwt")
public class JwtPropHolder {

    private String key;
    private String algorithm;
    private Integer randomByteSize;


    public SecretKey getSecretKey() {
        return new SecretKeySpec(key.getBytes(), algorithm);
    }



}

The class that generates the token:

@Service
public class JwsService {

    @Autowired
    private JwtPropHolder jwtPropHolder;

    private JWSObject jwsObject;

    private String jwsSerialised;

    @Autowired
    private ObjectMapper objectMapper;

    public void createJws(Map<String, Object> claims) throws JOSEException {
        jwsObject = new JWSObject(
            new JWSHeader(JWSAlgorithm.parse(jwtPropHolder.getAlgorithm())),
            new Payload(claims)
        );
            
        JWSSigner signer = new MACSigner(jwtPropHolder.getSecretKey());
        jwsObject.sign(signer);
        jwsSerialised = jwsObject.serialize();
    }

    public void createJwsFromClaimsSet(Map<String, Object> claims) throws JOSEException {
        JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder();
        if (claims.containsKey("sub")){
            builder.claim("sub", claims.get("sub"));
        }
        if (claims.containsKey("iss")){
            builder.claim("iss", claims.get("iss"));
        }
        if (claims.containsKey("exp")){
            builder.claim("exp", claims.get("exp"));
        }
        if (claims.containsKey("iat")) {
            builder.claim("iat", claims.get("iat"));
        }
        builder.claim("authorities", claims.get("authorities"));



    }

    /**
     * Returns the string token
     * @return
     */
    public String getJwsSerialised() {
        return jwsSerialised;
    }

    /**
     * Returns the whole JWSObject
     * @return
     */
    public JWSObject getJwsObject() {
        return jwsObject;
    }


}

The class that does the login and returns the token:

   @RestController
@Slf4j
public class AuthenticationApi {

    private AuthenticationManager authenticationManager;
    private JwsService jwsService;

    public AuthenticationApi(AuthenticationManager authenticationManager, JwsService jwsService) {
        this.authenticationManager = authenticationManager;
        this.jwsService = jwsService;
    }

    @PostMapping("/login")
    public LoginResponse login(@RequestBody LoginRequest loginRequest) throws JOSEException {
        Authentication authentication = UsernamePasswordAuthenticationToken.unauthenticated(
            loginRequest.username(),
            loginRequest.password()
        );

        Authentication authenticated = authenticationManager.authenticate(authentication);
        /**
         * I can't just pass a date,
         * And all time related things need to be in numeric seconds format as per JWT spec,
         * So I am converting LocalDateTime and then working with it as its more accurate,
         * And does not have that one hour discrepency
         * First I will create the LocalDateTimes, so I can send these back to the client,
         * Then I will create the seconds version and send it in the JWT:
         */
        LocalDateTime now = LocalDateTime.now();
        LocalDateTime expiry = LocalDateTime.now().plus(2, ChronoUnit.MINUTES);
        Long nowInSeconds = now.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
        Long expiryInSeconds = expiry.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();

        Map<String, Object> claims = new HashMap<>();
        claims.put("authorities", authenticated.getAuthorities());
        claims.put("name",  authenticated.getName());
        claims.put("exp", expiryInSeconds);
        claims.put("iat", nowInSeconds);

        jwsService.createJws(claims);

        return new LoginResponse(
            jwsService.getJwsSerialised(),
            jwsService.getJwsObject(),
            authenticated,
            now,
            expiry
        );

    }

}

Since i am using LocalDateTime, i am not getting the one hour difference, but it just does not expire ? I looked through the source code and I can't seem to find any code that validates the expiry date

Login end point successfully returns token

Login response returns the time stamps in the response:

Authenticated end point still accessible after token has expired:


Solution

  • Using ZonedDateTime / OffsetDateTime instead of LocalDateTime or instant solves the problem. The Instant class goes one hour behind and does not take into account the daylight saving time in Europe/London from where I am doing this.

    This should not be taken into account when working with timestamps, but for some reason it was not working when i was creating timestamps out of the Instant class. But it works with ZonedDateTime and OffsetDateTime

    @PostMapping("/login")
        public LoginResponse login(@RequestBody LoginRequest loginRequest) throws JOSEException {
            Authentication authentication = UsernamePasswordAuthenticationToken.unauthenticated(
                loginRequest.username(),
                loginRequest.password()
            );
    
            Authentication authenticated = authenticationManager.authenticate(authentication);
            
            OffsetDateTime now = OffsetDateTime.now();
            OffsetDateTime expiry = OffsetDateTime.now().plus(2, ChronoUnit.MINUTES);
            Long nowInSeconds = now.toEpochSecond();
            Long expiryInSeconds = expiry.toEpochSecond();
    
            Map<String, Object> claims = new HashMap<>();
            claims.put("authorities", authenticated.getAuthorities());
            claims.put("sub",  authenticated.getName());
            claims.put("exp", expiryInSeconds);
            claims.put("iat", nowInSeconds);
    
            jwsService.createJws(claims);
    
            return new LoginResponse(
                jwsService.getJwsSerialised(),
                jwsService.getJwsObject(),
                authenticated,
                now,
                expiry
            );
    
        }