[OAuth2] TokenSettings Class cast exception (2)

이 글은 이전 글 에서 이어지는 내용이다.
이번 글에서는 Type Cast issue 가 발생하는 근본적인 원인을 기록하고, 기존에 작성했던 코드도 리팩토링해보려고 한다.

요약

역시나 코드가 필요하거나, 자세한 내용이 필요 없는 사람들을 위해 먼저 요약 정리한다. OAuth Server의 전체 코드는 Spring-stack 여기서 확인할 수 있다.
1. OAuth 인증을 하기 위해서는 Client를 미리 등록해야 한다. 만약 필자처럼 이 Client 정보를 저장하는 Repository를 InMemoryRegisteredClientRepository 를 사용하지 않고, Database 에 저장하게 구현했다면, 인증 요청이 왔을 때 Client 정보를 Database의 Read 연산을 통해 확인해야 한다.
2. 그런데, SQL Database 에는 객체 자체를 저장할 수 없으니, 어떤 식으로든 직렬화 해서 저장하게 되고, 이 정보를 다시 객체로 역직렬화 하는 함수에서 ClassCastException이 발생한 것이다.
3. Docs 의 JPA Implementation 을 그대로 구현한다면, 토큰에 관한 설정값인 TokenSettings 객체를 DB에서 읽을 때 문제가 생긴다. TokenSettings 객체는 내부에 또 다른 객체인 Duration ( 이전 글에서 문제가 생겼던 Duration 객체도 이 TokenSettings 객체 내부의 Field 값이다. )과 OAuth2TokenFormat 를 가지고 있는데, 이 두 값이 Database 에 작성될 때 Duration 은 Double 값으로, OAuth2TokenFormat 은 LinkedHashMap 형태로 저장되기 때문에 단순 형변환 하면 문제가 생기게 된다.
따라서, JPA Entity 에서 RegisteredClient 객체로 변환 할 때 ( Docs 의 toObject 함수가 이 역할을 하고 있다. ) 문제가 생기는 TokenSettings 를 역직렬화 하는 객체를 직접 만들어 해결했다.
TokenSettingsSerializer 코드는 이렇게 작성했다.

public final class TokenSettingsSerializer {
    private final TokenSettings tokenSettings;
    
    ...

    private TokenSettings buildTokenSettings(Map<String, Object> settings){
        return TokenSettings.builder()
                //Convert Duration type.
                .authorizationCodeTimeToLive(
                        this.durationConverter(getSetting(ConfigurationSettingNames.Token.AUTHORIZATION_CODE_TIME_TO_LIVE, settings))
                )
                .accessTokenTimeToLive(
                        this.durationConverter(getSetting(ConfigurationSettingNames.Token.ACCESS_TOKEN_TIME_TO_LIVE, settings))
                )
                .deviceCodeTimeToLive(
                        this.durationConverter(getSetting(ConfigurationSettingNames.Token.DEVICE_CODE_TIME_TO_LIVE, settings))
                )
                .refreshTokenTimeToLive(
                        this.durationConverter(getSetting(ConfigurationSettingNames.Token.REFRESH_TOKEN_TIME_TO_LIVE,settings))
                )
                //Others
                .reuseRefreshTokens(
                        Boolean.TRUE.equals(getSetting(ConfigurationSettingNames.Token.REUSE_REFRESH_TOKENS, settings))
                )
                .idTokenSignatureAlgorithm(
                        SignatureAlgorithm.from(getSetting(ConfigurationSettingNames.Token.ID_TOKEN_SIGNATURE_ALGORITHM, settings))
                )
                .accessTokenFormat(
                        this.tokenFormatConverter(getSetting(ConfigurationSettingNames.Token.ACCESS_TOKEN_FORMAT, settings),null)
                )
                .build();
    }

    private Duration durationConverter(Double value){
        return Duration.ofSeconds(Math.round(value));
    }

    private OAuth2TokenFormat tokenFormatConverter(Map<String, Object> map, @Nullable String keyName){
        //in my case, value from database is LinkedHashMap.
        Assert.notEmpty(map, "Map object is empty.");
        keyName = Objects.requireNonNullElse(keyName, "value");
        if(OAuth2TokenFormat.SELF_CONTAINED.getValue().equals(getSetting(keyName, map))) return OAuth2TokenFormat.SELF_CONTAINED;
        else if(OAuth2TokenFormat.REFERENCE.getValue().equals(getSetting(keyName, map))) return OAuth2TokenFormat.REFERENCE;
        throw new IllegalArgumentException("Cannot convert "+getSetting(keyName, map)+"to OAuth2TokenFormat.");
    }

