[OAuth2] cannot be cast to class java.time.Duration

Spring module Project 에서 OAuth 관련 모듈을 개발하던 중에 생겼던 Duration 타입의 Type casting issue 를 해결한 과정을 공유하려 한다.
Spring-modules 프로젝트에서 자주 사용되는 인증 방식을 미리 Application으로 구현하는데, 요새는 누구나 다 지원할만큼 보편적으로 사용되는 OAuth 인증 방식을 빼먹을 수 없어서 함께 구현하게 되었다. 필자는 OAuth2 인증 방식을 이해하기 위해서 OAuth 인증을 담당하는 Client, Authorization Server, Resource Server 를 모두 직접 구현하면서 인증/인가 과정이 동작하는 방법을 이해하려 했고, 그중에서 문제가 발생했던 Server 부분에 대해 기록한다.

Dependencies

Spring Boot 3 버전이고 기존에 사용되던 Spring-security-oauth 가 EOL( End-Of-Life ) 에 도달해서, 더이상 사용할 수 없다. 따라서 2023.9 기준으로 활발하게 개발되고 있는 org.springframework.security:spring-security-oauth2-authorization-server 를 사용하여 Authorization Server 를 이용하여 구현한다.
Client의 Grant-Type 은 authorization_code 이고, Client-Authentication-Methods 는 client_secret_basic 을 기본값으로 사용했다.

dependencies {
    ...
    //EOL
    //implementation "org.springframework.security.oauth:spring-security-oauth2"
    //Authorization-Server
    implementation "org.springframework.boot:spring-boot-starter-oauth2-authorization-server"
    ...
}

이 Starter 에는 spring-boot-starter-webspring-security-oauth2-authorization-server 두가지 의존성이 함께 포함된다.

요약

코드가 필요하거나, 자세한 내용이 필요 없는 사람들을 위해 먼저 요약 정리한다. OAuth Server의 전체 코드는 Spring-stack 여기서 확인할 수 있다. 구현은 Docs에 나와있는 JPA Implementation을 구현했다.
1. OAuth2AuthorizationConsentAuthenticationProvider ( 만약 Client의 Consent 옵션이 True 인 경우에 포함된다. ) 와 OAuth2AuthorizationCodeRequestAuthenticationProvider 에서 사용되는 OAuth2AuthorizationCodeGenerator 에서 Authorization Code 를 생성하는 generate() 함수의 getAuthorizationCodeTimeToLive() 에서 Type casting issue 가 생겼던 것이다.

@Nullable
@Override
public OAuth2AuthorizationCode generate(OAuth2TokenContext context) {
    if (context.getTokenType() == null ||
            !OAuth2ParameterNames.CODE.equals(context.getTokenType().getValue())) {
        return null;
    }
    Instant issuedAt = Instant.now();
    Instant expiresAt = issuedAt.plus(context.getRegisteredClient().getTokenSettings().getAuthorizationCodeTimeToLive());
    return new OAuth2AuthorizationCode(this.authorizationCodeGenerator.generateKey(), issuedAt, expiresAt);
}

2. 필자의 경우 JPA Implementation 을 Oracle Database 로 구현했는데, 여기에서는 “settings.token.access-token-time-to-live” 값이 300.000000000 (Double 값) 으로 설정되어 class java.lang.Double cannot be cast to class java.time.Duration (java.lang.Double and java.time.Duration are in module java.base of loader ‘bootstrap’) 문제가 생겼다

3. 간단하게 OAuth2TokenGenerator 를 구현한 Custom Code Generator 를 만들어 Provider 에 주입하여 해결한다.

public class CustomOAuthAuthorizationCodeGenerator implements OAuth2TokenGenerator<OAuth2AuthorizationCode> {
    private final StringKeyGenerator authorizationCodeGenerator =
            new Base64StringKeyGenerator(Base64.getUrlEncoder().withoutPadding(), 96);

