서로 다른 두 기관의 인증 시스템을 안전하게 연결하는 유령 캐릭터와 SSO 보안 게이트웨이

타 기관 SSO 연동 완전 정리: 서로 다른 도메인을 Spring Boot와 OIDC로 안전하게 연결하는 방법

개발 2026년 8월 26일

회사 내부 서비스끼리 로그인 정보를 공유하는 것과 타 기관의 시스템을 우리 서비스에 연결하는 것은 난이도가 다릅니다. 도메인이 다르고 운영 주체도 다르기 때문에 쿠키나 세션을 직접 공유할 수 없고, 한쪽 기관이 발급한 사용자 정보를 다른 기관이 안전하게 신뢰할 수 있는 절차가 필요합니다.

이 글에서는 다음과 같은 상황을 기준으로 설명합니다.

  • A기관은 이미 사용자를 인증할 수 있는 로그인 시스템을 운영한다.
  • B기관은 Spring Boot로 만든 서비스를 운영한다.
  • 사용자는 A기관 계정으로 로그인한 뒤 B기관 서비스를 별도 로그인 없이 이용한다.
  • A기관과 B기관의 도메인은 서로 다르다.
  • B기관은 로그인 결과를 기반으로 자체 회원과 권한을 관리한다.

먼저 결론부터 말하면, 신규 구축이라면 OpenID Connect(OIDC)의 Authorization Code 흐름과 PKCE를 우선 검토하는 것이 좋습니다. 상대 기관이 기존 SAML 2.0 IdP만 제공한다면 Spring Security의 SAML 2.0 Service Provider 기능을 사용합니다. 표준 프로토콜을 전혀 제공할 수 없을 때만 수명이 매우 짧은 일회용 인가 코드를 이용한 별도 연계 규격을 고려해야 합니다.

이 글의 예제는 B기관이 OIDC Client, 즉 Relying Party 역할을 맡는 가장 일반적인 구조를 중심으로 작성합니다.

작성 기준: 2026년 8월 26일. 예제는 현재 유지보수되는 Spring Boot 3.5·Spring Security 6.5 계열과 Spring Boot 4.1·Spring Security 7.1 계열에서 공통으로 사용할 수 있는 SecurityFilterChain 및 Lambda DSL을 기준으로 합니다. 프로젝트에서는 Spring Boot BOM이 정한 호환 버전을 사용하세요.

1. 도메인이 다른데 SSO가 가능한 이유

많이 오해하는 부분부터 짚어보겠습니다.

a.exampleb.example처럼 서로 다른 사이트가 SSO로 연결되더라도 두 사이트가 같은 로그인 쿠키를 사용하는 것은 아닙니다. 브라우저의 쿠키는 도메인 경계를 따르므로 a.example이 발급한 세션 쿠키를 관계없는 b.example이 읽을 수 없습니다.

대신 다음과 같이 동작합니다.

  1. 사용자가 B기관 서비스에 접근합니다.
  2. B기관은 아직 로그인하지 않은 사용자를 A기관의 인증 서버로 보냅니다.
  3. A기관은 자신의 도메인에 저장된 세션 쿠키로 사용자의 로그인 상태를 확인합니다.
  4. 이미 로그인되어 있으면 비밀번호를 다시 묻지 않고 인증 결과를 발급합니다.
  5. 브라우저는 인증 결과를 가지고 B기관의 등록된 콜백 주소로 돌아옵니다.
  6. B기관 서버가 인증 결과를 검증하고 B기관 전용 세션을 새로 만듭니다.

즉, 사용자가 체감하기에는 한 번만 로그인하지만 실제로는 A기관과 B기관이 각각 자신의 도메인에서 별도 세션을 유지합니다. 두 기관 사이에서 공유되는 것은 쿠키 자체가 아니라 표준화된 인증 결과와 그 결과를 검증하기 위한 신뢰 관계입니다.

절대로 피해야 할 방식

  • 서로 다른 최상위 도메인에서 JSESSIONID를 공유하려는 설계
  • 사용자 ID, 사번, 권한을 URL 쿼리 문자열에 그대로 담아 전달하는 방식
  • 장시간 유효한 JWT를 브라우저 리다이렉트 URL에 넣는 방식
  • 서명 검증 없이 Base64 또는 AES로만 포장한 사용자 정보를 신뢰하는 방식
  • 사용자의 이메일 주소만 같으면 기존 계정에 자동 연결하는 방식
  • 콜백의 redirect 값을 검증하지 않고 그대로 다시 이동시키는 방식
  • OAuth 2.0의 Access Token만 보고 사용자가 누구인지 판단하는 방식

2. SSO, OAuth 2.0, OpenID Connect, SAML의 차이

SSO

SSO는 Single Sign-On의 약자로, 사용자가 한 번 인증한 뒤 여러 서비스에 반복해서 자격 증명을 입력하지 않고 접근하는 사용자 경험을 뜻합니다. SSO 자체는 특정 프로토콜 이름이 아닙니다.

OAuth 2.0

OAuth 2.0은 기본적으로 권한 위임을 위한 프레임워크입니다. 예를 들어 어떤 서비스가 사용자 대신 다른 API의 일부 기능을 호출하도록 권한을 받는 데 사용합니다. OAuth 2.0 Access Token이 있다고 해서 그 토큰만으로 로그인 사용자의 신원을 임의로 판단해서는 안 됩니다.

OpenID Connect

OpenID Connect는 OAuth 2.0 위에 인증 계층을 추가한 표준입니다. 인증 서버는 사용자의 신원을 나타내는 ID Token을 발급하고, 클라이언트는 발급자, 대상, 만료 시간, 서명 등을 검증하여 사용자를 인증합니다.

웹·모바일·API를 함께 운영하거나 신규 시스템을 구축한다면 일반적으로 OIDC가 가장 다루기 편합니다. JSON과 JWT, Discovery 문서, JWKS를 사용하므로 Spring Security와도 자연스럽게 연동됩니다.

SAML 2.0

SAML 2.0은 XML 기반의 인증 연계 표준입니다. 공공기관, 대학, 대기업 그룹웨어, ADFS 기반 환경에서 여전히 많이 사용됩니다. 상대 기관이 SAML IdP만 제공한다면 억지로 OIDC로 바꾸기보다 우리 Spring 애플리케이션을 SAML Service Provider로 구성하는 것이 현실적입니다.