    private <T> T getSetting(String name, Map<String, Object> settings){
        Assert.hasText(name, "name cannot be empty");
        Assert.notEmpty(settings,"Map object is empty.");
        Assert.notNull(settings.get(name),"Value not exist.");
        return (T) settings.get(name);
    }
}

그리고 이 객체를 이용해서 Entity 를 변환할 때 올바르게 변환할 수 있게 변경했다.

private RegisteredClient toObject(Client client) {
        ...
        //RegisteredClient 객체 만들 때, 올바르게 역직렬화 해서 객체를 넣어야 한다.
        TokenSettingsSerializer serializer = new TokenSettingsSerializer(parseMap(client.getTokenSettings()));
        builder.tokenSettings(serializer.getTokenSettings());
        
        return builder.build();
}

본론

OAuth 인증을 하기 위해서는 인증할 Client에 대한 정보를 미리 등록해야 한다. 만약 필자처럼 이 Client 정보를 저장하는 Repository를 InMemoryRegisteredClientRepository 를 사용하지 않고, Database 에 저장하게 구현했다면, 그 정보는 Database 에 저장될 것이다. 이때 몇몇 정보들은 객체를 그대로 저장할 수 없기 때문에 다른 형태로 저장하게 된다. 예를들면, Duration 객체는 Double로, 복잡한 Object 는 Json String 형태로 저장하는 식이다. 아래는 실제 Docs에서 구현된 Client Entity 의 모습이다.

@NoArgsConstructor
public class Client {
    @Id
    @GeneratedValue(
            strategy = GenerationType.SEQUENCE,
            generator = "CLIENT_SEQ"
    )
    private Long id;
    private String clientId;
    private Instant clientIdIssuedAt;
    private String clientSecret;
    private Instant clientSecretExpiresAt;
    private String clientName;
    @Column(length = 1000)
    private String clientAuthenticationMethods;
    @Column(length = 1000)
    private String authorizationGrantTypes;
    @Column(length = 1000)
    private String redirectUris;
    @Column(length = 1000)
    private String postLogoutRedirectUris;
    @Column(length = 1000)
    private String scopes;
    @Column(length = 2000)
    private String clientSettings;
    @Column(length = 2000)
    private String tokenSettings;
    
    ...
}

그리고 이 Entity 는 이후 OAuth-Client 로 부터 인증 요청을 받을 때 식별자 ( Client ID value )를 받아 Database에서 조회하여 등록된 Client 정보를 읽어서 사용하게 된다.
이 Client 객체는 OAuth-Server 에서 RegisteredClient 라는 객체로 변경되어서 사용된다. 따라서, 이 Client 객체를 RegisteredClient 객체로 변환해주는 과정이 필요하다.

public class RegisteredClient implements Serializable {
	private static final long serialVersionUID = SpringAuthorizationServerVersion.SERIAL_VERSION_UID;
	private String id;
	private String clientId;
	private Instant clientIdIssuedAt;
	private String clientSecret;
	private Instant clientSecretExpiresAt;
	private String clientName;
	private Set<ClientAuthenticationMethod> clientAuthenticationMethods;
	private Set<AuthorizationGrantType> authorizationGrantTypes;
	private Set<String> redirectUris;
	private Set<String> postLogoutRedirectUris;
	private Set<String> scopes;
	private ClientSettings clientSettings;
	private TokenSettings tokenSettings;
    
    ...
}

이제 이 Client 객체를 RegisteredClient 로 변환할 때, 기존에 변환되어 DB에 저장된 값들이 ClientAuthenticationMethodAuthorizationGrantType, ClientSettings, TokenSettings 와 같은 객체 값들로 올바르게 변환되는지 확인해야 한다.

//Entity Client 를 RegisteredClient 로 변환한다.
RegisteredClient.Builder builder = RegisteredClient.withId(String.valueOf(client.getId()))
        .clientId(client.getClientId())
        .clientIdIssuedAt(client.getClientIdIssuedAt())
        .clientSecret(client.getClientSecret())
        .clientSecretExpiresAt(client.getClientSecretExpiresAt())
        .clientName(client.getClientName())
        .clientAuthenticationMethods(authenticationMethods ->
                clientAuthenticationMethods.forEach(authenticationMethod ->
                        authenticationMethods.add(resolveClientAuthenticationMethod(authenticationMethod))))
        .authorizationGrantTypes((grantTypes) ->
                authorizationGrantTypes.forEach(grantType ->
                        grantTypes.add(resolveAuthorizationGrantType(grantType))))
        .redirectUris((uris) -> uris.addAll(redirectUris))
        .postLogoutRedirectUris((uris) -> uris.addAll(postLogoutRedirectUris))
        .scopes((scopes) -> scopes.addAll(clientScopes));
