
1. Spring Authorization Server 의 Core Model 을 알아보자
1) RegisteredClient
- 인가 서버에 등록된 클라이언트를 의미한다.
- OAuth2.0 또는 OAuth2.1 을 통해 인증 및 권한 부여를 요청하는 클라이언트를 구성하는데 사용된다.
- 예를 들어, 클라이언트가 authorization_code 또는 client_credentials 와 같은 권한 부여 흐름을 시작하려면 먼저 클라이언트를 권한 부여 서버에 등록해야한다.
- 클라이언트 등록시 클라이언트는 고유한 client_id, client_secret 및 고유한 클라이언트 식별자와 연결된 메타 데이터를 할당한다.
- 클라이언트의 주요 목적은 보호된 리소스에 대한 액세스를 요청하는 것으로 클라이언트는 먼저 권한 부여 서버를 인증하고 액세스 토큰과 교환을 요청한다.

▶ 주요 필드 및 설정
public class RegisteredClient implements Serializable { 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; ... }
1. 클라이언트ID (ClientId): 클라이언트를 식별하는 고유한 문자열
2. 클라이언트시크릿 (ClientSecret): 클라이언트를 인증하기 위해 사용되는 비밀번호 또는 키. 비밀이 필요한 클라이언트 유형에서 사용됨
3. 클라이언트 인증 방법 (Client Authentication Methods): 클라이언트가 인증 서버에 자신을 인증하는 방법
ex. client_secret_basic, client_secret_post, none, ....
4. 권한 부여 타입 (Authorization Grant Types): 클라이언트가 요청할 수 있는 권한 부여 방식
ex. authoriation_code, client_credentials, refresh_token, ...
5. 리다이렉트 URL (Redirect URIs): 인증 코드 또는 액세스 토큰을 전달받을 클라이언트의 URL 목록
6. 스코프 (Scopes): 클라이언트가 요청할 수 있는 권한의 범위
7. 클라이언트 설정 (Client Settings): PKCE 사용 여부, 리다이렉트 URL 요구 여부 등의 추가 설정
8. 토큰 설정 (Token Settings): 액세스 토큰과 리프레시 토큰의 유효 기간 등의 설정
▶ JAVA 코드로 예제 구성해보기
- AuthorizationServerConfig 에서 간단하게 인메모리 Repository 빈으로 등록하여 설정해볼 수 있다.
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository; import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; @Configuration public class AuthorizationServerConfig { @Bean public RegisteredClientRepository registeredClientRepository() { RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString()) .clientId("client-id") .clientSecret("{noop}client-secret") .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .redirectUri("https://example.com/callback") .scope("read") .scope("write") .tokenSettings(TokenSettings.builder() .accessTokenTimeToLive(Duration.ofHours(1)) .refreshTokenTimeToLive(Duration.ofDays(30)) .build()) .clientSettings(ClientSettings.builder() .requireProofKey(true) .requireAuthorizationConsent(true) .build()) .build(); return new InMemoryRegisteredClientRepository(registeredClient); } }
2) OAuth2Authorization
Spring Authorization Server 에서 OAuth2.0 및 OpenID Connect 1.0 토큰과 관련된 모든 정보와 상태를 캡슐화 하는 핵심 엔티티이다. 이 객체는 특정 클라이언트 애플리케이션과 사용자 간의 인증 및 권한 부여 과정에서 발생하는 다양한 데이터를 저장하는데 사용한다.
▶ 주요 필드 및 설정
public class OAuth2Authorization implements Serializable { private String id; private String registeredClientId; private String principalName; private AuthorizationGrantType authorizationGrantType; private Set<String> authorizedScopes; private Map<Class<? extends OAuth2Token>, Token<?>> tokens; private Map<String, Object> attributes; ... }
1. ID: OAuth2Authorization 객체를 식별하는 고유한 ID
2. 등록된 클라이언트 (Registered Client): 클라이언트 애플리케이션을 나타내는 RegisteredClient 객체
3. Principal 이름: 인증된 사용자를 나타내는 이름
4. Authorization Grant Type: 권한 부여 타입
5. Attributes: 추가적인 속성 정보를 저장하는 맵
6. Tokens: 발급된 토큰 (액세스 토큰, 리프레시 토큰, ID 토큰 등)을 저장하는 정보
해당 포스팅에서는 OAuth2Authorization 객체에 대해서는 따로 다루지 않으니 자세한 부분은 공식문서를 참고해 주세요!
2. 인메모리가 아닌 로컬 DB 로 연동해서 사용하고 싶은데 어떻게 해야할까?
아래 step 을 따라가기 전에 로컬 프로젝트에서 databse 관련 dependency 가 먼저 설정되어있어야한다.
또한, 이 포스팅에서는 Spring Data JPA 를 사용하기 때문에 해당 의존성도 설치해주어야 한다.
스프링 부트는 3.2.2 버전을 사용했다. (설정 참고)
스프링 부트 3.2.2 에서 spring-security-oauth2-authorization-server 버전은 1.2.1 버전이다.
implementation 'org.springframework.security:spring-security-oauth2-authorization-server' implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
1) 엔티티 클래스를 만든다.
- ClientEntity.java 클래스를 만든다.
- @GeneratedValue(strategy= GenerationType.IDENTITY) 같은 건 각자 프로젝트에 맞게 쓰면된다.
package com.server.authorization.entity; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; import java.time.Instant; import lombok.Getter; import lombok.Setter; @Getter @Setter @Entity @Table(name = "client") public class ClientEntity { @Id @GeneratedValue(strategy= GenerationType.IDENTITY) 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; }
- 클래스를 만든 뒤에는 yaml 파일 설정을 아래와 같이 해서 자동으로 생성되게 하거나 따로 create table 쿼리를 이용해서 테이블을 만들어주면 된다.
jpa: hibernate: ddl-auto: update show-sql: true
2. ClientRepository 를 만든다.
일반적인 JPA repository 를 만드는 방법과 다르지 않다.
ClientReposiroty 클래스를 만든다.
대신 조회할 때 increment id 대신 고유한 clientId 로 조회하기 때문에 String clientId 를 인자로 가지는 findByClientId 메서드를 만든다.
package com.server.authorization.repository; import com.server.authorization.entity.ClientEntity; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface ClientRepository extends JpaRepository<ClientEntity, String> { Optional<ClientEntity> findByClientId(String clientId); }
3. 인가 작업에서 사용하기 위한 RegisteredClient 객체로 변환하는 Repository 를 만든다.
spring-security-oauth2-authorization-server 에서는 RegisteredClientReposiroty 인터페이스가 존재한다.
따라서 해당 인터페이스의 구현체를 만들어서 데이터베이스 테이블에서 조회한 raw 데이터를 인가에 필요한 RegisteredClient 객체로 컨버팅한다.

- RegisteredClientRepository 인터페이스
public interface RegisteredClientRepository { void save(RegisteredClient registeredClient); @Nullable RegisteredClient findById(String id); @Nullable RegisteredClient findByClientId(String clientId); }
여기서 save 인터페이스는 위에서 언급한 JAVA 코드 예제에서 처럼 config 클래스에서 RegisteredClientRepository 빈을 등록하면서 사용할 수 있다.
@Bean public RegisteredClientRepository registeredClientRepository(JpaRegisteredClientRepository jpaRegisteredClientRepository) { // 클라이언트 정보를 등록하는 객체를 만든다. RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString()) .clientId("clientid") .clientSecret("{noop}clientsecret") .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) .redirectUri("http://127.0.0.1:8081") .scope("store") .scope("order") .tokenSettings(TokenSettings.builder().accessTokenTimeToLive(Duration.ofSeconds(31536000)).build()) .build(); jpaRegisteredClientRepository.save(registeredClient); // 저장 return jpaRegisteredClientRepository; }
- 구현체 JpaRegisteredClientRepository 클래스 만들기
클래스 명은 마음대로 명명해도 된다. 여기서는 JpaRegisteredClientRepository 라고 명명했다.
package com.server.authorization.repository; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.Module; import com.fasterxml.jackson.databind.ObjectMapper; import com.server.authorization.entity.ClientEntity; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Set; import org.springframework.security.jackson2.SecurityJackson2Modules; import org.springframework.security.oauth2.core.AuthorizationGrantType; import org.springframework.security.oauth2.core.ClientAuthenticationMethod; import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; import org.springframework.security.oauth2.server.authorization.jackson2.OAuth2AuthorizationServerJackson2Module; import org.springframework.security.oauth2.server.authorization.settings.ClientSettings; import org.springframework.security.oauth2.server.authorization.settings.TokenSettings; import org.springframework.stereotype.Component; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @Component public class JpaRegisteredClientRepository implements RegisteredClientRepository { private final ClientRepository clientRepository; private final ObjectMapper objectMapper = new ObjectMapper(); public JpaRegisteredClientRepository(ClientRepository clientRepository) { Assert.notNull(clientRepository, "clientRepository cannot be null"); this.clientRepository = clientRepository; ClassLoader classLoader = JpaRegisteredClientRepository.class.getClassLoader(); List<Module> securityModules = SecurityJackson2Modules.getModules(classLoader); this.objectMapper.registerModules(securityModules); this.objectMapper.registerModule(new OAuth2AuthorizationServerJackson2Module()); } @Override public void save(RegisteredClient registeredClient) { Assert.notNull(registeredClient, "registeredClient cannot be null"); // this.clientRepository.save(toEntity(registeredClient)); } @Override public RegisteredClient findById(String id) { Assert.hasText(id, "id cannot be empty"); return this.clientRepository.findById(id).map(this::toObject).orElse(null); } @Override public RegisteredClient findByClientId(String clientId) { Assert.hasText(clientId, "clientId cannot be empty"); return this.clientRepository.findByClientId(clientId).map(this::toObject).orElse(null); } private RegisteredClient toObject(ClientEntity client) { Set<String> clientAuthenticationMethods = StringUtils.commaDelimitedListToSet( client.getClientAuthenticationMethods()); Set<String> authorizationGrantTypes = StringUtils.commaDelimitedListToSet( client.getAuthorizationGrantTypes()); Set<String> redirectUris = StringUtils.commaDelimitedListToSet( client.getRedirectUris()); Set<String> postLogoutRedirectUris = StringUtils.commaDelimitedListToSet( client.getPostLogoutRedirectUris()); Set<String> clientScopes = StringUtils.commaDelimitedListToSet( client.getScopes()); RegisteredClient.Builder builder = RegisteredClient.withId(client.getId().toString()) .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)); Map<String, Object> clientSettingsMap = parseMap(client.getClientSettings()); builder.clientSettings(ClientSettings.withSettings(clientSettingsMap).build()); Map<String, Object> tokenSettingsMap = parseMap(client.getTokenSettings()); builder.tokenSettings(TokenSettings.withSettings(tokenSettingsMap).build()); return builder.build(); } private Map<String, Object> parseMap(String data) { try { return this.objectMapper.readValue(data, new TypeReference<Map<String, Object>>() { }); } catch (Exception ex) { throw new IllegalArgumentException(ex.getMessage(), ex); } } private String writeMap(Map<String, Object> data) { try { return this.objectMapper.writeValueAsString(data); } catch (Exception ex) { throw new IllegalArgumentException(ex.getMessage(), ex); } } private static AuthorizationGrantType resolveAuthorizationGrantType(String authorizationGrantType) { if (AuthorizationGrantType.AUTHORIZATION_CODE.getValue().equals(authorizationGrantType)) { return AuthorizationGrantType.AUTHORIZATION_CODE; } else if (AuthorizationGrantType.CLIENT_CREDENTIALS.getValue().equals(authorizationGrantType)) { return AuthorizationGrantType.CLIENT_CREDENTIALS; } else if (AuthorizationGrantType.REFRESH_TOKEN.getValue().equals(authorizationGrantType)) { return AuthorizationGrantType.REFRESH_TOKEN; } return new AuthorizationGrantType(authorizationGrantType); // Custom authorization grant type } private static ClientAuthenticationMethod resolveClientAuthenticationMethod(String clientAuthenticationMethod) { if (ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue().equals(clientAuthenticationMethod)) { return ClientAuthenticationMethod.CLIENT_SECRET_BASIC; } else if (ClientAuthenticationMethod.CLIENT_SECRET_POST.getValue().equals(clientAuthenticationMethod)) { return ClientAuthenticationMethod.CLIENT_SECRET_POST; } else if (ClientAuthenticationMethod.NONE.getValue().equals(clientAuthenticationMethod)) { return ClientAuthenticationMethod.NONE; } return new ClientAuthenticationMethod(clientAuthenticationMethod); // Custom client authentication method } }
위 코드는 예제일뿐 만약에 entity 데이터가 다른 식으로 구성되어있더라도 위 구현체에서 잘 맞게 컨버팅만 해주면 된다.
스프링 공식 홈페이지 예제에서 제공한 로직대로 save 하게 되면 아래와 같이 저장된다.

다른 컬럼들은 우리가 알아보기 쉽게 컬럼에 저장되어있지만 client_settings, token_settings 같은 경우 json 모양의 오브젝트가 String 타입으로 컬럼에 저장되어있다.
- tokens_settings 컬럼 값
{"@class":"java.util.Collections$UnmodifiableMap","settings.token.reuse-refresh-tokens":true,"settings.token.id-token-signature-algorithm":["org.springframework.security.oauth2.jose.jws.SignatureAlgorithm","RS256"],"settings.token.access-token-time-to-live":["java.time.Duration",31536000.000000000],"settings.token.access-token-format":{"@class":"org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat","value":"self-contained"},"settings.token.refresh-token-time-to-live":["java.time.Duration",3600.000000000],"settings.token.authorization-code-time-to-live":["java.time.Duration",300.000000000],"settings.token.device-code-time-to-live":["java.time.Duration",300.000000000]}
해당 구조를 모르는 사람이 DB 를 컬럼 값을 수정해야한다거나 (토큰만료시간 수정이 제일 유력하다) 기존 security5 에서 권장하던 oauth2 client 의 필드대로 테이블이 구성되어있었다면 (우리가 그랬다...) 마이그레이션 작업을 진행할 때 위와같은 데이터 구조가 불가능 할 수 있다.
따라서 Client 가 될 테이블 컬럼의 타입과 값들은 자유롭게 커스텀해주고 RegisteredClientRepository 의 구현체에서 RegisteredClient 로 컨버팅만 알맞게 해주면 된다.
3. 그래서 어떻게 사용할 수 있는데?
2번 과정만 진행하면 실제로 OAuth2TokenEndpointFilter 에서 해당 client 를 조회하여 사용할 수 없다.
대신 위에서도 계속 언급해 왔듯이 설정 클래스에서 반드시 RegisteredClientRepository 를 빈으로 등록해주어야한다.
- AuthorizationServerConfig.java (설정 코드 참고)
@Bean @Order(Ordered.HIGHEST_PRECEDENCE) public SecurityFilterChain authorizationServerSecurityFilterChain( HttpSecurity http, JwtEncoder jwtEncoder, JpaRegisteredClientRepository registeredClientRepository, MemberService memberService, JwtCustomizer jwtCustomizer ) throws Exception { OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) .registeredClientRepository(registeredClientRepository); http // Accept access tokens for User Info and/or Client Registration .oauth2ResourceServer(resourceServer -> resourceServer .jwt(Customizer.withDefaults())); return http.build(); } @Bean public RegisteredClientRepository registeredClientRepository(JpaRegisteredClientRepository jpaRegisteredClientRepository) { return jpaRegisteredClientRepository; }