무엇을 선택해야 할까

  • 상대 기관이 OIDC를 지원한다면 OIDC Authorization Code 방식이 우선입니다.
  • 상대 기관이 SAML 2.0만 지원한다면 SAML 2.0으로 연동합니다.
  • 단순 API 호출 권한만 필요하고 사용자 로그인은 필요 없다면 OAuth 2.0 Client Credentials 등 별도 흐름을 검토합니다.
  • 양 기관 모두 인증 서버가 없고 향후 여러 서비스가 붙을 예정이라면 검증된 IdP 제품 또는 Spring Authorization Server 기반의 인증 서버를 별도 구축할 수 있습니다.
  • 표준 프로토콜을 사용할 수 없는 불가피한 경우에만 일회용 인가 코드 교환 방식을 설계합니다.

3. OIDC 연동에서 각 시스템의 역할

이 글의 예시에서는 다음 용어를 사용합니다.

  • End User: 실제 로그인하는 사용자
  • A기관 OP 또는 IdP: 사용자를 인증하고 ID Token을 발급하는 시스템
  • B기관 RP 또는 Client: A기관의 인증 결과를 받아 자체 로그인을 만드는 Spring Boot 서비스
  • Authorization Endpoint: 브라우저가 이동하여 사용자를 인증하는 주소
  • Token Endpoint: B기관 서버가 인가 코드를 토큰으로 교환하는 서버 간 주소
  • UserInfo Endpoint: 동의된 사용자 정보를 조회하는 API
  • Issuer: 토큰을 발급한 인증 서버를 식별하는 고정 URI
  • JWKS Endpoint: ID Token의 전자서명을 검증할 공개키 목록
  • Redirect URI: 인증 후 A기관이 사용자를 돌려보낼 B기관의 정확한 콜백 주소

여기서 가장 중요한 신원 키는 대개 sub입니다. 그러나 sub만 저장해서는 안 되고 issuer + sub 조합을 외부 사용자의 고유 식별자로 사용해야 합니다. 서로 다른 인증 서버가 우연히 같은 sub 값을 발급할 수 있기 때문입니다.

4. OIDC Authorization Code 흐름을 단계별로 이해하기

예시 주소는 다음과 같습니다.

  • A기관 인증 서버: https://sso.partner.example
  • B기관 서비스: https://service.our.example
  • B기관 로그인 시작 주소: https://service.our.example/oauth2/authorization/partner
  • B기관 콜백 주소: https://service.our.example/login/oauth2/code/partner

1단계: 보호된 페이지 요청

사용자가 B기관의 보호된 페이지를 요청합니다. B기관 세션이 없다면 Spring Security가 로그인을 시작합니다.

2단계: A기관 Authorization Endpoint로 이동

B기관은 브라우저를 A기관으로 리다이렉트합니다. 요청에는 일반적으로 다음 값이 들어갑니다.

  • client_id: A기관에 등록한 B기관의 클라이언트 ID
  • redirect_uri: 등록된 B기관 콜백 주소
  • response_type=code: 인가 코드 방식 사용
  • scope=openid profile email: OIDC와 필요한 사용자 정보 범위
  • state: 요청과 콜백을 연결하고 CSRF를 방어하는 무작위 값
  • nonce: ID Token 재사용 공격을 탐지하기 위한 무작위 값
  • code_challenge: PKCE를 위한 검증값
  • code_challenge_method=S256: SHA-256 기반 PKCE 사용

3단계: A기관에서 사용자 인증

A기관은 자신의 정책에 따라 비밀번호, 인증서, OTP, 패스키 등으로 사용자를 인증합니다. 사용자가 A기관에 이미 로그인한 상태라면 기존 A기관 세션을 사용하므로 로그인 화면이 생략될 수 있습니다. 이것이 사용자가 체감하는 SSO입니다.

4단계: B기관 콜백으로 인가 코드 전달

인증에 성공하면 A기관은 브라우저를 다음과 같은 주소로 돌려보냅니다.

https://service.our.example/login/oauth2/code/partner?code=ONE_TIME_CODE&state=RANDOM_STATE

이때 전달되는 code는 짧게 살아 있고 한 번만 사용할 수 있는 값이어야 합니다. ID Token이나 사용자 개인정보가 URL에 직접 노출되는 구조가 아닙니다.

5단계: 서버 간 토큰 교환

B기관 서버는 브라우저가 아니라 자신의 백엔드에서 A기관 Token Endpoint를 호출합니다. 인가 코드, 동일한 Redirect URI, PKCE code_verifier와 클라이언트 인증 정보를 보내 ID Token과 Access Token을 받습니다.

6단계: ID Token 검증

B기관은 최소한 다음 항목이 올바른지 확인해야 합니다.

  • 전자서명이 신뢰한 JWKS 공개키로 검증되는가
  • iss가 계약한 A기관 Issuer와 정확히 일치하는가
  • aud에 우리 client_id가 포함되는가
  • 여러 Audience가 있을 때 azp가 올바른가
  • exp, iat, 필요한 경우 nbf가 허용 시간 범위 안에 있는가
  • 요청 때 보낸 nonce와 토큰의 nonce가 일치하는가
  • 허용한 서명 알고리즘을 사용하는가
  • 인증 강도가 중요하다면 acr, amr, auth_time이 정책을 만족하는가

Spring Security의 OIDC Login 기능을 사용하면 표준적인 코드 교환과 ID Token 검증을 직접 구현하지 않아도 됩니다. 다만 Issuer, Client ID, Redirect URI와 키 회전 정책을 정확히 설정하고, 프레임워크 검증을 임의로 우회하지 않아야 합니다.

7단계: 외부 신원과 내부 회원 연결

토큰이 유효하다고 곧바로 관리자 권한을 부여해서는 안 됩니다. B기관은 검증된 issuer + sub를 내부 사용자와 연결하고, B기관의 회원 상태와 권한 정책을 별도로 확인해야 합니다.

8단계: B기관 세션 발급

최종 확인이 끝나면 B기관은 자신의 도메인에만 유효한 세션을 새로 만듭니다. 이후 사용자는 B기관 페이지를 이동할 때 A기관 토큰을 계속 브라우저에서 들고 다니는 것이 아니라 B기관의 안전한 세션 쿠키를 사용합니다.

5. 연동 전에 양 기관이 합의해야 할 항목

