Spring Authorization Server 심화

Spring Authorization Server란?

Spring Authorization Server는 Spring 팀이 공식 지원하는 OAuth 2.1 / OpenID Connect 1.0 인가 서버 프레임워크입니다. 기존 Spring Security OAuth 프로젝트가 2022년 EOL된 이후, 완전히 새로 설계된 후속 프로젝트로 Spring Boot 3.x와 완벽히 호환됩니다. Keycloak 같은 외부 IdP 없이 자체 인가 서버를 Spring 생태계 안에서 구축할 수 있습니다.

핵심 아키텍처

Spring Authorization Server는 다음 네 가지 핵심 컴포넌트로 구성됩니다.

컴포넌트 역할 커스터마이징 포인트
RegisteredClientRepository OAuth2 클라이언트 등록·조회 JPA 영속화, 동적 등록
OAuth2AuthorizationService 인가 정보(코드·토큰) 저장 Redis/DB 백엔드
OAuth2TokenGenerator 액세스·리프레시·ID 토큰 생성 커스텀 클레임, 토큰 포맷
OAuth2TokenCustomizer 토큰 클레임 커스터마이징 역할·권한·프로필 추가

기본 설정: Authorization Server 구성

Spring Boot 3.x 기반 최소 설정부터 시작합니다.

// build.gradle.kts
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-oauth2-authorization-server")
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    runtimeOnly("org.postgresql:postgresql")
}

// application.yml
spring:
  security:
    oauth2:
      authorizationserver:
        issuer-url: https://auth.example.com
@Configuration
@EnableWebSecurity
public class AuthorizationServerConfig {

    @Bean
    @Order(1)
    public SecurityFilterChain authServerFilterChain(HttpSecurity http) throws Exception {
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
        http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
            .oidc(Customizer.withDefaults());  // OpenID Connect 활성화
        
        http.exceptionHandling(e -> e
            .defaultAuthenticationEntryPointFor(
                new LoginUrlAuthenticationEntryPoint("/login"),
                new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
            ));
        
        return http.build();
    }

    @Bean
    @Order(2)
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
            .formLogin(Customizer.withDefaults());
        return http.build();
    }

    @Bean
    public JWKSource<SecurityContext> jwkSource() {
        RSAKey rsaKey = generateRsaKey();
        JWKSet jwkSet = new JWKSet(rsaKey);
        return (selector, context) -> selector.select(jwkSet);
    }

    @Bean
    public AuthorizationServerSettings authorizationServerSettings() {
        return AuthorizationServerSettings.builder()
            .issuer("https://auth.example.com")
            .build();
    }
}

클라이언트 등록: JPA 영속화

프로덕션에서는 인메모리 대신 DB 기반 클라이언트 관리가 필수입니다.

@Bean
public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
    // Spring 제공 JDBC 구현체
    JdbcRegisteredClientRepository repository = 
        new JdbcRegisteredClientRepository(jdbcTemplate);
    
    // 초기 클라이언트 등록
    RegisteredClient webClient = RegisteredClient.withId(UUID.randomUUID().toString())
        .clientId("web-app")
        .clientSecret("{bcrypt}" + new BCryptPasswordEncoder().encode("secret"))
        .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
        .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
        .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
        .redirectUri("https://app.example.com/callback")
        .postLogoutRedirectUri("https://app.example.com/logout")
        .scope(OidcScopes.OPENID)
        .scope(OidcScopes.PROFILE)
        .scope("api.read")
        .scope("api.write")
        .clientSettings(ClientSettings.builder()
            .requireAuthorizationConsent(true)   // 동의 화면 표시
            .requireProofKey(true)               // PKCE 강제
            .build())
        .tokenSettings(TokenSettings.builder()
            .accessTokenTimeToLive(Duration.ofMinutes(30))
            .refreshTokenTimeToLive(Duration.ofDays(7))
            .reuseRefreshTokens(false)           // Refresh Token Rotation
            .idTokenSignatureAlgorithm(SignatureAlgorithm.RS256)
            .build())
        .build();
    
    repository.save(webClient);
    return repository;
}

핵심 포인트:

  • requireProofKey(true): OAuth 2.1 권장 사항인 PKCE를 강제하여 Authorization Code 가로채기 공격을 방지합니다.
  • reuseRefreshTokens(false): Refresh Token Rotation으로 토큰 탈취 시 피해를 최소화합니다.
  • 비밀번호는 반드시 BCrypt 인코딩으로 저장합니다.

커스텀 JWT 클레임 추가

액세스 토큰과 ID 토큰에 사용자 역할, 권한 등 커스텀 클레임을 주입하는 방법입니다.