...

이렇게 다른 값들은 잘 변환되는것을 알 수 있다. 하지만, 여기서 TokenSettings 를 변환할 때 문제가 발생했다. TokenSettings 값을 변환하는 코드는 String 을 읽어, ObjectMapper 를 통해서 Map<String, Object> 형태로 변환한 뒤, 이값을 그대로 TokenSettings 에 값을 추가하는 식이다.

//변환 코드. Map으로 변환할 때, String 객체에서 
Map<String, Object> tokenSettingsMap = parseMap(client.getTokenSettings());
builder.tokenSettings(TokenSettings.withSettings(tokenSettingsMap).build());

//String 객체를 읽어 Map 오브젝트로 변경한다.
private Map<String, Object> parseMap(String data) {
    try {
        log.info("data : {}", data);
        return new ObjectMapper().readValue(data, new TypeReference<>() {});
    } catch (Exception ex) {
        throw new IllegalArgumentException(ex.getMessage(), ex);
    }
}
//Map 객체를 읽어 해당하는 지정된 key 값에 그대로 매핑한다.
public static TokenSettings.Builder withSettings(Map<String, Object> settings) {
    Assert.notEmpty(settings, "settings cannot be empty");
    return new TokenSettings.Builder()
            .settings(s -> s.putAll(settings));
}

아래는 실제 Database 에 저장된 TokenSettings 값이다.

{
   "settings.token.reuse-refresh-tokens":true,
   "settings.token.id-token-signature-algorithm":"RS256",
   "settings.token.access-token-time-to-live":300.000000000,
   "settings.token.access-token-format":{
      "value":"self-contained"
   },
   "settings.token.refresh-token-time-to-live":3600.000000000,
   "settings.token.authorization-code-time-to-live":300.000000000,
   "settings.token.device-code-time-to-live":300.000000000
}

위 코드를 동작시켜 String 값을 Map<String, Object> 로 변환했을 때, 다음과 같이 저장될것이다.

//OAuth Server에서 요구하는 TokenSettings 의 자료형은 아래와 같다.
{
   "refresh-token-time-to-live" : (Boolean),
   "id-token-signature-algorithm" : (String),
   "access-token-time-to-live" : (Duration),
   "refresh-token-time-to-live" : (Duration),
   "authorization-code-time-to-live" : (Duration),
   "device-code-time-to-live" : (Duration),
   "access-token-format" : (OAuth2TokenFormat)
}

//하지만 위 로직을 따라서 그대로 변환하면 이러한 결과가 얻어진다.
{
   "refresh-token-time-to-live" : (Boolean) True,
   "id-token-signature-algorithm" : (String) "RS256",
   //여기서부터 문제가 생긴다.
   "access-token-time-to-live" : (Double) 300.0,
   "refresh-token-time-to-live" : (Double) 3600.0,
   "authorization-code-time-to-live" : (Double) 300.0,
   "device-code-time-to-live" : (Double) 300.0,
   "access-token-format" : (LinkedHashMap) { ... }
}

이런 식으로 변환되기 때문에, 원래 의도했던 TokenSettings 의 자료형과 달라지게 되고, Duration <-> Double 과 OAuth2TokenFormat <-> LinkedHashMap 사이에 변환이 진행되지 않아, 이 TokenSettings 객체의 값을 읽어들일 때 에러가 발생하게 된다.

직전 포스팅에서 형변환 문제가 생겼던 이유도 다시 살펴보면, Double 값이 형변환 되지 않았던 이유도 마찬가지로 RegisteredClient 객체에서 Duration값이라 예상되는데 실제 값은 Double 값이 저장되었기 때문에 문제가 발생한다.

@Nullable
@Override
public OAuth2AuthorizationCode generate(OAuth2TokenContext context) {
    if (context.getTokenType() == null ||
            !OAuth2ParameterNames.CODE.equals(context.getTokenType().getValue())) {
        return null;
    }
    Instant issuedAt = Instant.now();
    //tokenSettings 에서 Duration 타입 값을 요청하는데, 실제로 저장된 값은 Double 값이다.
    Instant expiresAt = issuedAt.plus(context.getRegisteredClient().getTokenSettings().getAuthorizationCodeTimeToLive());
    return new OAuth2AuthorizationCode(this.authorizationCodeGenerator.generateKey(), issuedAt, expiresAt);
}