코드를 작성하기 전에 연동 규격서를 먼저 확정해야 합니다. 실제 프로젝트에서는 이 단계가 구현보다 더 오래 걸리는 경우가 많습니다.

기본 프로토콜 정보

  • OIDC 버전과 사용할 흐름: Authorization Code
  • PKCE 지원 및 S256 사용 여부
  • Issuer URI
  • Discovery 주소: 일반적으로 {issuer}/.well-known/openid-configuration
  • Authorization, Token, UserInfo, JWKS, Logout Endpoint
  • 허용할 서명 알고리즘
  • 클라이언트 인증 방식: client_secret_basic, private_key_jwt, mTLS 등

등록 정보

  • 개발·검증·운영 환경별 Client ID
  • 환경별 Redirect URI의 정확한 목록
  • 환경별 Post Logout Redirect URI
  • 접속 출발지 IP 제한 여부
  • TLS 인증서 및 기관 간 방화벽 정책
  • Client Secret 또는 키의 전달·보관·교체 절차

사용자 Claim

  • 변하지 않는 사용자 식별자: sub
  • 이름, 이메일, 소속, 사번 등 제공 Claim
  • 이메일 검증 여부를 나타내는 email_verified
  • 재직·재학·회원 상태 값의 정의
  • 직급이나 역할 값을 제공할 경우 코드 체계와 변경 정책
  • acr, amr, auth_time 제공 여부와 인증 강도 기준
  • 개인정보 최소 제공 원칙과 보유 기간

계정 수명 주기

  • 최초 로그인 시 자동 생성할지, 사전 등록 사용자만 허용할지
  • 퇴사·퇴직·졸업·휴면·탈퇴 사용자를 언제 차단할지
  • 이름·이메일·부서가 변경될 때 갱신 기준
  • 외부 계정과 내부 계정 연결을 해제하는 절차
  • 긴급 차단 전파 방식

운영 정책

  • Access Token, ID Token, 인가 코드의 수명
  • 허용할 시계 오차
  • 공개키 교체 사전 통지와 구키 유지 기간
  • 인증 서버 장애 시 사용자 화면과 재시도 정책
  • 로그아웃 범위: 로컬 로그아웃, RP-Initiated Logout, Back-Channel Logout
  • 감사 로그 항목, 보존 기간, 개인정보 마스킹
  • 테스트 계정, 장애 연락망, SLA와 정기 연동 점검 일정

6. Spring Boot에서 OIDC Client 구현하기

이제 B기관 Spring Boot 애플리케이션을 OIDC Client로 구성해 보겠습니다. 예제는 Servlet 기반 Spring MVC를 사용합니다. Spring Boot가 관리하는 Spring Security 버전을 그대로 사용하는 것이 안전하므로 개별 Spring Security 버전을 임의로 섞지 않는 편이 좋습니다.

6.1 Gradle 의존성 추가

기존 Spring Boot 프로젝트라면 다음 의존성을 추가합니다.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    runtimeOnly 'org.postgresql:postgresql'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
}

로그인만 연동한다면 oauth2-client가 핵심입니다. 우리 서버가 Bearer Access Token을 받아 API를 보호해야 하는 별도 요구가 있을 때만 oauth2-resource-server를 추가합니다. OAuth2 Client와 Resource Server는 역할이 다릅니다.

6.2 환경변수 준비

Client Secret을 소스 코드나 Git 저장소의 application.yml에 넣으면 안 됩니다.

export PARTNER_OIDC_CLIENT_ID='issued-client-id'
export PARTNER_OIDC_CLIENT_SECRET='issued-client-secret'

운영 환경에서는 Secret Manager, Vault, Kubernetes Secret 등 조직이 승인한 비밀 저장소를 사용하고 조회 권한과 교체 이력을 관리해야 합니다.

6.3 application.yml 설정

상대 기관이 OIDC Discovery를 지원한다면 개별 Endpoint를 모두 적기보다 issuer-uri를 사용하는 것이 좋습니다.

spring:
  security:
    oauth2:
      client:
        registration:
          partner:
            provider: partner
            client-id: ${PARTNER_OIDC_CLIENT_ID}
            client-secret: ${PARTNER_OIDC_CLIENT_SECRET}
            client-authentication-method: client_secret_basic
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
            scope:
              - openid
              - profile
              - email
        provider:
          partner:
            issuer-uri: https://sso.partner.example

server:
  forward-headers-strategy: framework
  servlet:
    session:
      timeout: 30m
      cookie:
        name: OUR_APP_SESSION
        http-only: true
        secure: true
        same-site: lax

registrationId는 여기서 partner입니다. 따라서 기본 로그인 시작 주소와 콜백 주소는 다음과 같습니다.

로그인 시작: /oauth2/authorization/partner
로그인 콜백: /login/oauth2/code/partner

issuer-uri는 브라우저 로그인 주소를 대충 적는 항목이 아닙니다. A기관이 발행한 Discovery 문서와 ID Token의 iss에 사용되는 Issuer 값을 정확히 받아야 합니다. 마지막 슬래시 하나가 달라도 별개 값으로 취급될 수 있으므로 계약한 문자열을 그대로 사용합니다.

상대 기관이 Discovery를 제공하지 않는다면 다음처럼 Endpoint를 명시할 수 있습니다.

spring:
  security:
    oauth2:
      client:
        provider:
          partner:
            authorization-uri: https://sso.partner.example/oauth2/authorize
            token-uri: https://sso.partner.example/oauth2/token
            user-info-uri: https://sso.partner.example/oidc/userinfo
            jwk-set-uri: https://sso.partner.example/oauth2/jwks
            user-name-attribute: sub

다만 수동 설정은 누락이나 불일치 가능성이 커집니다. 가능하면 기관 간 규격에 Discovery와 JWKS를 포함하는 것이 좋습니다.

6.4 Spring Security 설정

package com.example.sso.config;

import com.example.sso.security.PartnerOidcUserService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.oidc.web.logout.OidcClientInitiatedLogoutSuccessHandler;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;

@Configuration
public class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(
            HttpSecurity http,
            PartnerOidcUserService partnerOidcUserService,
            LogoutSuccessHandler oidcLogoutSuccessHandler) throws Exception {

        http
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/", "/login", "/error", "/css/**", "/js/**").permitAll()
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .oauth2Login(oauth2 -> oauth2
                .userInfoEndpoint(userInfo -> userInfo
                    .oidcUserService(partnerOidcUserService)
                )
            )
            .logout(logout -> logout
                .logoutSuccessHandler(oidcLogoutSuccessHandler)
                .invalidateHttpSession(true)
                .clearAuthentication(true)
                .deleteCookies("OUR_APP_SESSION")
            );

        // 브라우저 세션을 사용하는 애플리케이션이므로 CSRF 보호는 기본값 그대로 유지한다.
        return http.build();
    }

    @Bean
    LogoutSuccessHandler oidcLogoutSuccessHandler(
            ClientRegistrationRepository registrations) {

        var handler = new OidcClientInitiatedLogoutSuccessHandler(registrations);
        handler.setPostLogoutRedirectUri("{baseUrl}/");
        return handler;
    }
}

로그인 버튼은 다음 주소로 연결하면 됩니다.

<a href="/oauth2/authorization/partner">A기관 계정으로 로그인</a>

Spring Security가 기본 state 저장과 콜백 상관관계 확인, 인가 코드 교환, OIDC ID Token 검증을 수행합니다. 이를 직접 만든 필터로 대체하거나 콜백 파라미터만 읽어 로그인 처리하지 않는 것이 중요합니다.

기본 로그아웃은 CSRF가 활성화된 상태에서 POST로 호출하는 것이 안전합니다. 단순한 <a href="/logout"> GET 링크를 허용하기 위해 보안을 약화하지 말고, 화면 템플릿에서 CSRF 토큰이 포함된 POST 폼을 사용합니다.

6.5 PKCE를 명시적으로 적용하기

PKCE는 탈취된 인가 코드가 공격자에게 재사용되는 위험을 줄입니다. 최신 보안 권고에서는 공개 클라이언트뿐 아니라 지원 가능한 Authorization Code 클라이언트에 PKCE를 적용하는 것이 권장됩니다.

상대 기관이 Confidential Client의 PKCE를 지원하고 현재 사용하는 Spring Security 버전에서 자동 적용되지 않는다면 Authorization Request Resolver에 다음 Customizer를 적용할 수 있습니다.

import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestCustomizers;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver;

private OAuth2AuthorizationRequestResolver pkceResolver(
        ClientRegistrationRepository registrations) {

    var resolver = new DefaultOAuth2AuthorizationRequestResolver(
        registrations,
        "/oauth2/authorization"
    );
    resolver.setAuthorizationRequestCustomizer(
        OAuth2AuthorizationRequestCustomizers.withPkce()
    );
    return resolver;
}

그리고 oauth2Login에 연결합니다.

.oauth2Login(oauth2 -> oauth2
    .authorizationEndpoint(endpoint -> endpoint
        .authorizationRequestResolver(pkceResolver(registrations))
    )
    .userInfoEndpoint(userInfo -> userInfo
        .oidcUserService(partnerOidcUserService)
    )
)

Spring Security 6.5 이상 계열에는 ClientRegistration.ClientSettings.requireProofKey 설정도 있습니다. 사용하는 Spring Boot와 Spring Security 조합에 따라 공식 문서를 확인해 한 가지 방식으로 구성합니다. 상대 기관이 PKCE를 지원하지 않는다고 임의로 가정하지 말고, 연동 규격과 Discovery의 지원 정보를 확인해야 합니다.

6.6 외부 사용자와 내부 사용자 연결하기

OIDC 로그인 성공 후 해야 할 핵심 업무는 외부 신원을 내부 회원에 안전하게 연결하는 것입니다.

package com.example.sso.security;

import com.example.sso.user.ExternalIdentityService;
import com.example.sso.user.LocalLoginUser;
import java.util.HashSet;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest;
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames;
import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.stereotype.Service;

@Service
public class PartnerOidcUserService
        implements OAuth2UserService<OidcUserRequest, OidcUser> {

    private final OidcUserService delegate = new OidcUserService();
    private final ExternalIdentityService externalIdentityService;

    public PartnerOidcUserService(
            ExternalIdentityService externalIdentityService) {
        this.externalIdentityService = externalIdentityService;
    }

    @Override
    public OidcUser loadUser(OidcUserRequest request)
            throws OAuth2AuthenticationException {

        // 여기까지 오기 전에 Spring Security가 표준 OIDC 검증을 수행한다.
        OidcUser remoteUser = delegate.loadUser(request);

        String issuer = request.getIdToken().getIssuer().toString();
        String subject = request.getIdToken().getSubject();
        String email = remoteUser.getEmail();
        boolean emailVerified = Boolean.TRUE.equals(remoteUser.getEmailVerified());

        LocalLoginUser localUser = externalIdentityService.loginOrProvision(
            issuer,
            subject,
            email,
            emailVerified,
            remoteUser.getClaims()
        );

        var authorities = new HashSet<>(remoteUser.getAuthorities());
        localUser.roles().stream()
            .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
            .forEach(authorities::add);

        return new DefaultOidcUser(
            authorities,
            remoteUser.getIdToken(),
            remoteUser.getUserInfo(),
            IdTokenClaimNames.SUB
        );
    }
}

예제의 LocalLoginUser와 서비스 계약은 다음처럼 단순화할 수 있습니다. 실제 구현에서는 @Transactional 범위에서 사용자 상태 확인, JIT 생성, 마지막 로그인 시각 갱신과 감사 로그를 처리합니다.

public record LocalLoginUser(long userId, Set<String> roles) {
}

public interface ExternalIdentityService {
    LocalLoginUser loginOrProvision(
        String issuer,
        String subject,
        String email,
        boolean emailVerified,
        Map<String, Object> claims
    );
}

위 코드에서 외부 기관이 보낸 역할을 그대로 Spring의 관리자 권한으로 바꾸지 않았다는 점이 중요합니다. ExternalIdentityService가 B기관의 정책에 따라 허용된 내부 역할만 반환하도록 해야 합니다.

외부 기관에서 ADMIN이라는 문자열을 보냈다고 해서 곧바로 ROLE_ADMIN을 부여하면 안 됩니다. 기관 간 역할 매핑 규칙을 화이트리스트로 관리하고, 중요한 권한은 B기관 관리자 승인 절차를 거치는 것이 안전합니다.

6.7 권장 데이터 모델

외부 계정과 내부 계정을 분리하면 한 사용자가 여러 기관 계정을 연결하거나, 외부 이메일이 바뀌더라도 내부 데이터를 안정적으로 유지할 수 있습니다.

create table app_user (
    id              bigserial primary key,
    status          varchar(20) not null,
    display_name    varchar(100),
    created_at      timestamptz not null default now(),
    updated_at      timestamptz not null default now()
);

create table external_identity (
    id                  bigserial primary key,
    user_id             bigint not null references app_user(id),
    issuer              varchar(500) not null,
    subject             varchar(255) not null,
    email_snapshot      varchar(320),
    last_login_at       timestamptz,
    created_at          timestamptz not null default now(),
    updated_at          timestamptz not null default now(),
    constraint uk_external_identity unique (issuer, subject)
);

email_snapshot은 표시나 감사 목적의 참고값일 뿐 외부 신원의 기본키가 아닙니다. 이메일은 변경될 수 있고, 탈퇴 뒤 다른 사람에게 재할당될 수도 있으며, 검증되지 않은 이메일 Claim이 올 수도 있습니다.

최초 로그인 시 자동 가입하는 JIT Provisioning을 사용한다면 다음 정책을 명확히 정해야 합니다.

  • 허용된 Issuer인지 확인한다.
  • sub가 비어 있으면 실패 처리한다.
  • 이메일이 필수라면 email_verified=true도 요구한다.
  • 동일한 issuer + sub 생성 경쟁은 데이터베이스 Unique Constraint로 막는다.
  • 내부 사용자가 정지 상태면 외부 인증이 성공해도 로그인을 거부한다.
  • 자동 부여 기본 권한은 최소 권한으로 제한한다.
  • 기존 내부 계정에 이메일만으로 자동 병합하지 않는다.

기존 계정 연결이 필요하면 로그인된 내부 사용자가 다시 외부 인증을 수행하고 명시적으로 연결을 승인하는 절차를 두는 편이 안전합니다.

7. Nginx 또는 로드밸런서 뒤에서 주의할 점

Spring Boot 애플리케이션이 내부적으로 http://app:8080에서 실행되고 외부에서는 Nginx가 https://service.our.example을 제공하는 경우가 많습니다. 이때 Forwarded Header가 올바르지 않으면 Spring이 콜백 주소를 HTTP 또는 내부 호스트명으로 계산하여 redirect_uri_mismatch가 발생합니다.

Nginx 예시는 다음과 같습니다.

location / {
    proxy_pass http://spring_app:8080;

    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Port $server_port;
    proxy_set_header X-Forwarded-For $remote_addr;
}

Spring Boot에는 앞서 본 설정을 적용합니다.

server:
  forward-headers-strategy: framework

위 예시는 Nginx가 인터넷과 직접 맞닿은 최종 신뢰 프록시인 경우입니다. 앞단에 신뢰한 CDN이나 로드밸런서가 더 있다면 해당 프록시의 주소만 허용하고 Real IP 모듈 등으로 체인을 정상화한 뒤 값을 전달해야 합니다. 중요한 점은 외부 사용자가 임의로 보낸 X-Forwarded-* 값을 애플리케이션이 그대로 신뢰하지 않도록 신뢰 경계의 프록시가 해당 헤더를 제거하거나 올바른 값으로 덮어쓰는 것입니다. 애플리케이션 서버 포트도 인터넷에 직접 노출하지 않고 신뢰한 프록시에서만 접근하도록 제한합니다.

등록할 Redirect URI는 다음처럼 외부에서 실제 보이는 HTTPS 주소와 정확히 일치해야 합니다.

https://service.our.example/login/oauth2/code/partner

와일드카드 Redirect URI나 런타임에 전달받은 임의의 URL은 허용하지 않습니다.

8. 다중 서버 환경에서는 세션 저장소가 중요하다

Spring Security는 로그인 요청을 시작할 때 Authorization Request와 state, PKCE 검증에 필요한 정보를 기본적으로 HTTP 세션에 저장합니다. 서버가 여러 대인데 로그인 시작 요청과 콜백 요청이 서로 다른 인스턴스로 전달되면 해당 정보를 찾지 못해 인증이 실패할 수 있습니다.

해결 방법은 두 가지입니다.

  • 로드밸런서의 Sticky Session 사용
  • Spring Session과 Redis 등을 이용한 공유 세션 사용

운영 확장성과 장애 복구까지 고려하면 공유 세션 저장소가 일반적으로 관리하기 좋습니다.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-session-data-redis'
}
spring:
  session:
    timeout: 30m
    redis:
      namespace: our-service:sessions
  data:
    redis:
      host: redis.internal
      port: 6379

Redis 자체도 인증, TLS 또는 내부망 격리, 백업과 장애 조치 정책이 필요합니다. 세션 데이터 안에 불필요한 Access Token이나 과도한 개인정보를 저장하지 않도록 함께 점검합니다.

9. 계정 정지와 퇴사 처리는 SSO 로그인만으로 해결되지 않는다

SSO는 현재 로그인 요청이 유효한지 확인할 뿐, B기관에 이미 생성된 계정과 세션을 자동으로 정리해 주지 않습니다. A기관에서 사용자를 정지해도 B기관의 기존 세션이나 Refresh Token이 살아 있으면 만료 전까지 접근이 계속될 수 있습니다.

계정 회수 경로를 별도로 설계해야 합니다.

  1. A기관이 SCIM 또는 합의한 Provisioning API로 active=false 이벤트를 보냅니다.
  2. B기관은 신규 로그인을 즉시 차단합니다.
  3. 해당 내부 사용자의 모든 로컬 세션을 종료합니다.
  4. 저장한 Refresh Token과 위임 권한을 폐기합니다.
  5. 내부 역할, API Key, 대리 권한 등을 제거합니다.
  6. 실시간 이벤트가 누락될 상황에 대비해 매일 또는 합의한 주기로 전체 사용자 상태를 대조합니다.
  7. 연계 장애와 처리 실패는 재시도하고 담당자에게 경보를 보냅니다.

SCIM을 사용하지 않는다면 최소한 계정 정지 Webhook, 주기적 배치 동기화 또는 기관 관리자의 긴급 차단 채널 중 하나는 마련해야 합니다. 연동 계약서에는 정지 처리 SLA와 장애 시 수동 처리 절차도 포함합니다.

10. 로그아웃은 왜 로그인보다 복잡할까

SSO 환경에는 최소 두 개의 세션이 있습니다.

  • A기관 인증 서버의 로그인 세션
  • B기관 서비스의 로컬 세션

B기관 세션만 지우면 현재 서비스에서는 로그아웃됩니다. 그러나 A기관 세션이 남아 있으므로 사용자가 다시 B기관 로그인을 누르면 비밀번호 입력 없이 바로 로그인될 수 있습니다. 이것은 오류가 아니라 SSO의 정상적인 특성입니다.

전체 로그아웃이 필요하다면 다음을 구분해야 합니다.

로컬 로그아웃

B기관의 세션과 관련 쿠키만 제거합니다. 다른 연계 서비스와 A기관 로그인에는 영향을 주지 않습니다.

RP-Initiated Logout

B기관이 브라우저를 A기관의 end_session_endpoint로 보내 A기관 로그아웃도 요청합니다. 앞의 OidcClientInitiatedLogoutSuccessHandler가 이 흐름을 지원합니다. A기관에 Post Logout Redirect URI가 미리 등록되어 있어야 합니다.

Front-Channel 또는 Back-Channel Logout

A기관에서 로그아웃했을 때 연결된 여러 서비스의 세션까지 종료해야 한다면 별도 표준과 구현이 필요합니다. 특히 Back-Channel Logout은 브라우저 상태와 무관하게 A기관이 B기관의 백엔드로 Logout Token을 전송합니다. 로그아웃 토큰의 서명, Issuer, Audience, 이벤트 Claim, 재사용 여부를 검증하고 해당 사용자 세션을 찾아 종료해야 합니다.

모든 서비스에서 무조건 전체 로그아웃을 수행하는 것이 항상 정답은 아닙니다. 공용 PC인지, 여러 업무 시스템을 동시에 사용하는지, 한 서비스 로그아웃이 다른 서비스 업무를 끊어도 되는지를 정책적으로 결정해야 합니다.

11. SAML 2.0만 제공되는 기관과 연동하기

상대 기관이 ADFS 또는 기존 SAML IdP를 운영한다면 B기관 Spring Boot를 Relying Party, 즉 Service Provider로 구성할 수 있습니다.

Gradle 의존성

repositories {
    mavenCentral()
    maven { url 'https://build.shibboleth.net/maven/releases/' }
}

dependencies {
    implementation 'org.springframework.security:spring-security-saml2-service-provider'
}

메타데이터 기반 설정 예시

spring:
  security:
    saml2:
      relyingparty:
        registration:
          partner:
            assertingparty:
              metadata-uri: https://sso.partner.example/saml/metadata

Spring Security 설정에서는 다음 기능을 활성화합니다.

@Bean
SecurityFilterChain samlSecurityFilterChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(authorize -> authorize
            .requestMatchers("/", "/error").permitAll()
            .anyRequest().authenticated()
        )
        .saml2Login(Customizer.withDefaults());

    return http.build();
}

SAML 연동에서는 Entity ID, Assertion Consumer Service URL, IdP SSO URL, 서명 인증서, NameID 또는 사용자 Attribute, Audience, 시간 조건, 요청·응답 서명 정책을 양 기관이 합의해야 합니다. 인증서 교체 시점을 놓치면 전체 로그인이 중단될 수 있으므로 구·신 인증서의 중첩 운영과 사전 알림 절차가 특히 중요합니다.

SAML의 HTTP-POST Binding에서는 IdP가 B기관의 ACS로 교차 사이트 POST를 보냅니다. 이때 SameSite=Lax 세션 쿠키가 전송되지 않아 저장해 둔 AuthnRequest를 찾지 못할 수 있습니다. 실제 Binding과 브라우저 정책을 확인하고, 필요한 경우 해당 배포에서 세션 쿠키를 SameSite=None; Secure로 설정하거나 콜백 상관관계를 서버 측에서 안전하게 유지하는 방식을 검토해야 합니다. SameSite=None을 선택하더라도 SAML 응답 검증과 CSRF 방어를 생략할 수는 없습니다.

12. 표준 프로토콜을 쓸 수 없을 때의 일회용 티켓 방식

상대 기관의 제약으로 OIDC와 SAML을 모두 사용할 수 없다면 자체 연계 규격이 필요할 수 있습니다. 이 경우에도 사용자 정보를 URL에 직접 싣지 말고 OIDC Authorization Code와 비슷한 일회용 티켓 교환 방식으로 제한해야 합니다.

권장 흐름은 다음과 같습니다.

  1. B기관이 무작위 state를 세션에 저장하고 A기관 로그인 주소로 사용자를 보냅니다.
  2. A기관이 사용자를 인증합니다.
  3. A기관은 암호학적으로 안전한 무작위 일회용 티켓을 생성합니다.
  4. A기관은 티켓의 원문이 아닌 해시, 대상 서비스, 사용자, 만료 시각, 사용 여부를 서버 저장소에 기록합니다.
  5. 브라우저는 B기관의 고정 콜백 주소로 ticketstate만 전달합니다.
  6. B기관은 저장한 state와 응답을 비교합니다.
  7. B기관 백엔드는 mTLS 또는 비대칭 키 기반 클라이언트 인증을 사용해 A기관의 교환 API를 호출합니다.
  8. A기관은 티켓을 원자적으로 1회 소비 처리한 뒤 최소 사용자 정보를 응답합니다.
  9. B기관은 대상 서비스, 발급자, 만료 시간과 사용자 상태를 확인한 뒤 로컬 세션을 만듭니다.

일회용 티켓의 권장 특성은 다음과 같습니다.

  • CSPRNG로 생성한 충분히 긴 무작위 값
  • 30초에서 60초 정도의 매우 짧은 수명
  • 한 번 사용하거나 실패 기준을 넘으면 즉시 폐기
  • 특정 B기관 Client와 정확한 콜백에 결속
  • 데이터베이스나 Redis에서 원자적인 단일 소비 보장
  • 로그, 분석 도구, Referer에 남지 않도록 민감정보 비포함
  • 서버 간 교환 API의 강한 클라이언트 인증과 TLS 적용
  • 키 식별자와 교체 절차 마련

이 방식은 결국 자체 인증 프로토콜을 운영하는 일이므로 보안 검토와 상호운용 비용이 큽니다. 가능하다면 앞단에 표준 OIDC Broker를 두어 기존 인증을 OIDC로 변환하는 방법을 우선 검토하는 편이 좋습니다.

13. 운영 환경 보안 체크리스트

프로토콜과 Redirect URI

  • Authorization Code 흐름을 사용한다.
  • Implicit Grant와 Resource Owner Password Credentials Grant를 신규 구축에 사용하지 않는다.
  • PKCE S256을 사용한다.
  • Redirect URI는 HTTPS의 정확한 주소만 허용한다.
  • 와일드카드, 부분 문자열, 임의 Redirect URI를 허용하지 않는다.
  • 로그인 완료 후 이동 주소도 서버의 허용 목록 또는 내부 상대 경로만 사용한다.

요청 상관관계와 재사용 방지

  • state를 예측 불가능하게 생성하고 현재 로그인 세션에 결속한다.
  • OIDC nonce를 검증한다.
  • 인가 코드는 짧은 수명과 1회 사용을 보장한다.
  • PKCE verifier를 인증 요청과 안전하게 연결한다.
  • 커스텀 Authorization Request 저장소를 만들었다면 만료와 단일 소비를 테스트한다.

토큰 검증

  • iss, aud, 필요 시 azp를 검증한다.
  • exp, iat, 필요한 경우 nbf, auth_time을 검증한다.
  • 허용한 알고리즘만 사용하고 alg=none을 거부한다.
  • 토큰 헤더가 지정한 임의 URL에서 키를 가져오지 않는다.
  • 계약한 Issuer의 Discovery와 JWKS만 사용한다.
  • JWKS 키 회전과 캐시 갱신을 테스트한다.
  • ID Token을 API 호출용 Access Token처럼 사용하지 않는다.
  • Access Token을 사용자 신원 증명의 대체물로 사용하지 않는다.

토큰 저장과 전달

  • 서버 렌더링 웹이나 BFF 구조에서는 Token을 서버 측에 보관하고 브라우저에는 불투명한 세션 쿠키만 발급한다.
  • Access Token과 Refresh Token을 브라우저의 localStorage에 장기 저장하지 않는다.
  • API 호출 시 Access Token은 URL 쿼리가 아니라 Authorization: Bearer 헤더로 전달한다.
  • 로그인만 필요하다면 offline_access Scope와 Refresh Token을 불필요하게 요청하지 않는다.
  • Refresh Token을 저장해야 한다면 암호화, 접근 통제, 회전과 폐기 정책을 적용한다.

세션과 쿠키

  • 인증 성공 시 세션 ID가 교체되는지 확인한다.
  • 세션 쿠키에 Secure, HttpOnly, 적절한 SameSite를 적용한다.
  • 세션 쿠키의 Domain 범위를 불필요하게 넓히지 않는다.
  • 로그인과 보호 페이지 전체에 HTTPS를 강제한다.
  • 유휴 만료와 최대 세션 수명을 업무 위험도에 맞게 정한다.
  • 브라우저 기반 세션 애플리케이션의 CSRF 보호를 전역 비활성화하지 않는다.

계정과 권한

  • 외부 신원 키는 issuer + sub로 저장한다.
  • 이메일만으로 계정을 자동 병합하지 않는다.
  • 외부 역할을 내부 고권한으로 직접 변환하지 않는다.
  • 내부 계정의 정지·탈퇴 상태를 로그인마다 확인한다.
  • 최소 권한으로 시작하고 권한 상승은 승인 절차를 둔다.
  • JIT Provisioning과 사전 프로비저닝 중 하나를 명확히 선택한다.
  • 퇴사·졸업·계약 종료 등 접근 회수 시점을 합의한다.

비밀과 키

  • Client Secret, 개인키, Access Token, Refresh Token을 Git에 저장하지 않는다.
  • 로그와 오류 화면에 코드·토큰·Secret을 출력하지 않는다.
  • 비밀은 승인된 저장소에서 주입하고 최소 권한으로 조회한다.
  • 키마다 kid를 사용하고 구·신 키의 중첩 교체 기간을 둔다.
  • 키 유출 시 폐기, 재발급, 세션 종료, 상대 기관 통지 절차를 문서화한다.

개인정보와 감사

  • 업무에 필요한 최소 Scope와 Claim만 요청한다.
  • 원본 토큰 전체를 감사 로그에 보관하지 않는다.
  • 감사 로그에는 성공 여부, 내부 사용자 ID, Issuer, 해시 또는 추적 ID, 시각, 오류 코드 정도만 남긴다.
  • 이메일, 사번 등은 로그 목적에 맞게 마스킹한다.
  • 사용자에게는 단순 오류 화면을 보여주고 상세 원인은 내부 추적 ID로 조회한다.

14. 반드시 수행해야 할 테스트 시나리오

정상 로그인 한 번만 확인하고 운영에 반영하면 키 교체나 장애 순간에 문제가 드러납니다. 최소한 다음 시나리오를 자동 또는 수동으로 검증해야 합니다.

정상 흐름

  • A기관 미로그인 상태에서 B기관 로그인
  • A기관 로그인 상태에서 B기관 SSO
  • 최초 사용자 자동 생성 또는 사전 사용자 연결
  • 기존 사용자의 재로그인
  • 로그인 전 접근한 원래 페이지로 안전하게 복귀

검증 실패

  • 잘못된 state
  • 일치하지 않는 nonce
  • 만료된 ID Token
  • 잘못된 Issuer
  • 다른 Client를 대상으로 한 Audience
  • 허용하지 않은 서명 알고리즘
  • 알 수 없는 kid
  • 동일한 인가 코드 재사용
  • 등록하지 않은 Redirect URI

사용자와 권한

  • sub 누락
  • 이메일 누락 또는 email_verified=false
  • 정지된 내부 사용자
  • 외부 이메일 변경
  • 외부 부서 또는 역할 변경
  • 동일 사용자의 동시 최초 로그인
  • 공격자가 같은 이메일을 가진 다른 Issuer로 로그인

운영과 장애

  • A기관 Authorization Endpoint 장애
  • Token Endpoint 지연과 타임아웃
  • JWKS Endpoint 일시 장애 및 캐시 동작
  • A기관 키 회전 중 구키·신키 검증
  • B기관 다중 인스턴스에서 다른 서버로 콜백
  • Nginx 뒤에서 HTTPS Redirect URI 계산
  • 서버 간 시간 차이가 허용 범위를 초과한 경우
  • 로컬 로그아웃과 A기관 전체 로그아웃
  • A기관에서 계정을 차단한 직후의 B기관 접근

15. 자주 발생하는 오류와 원인

redirect_uri_mismatch

가장 흔한 오류입니다. A기관에 등록된 URI와 실제 요청 URI가 문자 단위로 다를 때 발생합니다.

  • HTTP와 HTTPS 차이
  • 포트 차이
  • 마지막 슬래시 차이
  • 프록시 뒤에서 내부 호스트명이 사용됨
  • 환경별 도메인 혼동
  • Context Path 누락

authorization_request_not_found 또는 invalid_state

  • 로그인 시작 시 만든 세션이 콜백에서 유지되지 않음
  • 다중 서버인데 세션을 공유하지 않음
  • 세션 쿠키의 SameSite 또는 Domain 설정 오류
  • 콜백까지 시간이 너무 오래 걸려 세션이 만료됨
  • 프록시가 쿠키나 경로를 잘못 변경함

invalid_nonce

  • 로그인 요청과 다른 브라우저 세션으로 콜백됨
  • Authorization Request 저장소를 직접 구현하며 nonce를 잃어버림
  • 오래된 탭의 콜백을 재사용함
  • 프레임워크의 OIDC 요청 구성을 과도하게 변경함

서명 또는 kid 검증 실패

  • A기관이 키를 교체했지만 JWKS 캐시가 갱신되지 않음
  • Discovery의 JWKS 주소가 잘못됨
  • Issuer 환경이 섞임
  • 서버 시간이 크게 어긋남
  • 구키를 너무 일찍 제거함

로그인은 성공하지만 권한이 없음

  • 외부 Claim 이름과 실제 응답이 다름
  • 문자열과 배열 형식이 환경별로 다름
  • 내부 역할 매핑에 해당 값이 없음
  • 사용자가 내부 시스템에서 정지 상태임
  • 인증과 인가를 혼동하여 로그인 성공만 확인함

16. A기관이 직접 인증 서버를 구축해야 한다면

A기관이 OIDC Provider를 제공해야 하고 조직이 Java·Spring 기반으로 인증 플랫폼을 운영할 역량이 있다면 Spring Authorization Server를 사용할 수 있습니다.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-authorization-server'
}

OIDC 기능은 기본 OAuth 2.0 설정만으로 자동 활성화되는 것이 아니므로 명시적으로 켜야 합니다.

@Bean
@Order(1)
SecurityFilterChain authorizationServerSecurityFilterChain(
        HttpSecurity http) throws Exception {

    var authorizationServer =
        OAuth2AuthorizationServerConfigurer.authorizationServer();

    http
        .securityMatcher(authorizationServer.getEndpointsMatcher())
        .with(authorizationServer, server -> server
            .oidc(Customizer.withDefaults())
        );

    return http.build();
}

실제 운영에서는 이것만으로 충분하지 않습니다. 다음 구성요소를 모두 운영 수준으로 준비해야 합니다.

  • 사용자 인증과 MFA
  • Registered Client 영속 저장소
  • Authorization과 Consent 영속 저장소
  • 고정 Issuer 설정
  • 안전하게 보관되는 서명 개인키
  • JWKS의 구·신 공개키 동시 게시와 회전
  • Client Secret 해시 저장 또는 private_key_jwt, mTLS 사용
  • 계정 잠금·접근 회수·세션 관리
  • 감사 로그와 이상 징후 탐지
  • 백업, 고가용성, 장애 복구
  • 보안 업데이트와 침해 사고 대응

인증 서버는 여러 서비스의 보안 경계가 집중되는 핵심 시스템입니다. 단지 SSO 화면 하나를 만들기 위해 자체 토큰 발급 코드를 작성하는 것은 피하고, 검증된 IdP 제품이나 Spring Authorization Server의 표준 구현을 기반으로 보안 검토를 거쳐야 합니다.

17. 더 높은 보안 수준이 필요한 경우

금융, 의료, 공공처럼 위험도가 높은 연계라면 기본 OIDC 위에 다음 항목을 추가 검토할 수 있습니다.

  • private_key_jwt 기반 Client 인증
  • mTLS와 인증서 수명 주기 관리
  • PAR(Pushed Authorization Requests)
  • JAR 또는 JARM을 이용한 요청·응답 보호
  • DPoP 또는 mTLS 기반 Sender-Constrained Access Token
  • acr·amr를 이용한 MFA 수준 확인
  • 중요한 업무 직전 prompt=login 또는 max_age를 이용한 재인증
  • Back-Channel Logout 또는 세션 상태 전파
  • SCIM 또는 별도 프로비저닝 채널을 이용한 접근 회수

이 기능은 상대 기관이 동일하게 지원해야 하므로 일방적으로 코드만 추가해서 해결할 수 없습니다. 위협 모델과 규제 요건을 기준으로 양 기관이 프로파일을 합의해야 합니다.

마무리

서로 다른 도메인과 기관을 연결하는 SSO의 본질은 쿠키 공유가 아니라 신뢰 연합입니다. A기관은 사용자를 인증하고, B기관은 표준 프로토콜로 전달된 결과를 검증한 뒤 자신의 회원과 권한 정책에 따라 별도 세션을 만듭니다.

신규 Spring Boot 서비스라면 다음 순서로 접근하면 됩니다.

  1. 상대 기관의 OIDC 또는 SAML 지원 여부를 먼저 확인합니다.
  2. 가능하면 OIDC Authorization Code와 PKCE를 선택합니다.
  3. Issuer, Redirect URI, Claim, 로그아웃, 키 교체, 계정 회수 정책을 문서로 합의합니다.
  4. Spring Security의 표준 OAuth2 Login 기능을 사용합니다.
  5. 외부 사용자는 issuer + sub로 내부 계정에 연결합니다.
  6. 인증 결과와 내부 권한을 분리하고 최소 권한을 적용합니다.
  7. 프록시, 다중 서버 세션, 로그아웃, 키 회전, 장애 상황까지 테스트합니다.

로그인 성공 화면이 보이는 것은 연동의 시작일 뿐입니다. 안전한 사용자 식별, 권한 부여, 퇴사자 차단, 키 회전, 장애 대응까지 완성되어야 실제 운영 가능한 SSO라고 할 수 있습니다.

공식 참고 자료

태그

mLog

8년 이상 풀스택 개발자로 일하고 있습니다. Spring Boot, PostgreSQL, Redis, Vue·TypeScript와 Docker·Linux 서버를 다루며, 직접 운영하고 해결한 내용을 공식 문서와 실행 결과를 바탕으로 정리합니다.