@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer(
        UserService userService) {
    return context -> {
        // 액세스 토큰 커스터마이징
        if (context.getTokenType() == OAuth2TokenType.ACCESS_TOKEN) {
            Authentication principal = context.getPrincipal();
            Set<String> authorities = principal.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.toSet());
            
            context.getClaims()
                .claim("roles", authorities)
                .claim("tenant_id", extractTenantId(principal));
        }
        
        // ID 토큰 커스터마이징 (OIDC)
        if (context.getTokenType().getValue().equals("id_token")) {
            UserProfile profile = userService
                .findByUsername(context.getPrincipal().getName());
            
            context.getClaims()
                .claim("picture", profile.getAvatarUrl())
                .claim("locale", profile.getLocale())
                .claim("email_verified", profile.isEmailVerified());
        }
    };
}

Resource Server에서는 이 커스텀 클레임을 기반으로 세밀한 접근 제어를 구현할 수 있습니다. @PreAuthorize("hasAuthority('SCOPE_api.write') and #jwt.claims['tenant_id'] == #tenantId") 같은 SpEL 표현식이 가능해집니다.

PKCE + Authorization Code Flow 전체 흐름

OAuth 2.1에서 권장하는 PKCE 기반 Authorization Code Grant의 전체 시퀀스입니다.

// 1. 클라이언트: code_verifier 생성 + code_challenge 계산
String codeVerifier = generateRandomString(128);
String codeChallenge = Base64.urlEncode(SHA256(codeVerifier));

// 2. 인가 요청 (브라우저 리다이렉트)
GET /oauth2/authorize?
    response_type=code
    &client_id=web-app
    &redirect_uri=https://app.example.com/callback
    &scope=openid profile api.read
    &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw
    &code_challenge_method=S256
    &state=xyz123

// 3. 사용자 로그인 + 동의 → 인가 코드 발급
302 Location: https://app.example.com/callback?code=AUTH_CODE&state=xyz123

// 4. 토큰 교환 (서버 사이드)
POST /oauth2/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64(web-app:secret)

grant_type=authorization_code
&code=AUTH_CODE
&redirect_uri=https://app.example.com/callback
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

// 5. 응답: access_token + refresh_token + id_token
{
  "access_token": "eyJhbGciOiJSUzI1NiJ9...",
  "refresh_token": "Atzr|IQEBLzAtA...",
  "id_token": "eyJhbGciOiJSUzI1NiJ9...",
  "token_type": "Bearer",
  "expires_in": 1800
}

Machine-to-Machine: Client Credentials

서비스 간 통신에는 Client Credentials Grant를 사용합니다.

// 서비스 전용 클라이언트 등록
RegisteredClient serviceClient = RegisteredClient.withId(UUID.randomUUID().toString())
    .clientId("payment-service")
    .clientSecret("{bcrypt}" + encoder.encode("service-secret"))
    .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
    .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
    .scope("internal.payment.process")
    .scope("internal.order.read")
    .tokenSettings(TokenSettings.builder()
        .accessTokenTimeToLive(Duration.ofMinutes(10))  // 짧은 TTL
        .accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED)
        .build())
    .build();

// 서비스에서 토큰 요청
POST /oauth2/token
Authorization: Basic base64(payment-service:service-secret)
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&scope=internal.payment.process

M2M 토큰은 TTL을 짧게(10분 이내) 설정하고, scope를 최소 권한으로 제한하는 것이 보안 모범 사례입니다.

인가 정보 DB 저장: JdbcOAuth2AuthorizationService

다중 인스턴스 환경에서는 인가 정보를 DB에 저장해야 합니다.

@Bean
public OAuth2AuthorizationService authorizationService(
        JdbcTemplate jdbcTemplate, 
        RegisteredClientRepository clientRepository) {
    
    JdbcOAuth2AuthorizationService service = 
        new JdbcOAuth2AuthorizationService(jdbcTemplate, clientRepository);
    
    // 커스텀 Row Mapper (추가 메타데이터 저장 시)
    JdbcOAuth2AuthorizationService.OAuth2AuthorizationRowMapper rowMapper =
        new JdbcOAuth2AuthorizationService.OAuth2AuthorizationRowMapper(clientRepository);
    
    ObjectMapper objectMapper = new ObjectMapper();
    objectMapper.registerModule(new CoreJackson2Module());
    objectMapper.registerModule(new OAuth2AuthorizationServerJackson2Module());
    // 커스텀 타입 등록 (UserProfile 등)
    objectMapper.addMixIn(UserProfile.class, UserProfileMixin.class);
    rowMapper.setObjectMapper(objectMapper);
    
    service.setAuthorizationRowMapper(rowMapper);
    return service;
}