    @Nullable
    @Override
    public OAuth2AuthorizationCode generate(OAuth2TokenContext context) {
        if (context.getTokenType() == null ||
                !OAuth2ParameterNames.CODE.equals(context.getTokenType().getValue())) {
            return null;
        }
        Instant issuedAt = Instant.now();
        try {
            Instant expiresAt = issuedAt.plus(context.getRegisteredClient().getTokenSettings().getAuthorizationCodeTimeToLive());
            return new OAuth2AuthorizationCode(this.authorizationCodeGenerator.generateKey(), issuedAt, expiresAt);
        } catch (ClassCastException e){
            //Cannot type cast
            Map<String, Object> settings = context.getRegisteredClient().getTokenSettings().getSettings();
            // In my case (Oracle 19c) It's double value with seconds.
            long authorizationCodeTimeToLive = Math.round((double) settings.get(ConfigurationSettingNames.Token.AUTHORIZATION_CODE_TIME_TO_LIVE));
            Duration authorizationCodeDuration = Duration.ofSeconds(authorizationCodeTimeToLive);
            Instant expiresAt = issuedAt.plus(authorizationCodeDuration);
            return new OAuth2AuthorizationCode(this.authorizationCodeGenerator.generateKey(), issuedAt, expiresAt);
        }
    }
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity){
        //Provider 객체 가져오기. Bean으로 주입하거나, 새로 생성하기.
        OAuth2AuthorizationCodeRequestAuthenticationProvider provider = ...
        OAuth2AuthorizationConsentAuthenticationProvider consentProvider = ...
        
        //Custom Code Generator 를 주입하고, Authentication Providers list 에 등록하기.
        provider.setAuthorizationCodeGenerator(new CustomOAuthAuthorizationCodeGenerator());
        consentProvider.setAuthorizationCodeGenerator(new CustomOAuthAuthorizationCodeGenerator());
        httpSecurity.authenticationProvider(provider);
        httpSecurity.authenticationProvider(consentProvider);
        ...
}

본론

필자는 Client 를 In memory repository 가 아닌 Docs에 나와있는 JPA Implementation 을 구현하고 있었다. OAuth2 인증에서 사용될 Client정보를 Rest-API 를 통해서 Database 에 기록하는 방식이다.

@Builder
protected Client(RegisterOAuthClient oauthClient){
    this.clientId = this.generateClientId();
    this.clientIdIssuedAt = Instant.now();
    this.clientSecret = new BCryptPasswordEncoder().encode(this.generatePassword());
    this.clientSecretExpiresAt = Instant.now().plus(24, ChronoUnit.HOURS);// 24Hours later.
    this.clientName = oauthClient.getClientName();
    this.clientAuthenticationMethods = ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue(); // Default.
    this.authorizationGrantTypes = AuthorizationGrantType.AUTHORIZATION_CODE.getValue();
    this.redirectUris = oauthClient.getRedirectUris();
    this.postLogoutRedirectUris = oauthClient.getPostLogoutRedirectUris();
    this.scopes = StringUtils.collectionToCommaDelimitedString(oauthClient.getScopes());
    if (this.clientSettings == null) {
        ClientSettings.Builder builder = ClientSettings.builder();
        builder.requireAuthorizationConsent(true);
        this.clientSettings = writeMap(builder.build().getSettings());
    }
    if (this.tokenSettings == null) {
        this.tokenSettings = writeMap(TokenSettings.builder().build().getSettings());
    }
}

TokenSettings 를 기본값으로 저장하는데, 이 기본값에 Duration 객체가 포함된다. 문제는 이 Duration 객체를 Database 에 저장할 때 ( 필자 기준 Oracle )는 Double 값으로 저장되었다.
Duration 을 Authorization Code 를 생성할 때 사용한다. 그래서, OAuth2AuthorizationCodeRequestAuthenticationProvider 에서 이 Duration 을 사용하는데, 그 방식이 단순 형변환 하는 방식이라, Double 타입을 Duration 형식으로 형변환하려고 해서 ClassCastException 이 발생한다.
Custom Generator 가 아닌 원본 OAuth2AuthorizationCodeGenerator 를 살펴보자.

@Nullable
@Override
public OAuth2AuthorizationCode generate(OAuth2TokenContext context) {
    if (context.getTokenType() == null ||
            !OAuth2ParameterNames.CODE.equals(context.getTokenType().getValue())) {
        return null;
    }
    Instant issuedAt = Instant.now();
    Instant expiresAt = issuedAt.plus(context.getRegisteredClient().getTokenSettings().getAuthorizationCodeTimeToLive());
    return new OAuth2AuthorizationCode(this.authorizationCodeGenerator.generateKey(), issuedAt, expiresAt);
}

여기에서 문제가 생기는 부분은 getAuthorizationCodeTimeToLive() 부분이다. 이부분에서 사용되는 코드를 보면, 단순 형변환을 이용해서 처리하는것을 볼 수 있다.

// 문제가 생겼던 원본 함수이다.
public Duration getAuthorizationCodeTimeToLive() {
    return getSetting(ConfigurationSettingNames.Token.AUTHORIZATION_CODE_TIME_TO_LIVE);
}

public <T> T getSetting(String name) {
        Assert.hasText(name, "name cannot be empty");
        //단순 형변환. (Duration) Double
        return (T) getSettings().get(name);
}

generate() 함수만 변경하면 해결할 수 있을것이라 생각했다. 따라서, 필자는 Custom Authorization Code Generator 를 생성하여 Provider 에 주입해주는 방식을 선택했다.

@Nullable
@Override
public OAuth2AuthorizationCode generate(OAuth2TokenContext context) {
    ...
    // In my case (Oracle 19c) It's double value with seconds.
    long authorizationCodeTimeToLive = Math.round((double) settings.get(ConfigurationSettingNames.Token.AUTHORIZATION_CODE_TIME_TO_LIVE));
    Duration authorizationCodeDuration = Duration.ofSeconds(authorizationCodeTimeToLive);
    Instant expiresAt = issuedAt.plus(authorizationCodeDuration);
    return new OAuth2AuthorizationCode(this.authorizationCodeGenerator.generateKey(), issuedAt, expiresAt);
}

CustomOAuthAuthorizationCodeGenerator 클래스를 생성하고, generate 함수에서 Double 값을 올바르게 Duration 객체로 변경할 수 있게끔 형변환 코드를 작성하고, Provider 에 Setter 를 이용해서 주입하여 문제를 해결했다.

@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity,
                                       ...) throws Exception {
    ...
    //Provider's
    OAuth2AuthorizationCodeRequestAuthenticationProvider provider =
            new OAuth2AuthorizationCodeRequestAuthenticationProvider(
                    registeredClientRepository, authorizationService, oAuth2AuthorizationConsentService);
    OAuth2AuthorizationConsentAuthenticationProvider consentProvider =
            new OAuth2AuthorizationConsentAuthenticationProvider(
                    registeredClientRepository, authorizationService, oAuth2AuthorizationConsentService);
    
    //Custom Code Generator 주입
    provider.setAuthorizationCodeGenerator(new CustomOAuthAuthorizationCodeGenerator());
    consentProvider.setAuthorizationCodeGenerator(new CustomOAuthAuthorizationCodeGenerator());
    
    httpSecurity.authenticationProvider(provider);
    httpSecurity.authenticationProvider(consentProvider);
    
    return httpSecurity.build();
}

의외로 간단하게 해결되는 문제였다. 더 나아가서, CustomAuthorizationCodeGenerator 객체를 Profile 에 따라서 각각 다르게 등록되게 한 뒤, 의존성을 주입받아 사용해도 좋겠다는 생각이 들었다.
만약, Oracle Database 가 아닌, 다른 Database 에서 사용할 때 에러가 난다면 이 방법으로 변경해봐야겠다.

소스의 전체 코드는 Spring-stack 여기서 확인할 수 있다.

Leave a Comment