이 문제는 직전 포스팅에서 다뤘던 CustomOAuthAuthorizationCodeGenerator 에서만 발생하는게 아니다. 예상대로라면 TokenSettings 에서 변환되지 않은 5가지 값을 읽어들이는 모든 곳에서 에러가 발생할 것이다. 그리고 실제로도 동일하게 문제가 발생한다.

Exceptions

따라서, 이 문제를 근본적으로 해결하기 위해서는 Client 를 RegisteredClient 로 변환할 때, 올바르게 변환되지 않는 TokenSettings 객체만 똑바로 변환시켜 입력하게 하면 근본적인 문제를 해결할 수 있게 된다.

public final class TokenSettingsSerializer {
    private final TokenSettings tokenSettings;
    
    ...

    private TokenSettings buildTokenSettings(Map<String, Object> settings){
        return TokenSettings.builder()
                //Convert Duration type.
                .authorizationCodeTimeToLive(
                        this.durationConverter(getSetting(ConfigurationSettingNames.Token.AUTHORIZATION_CODE_TIME_TO_LIVE, settings))
                )
                .accessTokenTimeToLive(
                        this.durationConverter(getSetting(ConfigurationSettingNames.Token.ACCESS_TOKEN_TIME_TO_LIVE, settings))
                )
                .deviceCodeTimeToLive(
                        this.durationConverter(getSetting(ConfigurationSettingNames.Token.DEVICE_CODE_TIME_TO_LIVE, settings))
                )
                .refreshTokenTimeToLive(
                        this.durationConverter(getSetting(ConfigurationSettingNames.Token.REFRESH_TOKEN_TIME_TO_LIVE,settings))
                )
                //Others
                .reuseRefreshTokens(
                        Boolean.TRUE.equals(getSetting(ConfigurationSettingNames.Token.REUSE_REFRESH_TOKENS, settings))
                )
                .idTokenSignatureAlgorithm(
                        SignatureAlgorithm.from(getSetting(ConfigurationSettingNames.Token.ID_TOKEN_SIGNATURE_ALGORITHM, settings))
                )
                .accessTokenFormat(
                        this.tokenFormatConverter(getSetting(ConfigurationSettingNames.Token.ACCESS_TOKEN_FORMAT, settings),null)
                )
                .build();
    }

    private Duration durationConverter(Double value){
        return Duration.ofSeconds(Math.round(value));
    }

    private OAuth2TokenFormat tokenFormatConverter(Map<String, Object> map, @Nullable String keyName){
        //in my case, value from database is LinkedHashMap.
        Assert.notEmpty(map, "Map object is empty.");
        keyName = Objects.requireNonNullElse(keyName, "value");
        if(OAuth2TokenFormat.SELF_CONTAINED.getValue().equals(getSetting(keyName, map))) return OAuth2TokenFormat.SELF_CONTAINED;
        else if(OAuth2TokenFormat.REFERENCE.getValue().equals(getSetting(keyName, map))) return OAuth2TokenFormat.REFERENCE;
        throw new IllegalArgumentException("Cannot convert "+getSetting(keyName, map)+"to OAuth2TokenFormat.");
    }
    ...
}

따라서, 올바르지 않게 변환된 Map 객체를 TokenSettings 객체로 올바르게 변경해주는 TokenSettingsSerializer 객체를 새로 만들었고, RegisteredClient 를 생성할 때에 역직렬화 로직을 변경했다.

//TokenSettings 를 올바르게 변환한다.
TokenSettingsSerializer serializer = new TokenSettingsSerializer(parseMap(client.getTokenSettings()));
builder.tokenSettings(serializer.getTokenSettings());

이제 기존에 FilterChain 에 등록했던 CustomOAuthAuthorizationCodeGenerator 는 더이상 필요하지 않으므로, 삭제한다.

@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity,
                                       ...) throws Exception {
    ...
    //더 이상 이런 코드는 필요 없다.
    //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();
}

사실 이 TokenSettings 는 OAuth2TokenGenerator interface를 Implements 하는 객체들에서 모두 사용한다.

앞선 포스팅에서 에러가 났었던 OAuth2AuthorizationCodeGenerator도, 위에 첨부된 에러 이미지의 JwtGenerator 도 모두 다 포함되어 있음을 알 수 있다.

Leave a Comment