@Bean
public OAuth2AuthorizationConsentService authorizationConsentService(
        JdbcTemplate jdbcTemplate, 
        RegisteredClientRepository clientRepository) {
    return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, clientRepository);
}

Spring Authorization Server는 필요한 테이블 스키마를 공식 제공합니다. org/springframework/security/oauth2/server/authorization/ 경로에서 DDL을 확인할 수 있습니다.

토큰 무효화(Revocation)와 Introspection

Spring Authorization Server는 Token Revocation(RFC 7009)Token Introspection(RFC 7662) 엔드포인트를 기본 제공합니다.

// 토큰 무효화 요청
POST /oauth2/revoke
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/x-www-form-urlencoded

token=eyJhbGciOiJSUzI1NiJ9...&token_type_hint=access_token

// 토큰 검증 요청 (Opaque 토큰 사용 시)
POST /oauth2/introspect
Authorization: Basic base64(client_id:client_secret)

token=kYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNn2DhB

// 응답
{
  "active": true,
  "sub": "user123",
  "scope": "openid api.read",
  "client_id": "web-app",
  "exp": 1711350000
}

다중 IdP 연동: Social Login + 자체 인증

Google, GitHub 등 소셜 로그인을 Authorization Server의 인증 수단으로 통합하는 패턴입니다.

@Bean
@Order(2)
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests(auth -> auth
            .requestMatchers("/login", "/error").permitAll()
            .anyRequest().authenticated())
        .formLogin(form -> form.loginPage("/login"))
        .oauth2Login(oauth2 -> oauth2
            .loginPage("/login")
            .userInfoEndpoint(userInfo -> userInfo
                .userService(customOAuth2UserService())
            ));
    return http.build();
}

// application.yml
spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: ${GOOGLE_CLIENT_ID}
            client-secret: ${GOOGLE_CLIENT_SECRET}
            scope: openid, profile, email
          github:
            client-id: ${GITHUB_CLIENT_ID}
            client-secret: ${GITHUB_CLIENT_SECRET}
            scope: read:user, user:email

사용자는 폼 로그인 또는 Google/GitHub 중 선택하여 인증하고, Authorization Server는 통합된 OAuth2 토큰을 발급합니다. 하나의 인가 서버가 여러 인증 수단을 통합하는 허브 역할을 합니다.

프로덕션 보안 체크리스트

Spring Authorization Server를 프로덕션에 배포할 때 반드시 확인해야 할 보안 항목입니다.

  1. HTTPS 필수: 모든 엔드포인트에 TLS를 적용하고, issuer-url도 HTTPS로 설정
  2. PKCE 강제: 모든 Authorization Code 클라이언트에 requireProofKey(true) 설정
  3. Refresh Token Rotation: reuseRefreshTokens(false)로 탈취 감지 가능
  4. 토큰 TTL 최소화: Access Token 15-30분, Refresh Token 7일 이내
  5. Client Secret 암호화: BCrypt 또는 Argon2로 인코딩하여 저장
  6. Redirect URI 정확 매칭: 와일드카드 사용 금지, 정확한 URI만 허용
  7. JWK 키 로테이션: RSA/EC 키를 주기적으로 교체하고, kid로 식별
  8. Rate Limiting: /oauth2/token 엔드포인트에 요청 제한 적용

Keycloak 대비 장단점

항목 Spring Authorization Server Keycloak
커스터마이징 코드 레벨 완전 제어 SPI 확장 (제한적)
관리 UI 직접 구현 필요 내장 Admin Console
멀티테넌시 직접 구현 Realm 기반 내장
운영 복잡도 Spring Boot 앱 하나 별도 인프라 필요
프로토콜 지원 OAuth 2.1 + OIDC OAuth 2.0 + OIDC + SAML
적합한 상황 Spring 기반 마이크로서비스 이기종 환경, 엔터프라이즈

Spring 생태계 중심의 마이크로서비스라면 Spring Authorization Server가 적합하고, SAML 지원이나 관리 UI가 즉시 필요하다면 Keycloak이 유리합니다.

Spring Authorization Server는 인가 로직을 애플리케이션 코드로 완전히 제어할 수 있어, 복잡한 비즈니스 요구사항에 유연하게 대응할 수 있습니다. OAuth 2.1 표준 준수와 Spring Security와의 긴밀한 통합이 최대 장점입니다. Resource Server 측 JWT 검증 설정은 Spring OAuth2 Resource Server 글을, 메서드 수준 보안은 Spring Method Security 심화 가이드를 참고하세요.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux