본문 바로가기

프로젝트/Techfork

[26/01/28] 오늘의 개발 일지 - 웹 Apple 소셜 로그인 구현

오늘은 웹에서 Apple 소셜 로그인을 할 수 있도록 REST API 문서를 보고 구현했습니다.

 

애플 로그인은 카카오 로그인보다 훨씬 까다롭더군요...

https://developer.apple.com/documentation/signinwithapplerestapi

 

Sign in with Apple REST API | Apple Developer Documentation

Communicate between your app servers and Apple’s authentication servers.

developer.apple.com

 

 

웹 Apple 로그인 구현 가이드

TechFork 프로젝트의 Spring Security OAuth2 기반 Apple 소셜 로그인 구현


⚠️ Apple vs Kakao 핵심 차이점 (먼저 읽어야 할 것!)

Apple 로그인은 Kakao와 달리 몇 가지 특수한 처리가 필요합니다. 이걸 모르면 삽질합니다.

1️⃣ Authorization Code를 POST로 받음 (response_mode=form_post)

구분  Kakao  Apple
콜백 방식 GET (URL 쿼리 파라미터) POST (form data)
response_mode 기본값 (query) form_post 필수
# application.yml
provider:
  kakao:
    authorization-uri: https://kauth.kakao.com/oauth/authorize
    # → 콜백: GET /login/oauth2/code/kakao?code=xxx&state=yyy
  
  apple:
    authorization-uri: https://appleid.apple.com/auth/authorize?response_mode=form_post
    # → 콜백: POST /login/oauth2/code/apple (body: code=xxx&state=yyy)

🚨 이로 인해 필요한 설정: CORS에 Apple 도메인 추가

Apple이 우리 서버로 POST 요청을 보내기 때문에, CORS 허용 Origin에 Apple 도메인을 추가해야 합니다.

// SecurityConfig.java
@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration configuration = new CorsConfiguration();

    configuration.setAllowedOrigins(List.of(
        "http://localhost:5173",
        "https://techfork.shop",
        "https://api.techfork.shop",
        "https://appleid.apple.com"  // ⚠️ Apple Sign In form_post 필수!
    ));
    
    // ...
}

빠뜨리면? Apple 콜백 시 CORS 에러로 로그인 실패

 


2️⃣ client-secret 동적 생성 (JWT 서명)

구분 Kakao Apple
client-secret 고정값 (콘솔에서 발급) JWT로 동적 생성
서명 알고리즘 없음 ES256 (ECDSA)
필요한 키 없음 Private Key (.p8 파일)
만료 시간 없음 최대 6개월
# application.yml
registration:
  kakao:
    client-secret: ${KAKAO_CLIENT_SECRET}  # ✅ 고정값 그대로 사용
  
  apple:
    client-secret: dummy-will-be-replaced-at-runtime  # ⚠️ 런타임에 교체됨

Apple client-secret JWT 구조

Header: { "kid": "{KEY_ID}", "alg": "ES256" }
Payload: {
  "iss": "{TEAM_ID}",        // Apple Developer Team ID
  "iat": 1234567890,         // 발급 시간
  "exp": 1234567890,         // 만료 시간 (최대 6개월)
  "aud": "https://appleid.apple.com",
  "sub": "{CLIENT_ID}"       // Apple Services ID
}
Signature: ES256(Header + Payload, PrivateKey)

필요한 Apple Developer 설정

항목 설명 환경변수
Team ID Apple Developer 계정 ID APPLE_TEAM_ID
Key ID Sign in with Apple Key ID APPLE_KEY_ID
Client ID Services ID (Identifier) APPLE_CLIENT_ID
Private Key .p8 파일 경로 APPLE_PRIVATE_KEY_PATH

 


3️⃣ 프로필 이미지 미제공

구분 Kakao Apple
프로필 이미지 picture 제공 항상 null
이름 nickname 제공 최초 로그인 시에만 (user 파라미터)
// CustomOidcUserService.java
String profileImage = oidcUser.getAttribute("picture");
// Kakao: "http://k.kakaocdn.net/..."
// Apple: null (항상)

주의: Apple은 사용자가 "Hide My Email"을 선택하면 랜덤 이메일(xxx@privaterelay.appleid.com)을 제공합니다.

 


4️⃣ 사용자 정보 최초 1회만 제공

구분 Kakao Apple
사용자 정보 매 로그인마다 조회 가능 최초 로그인 시에만
이름/이메일 userinfo endpoint로 조회 콜백의 user 파라미터로만
# Apple 최초 로그인 콜백 (POST body)
code=xxx
&state=yyy
&user={"name":{"firstName":"길동","lastName":"홍"},"email":"user@example.com"}

# Apple 재로그인 콜백 (POST body) - user 파라미터 없음!
code=xxx
&state=yyy

중요: 최초 로그인 시 받은 사용자 정보를 반드시 DB에 저장해야 합니다. 이후에는 다시 받을 수 없습니다.

 


5️⃣ HTTPS 필수 (로컬 테스트 시 ngrok 사용)

 

구분  Kakao  Apple
HTTP 허용 ✅ 로컬 테스트 가능 HTTPS 필수
로컬 테스트 http://localhost:8080 ngrok 등 터널링 필요

Apple은 redirect_uri에 HTTPS만 허용합니다. 로컬에서 테스트하려면 ngrok으로 HTTPS 터널을 만들어야 해요.

ngrok 설치 및 실행

# 1. ngrok 설치 (macOS)
brew install ngrok

# 2. ngrok 계정 연동 (https://ngrok.com 에서 authtoken 발급)
ngrok config add-authtoken {YOUR_AUTH_TOKEN}

# 3. 터널 실행 (Spring Boot 8080 포트)
ngrok http 8080

ngrok 실행 결과

Session Status                online
Forwarding                    https://abc123.ngrok-free.app -> http://localhost:8080

 

주의: ngrok 무료 플랜은 실행할 때마다 URL이 바뀝니다. 고정 도메인이 필요하면 유료 플랜을 사용하세요.

Apple Developer에 redirect URI 등록

ngrok URL이 바뀔 때마다 Apple Developer Console에서도 업데이트해야 합니다:

  1. Apple Developer Console 접속
  2. Certificates, Identifiers & Profiles → Identifiers
  3. Services IDs 선택 → Sign in with Apple 설정
  4. Return URLs에 ngrok URL 추가: https://abc123.ngrok-free.app/login/oauth2/code/apple

 


📋 체크리스트: Apple 로그인 구현 시 확인사항

  • [ ] authorization-uri에 ?response_mode=form_post 추가했는가?
  • [ ] CORS 허용 Origin에 https://appleid.apple.com 추가했는가?
  • [ ] AppleClientSecretGenerator로 JWT 동적 생성 구현했는가?
  • [ ] Apple Developer에서 Team ID, Key ID, Client ID, Private Key 발급받았는가?
  • [ ] Private Key (.p8) 파일을 안전한 경로에 배치했는가?
  • [ ] 프로필 이미지가 null일 수 있음을 고려했는가?
  • [ ] 최초 로그인 시 사용자 정보를 DB에 저장하는가?
  • [ ] (로컬 테스트) ngrok으로 HTTPS 터널 설정했는가?
  • [ ] (로컬 테스트) Apple Developer Console에 ngrok URL 등록했는가?
  • [ ] (로컬 테스트) yml 파일에 ngrok HTTPS 경로로 리다이렉트 URI를 등록했는가?

 


🔄 Kakao 로그인과의 코드 공유

TechFork는 Kakao 로그인을 OIDC 방식으로 구현하여 Apple 로그인과 동일한 CustomOidcUserService와 핸들러를 공유합니다.

// AppleOAuth2Config.java - Apple만 특수 처리
if ("apple".equals(grantRequest.getClientRegistration().getRegistrationId())) {
    // Apple: JWT로 client-secret 동적 생성
    String clientSecret = appleClientSecretGenerator.generateClientSecret();
    parameters.add(OAuth2ParameterNames.CLIENT_SECRET, clientSecret);
} else {
    // Kakao 등: yml 설정의 고정값 사용
    parameters.add(OAuth2ParameterNames.CLIENT_SECRET, 
        grantRequest.getClientRegistration().getClientSecret());
}
# application.yml - 설정 비교
registration:
  kakao:
    client-id: ${KAKAO_REST_API_KEY}
    client-secret: ${KAKAO_CLIENT_SECRET}  # 고정값
    scope: [openid, account_email, profile_image]
  
  apple:
    client-id: ${APPLE_CLIENT_ID}
    client-secret: dummy-will-be-replaced-at-runtime  # 동적 생성
    scope: [openid, email]  # name scope 없음 (어차피 최초 1회만)

 


📖 전체 플로우 상세 설명

위의 핵심 차이점을 이해했다면, 아래 전체 플로우를 읽어보세요.

1️⃣ 사용자가 로그인 시작

사용자 → 프론트엔드: "Sign in with Apple" 버튼 클릭
프론트엔드 → 서버: GET /oauth2/authorization/apple

 


2️⃣ Authorization Request (서버 → Apple)

// SecurityConfig의 oauth2Login이 자동 처리
// application.yml의 provider.apple.authorization-uri 사용

서버가 Apple authorization URL 생성:
https://appleid.apple.com/auth/authorize?
  client_id={APPLE_CLIENT_ID}
  &redirect_uri={baseUrl}/login/oauth2/code/apple
  &response_type=code
  &scope=openid email
  &response_mode=form_post
  &nonce={랜덤생성}
  &state={랜덤생성}

→ 사용자를 Apple 로그인 페이지로 리다이렉트

🔑 STATELESS 환경에서의 state 관리

// HttpCookieOAuth2AuthorizationRequestRepository
// JWT 기반 STATELESS 환경에서 OAuth2 state를 쿠키로 관리

@Component
public class HttpCookieOAuth2AuthorizationRequestRepository 
        implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
    
    public static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request";
    private static final int COOKIE_EXPIRE_SECONDS = 180; // 3분
    
    // OAuth2AuthorizationRequest를 직렬화하여 쿠키에 저장
    // → 콜백 시 쿠키에서 복원하여 state 검증
}

 


3️⃣ 사용자 인증 (Apple)

사용자 → Apple: Apple ID/비밀번호 입력 또는 Face ID/Touch ID
Apple: 사용자 인증 및 동의 확인

 


4️⃣ Authorization Code 수신

Apple → 서버: POST /login/oauth2/code/apple
  - code: {authorization_code}
  - state: {검증용}
  - user: {최초 로그인 시에만, 이름/이메일 정보}

 


5️⃣ Token 교환 (핵심! 커스터마이징한 부분)

5-1. client-secret 동적 생성

// AppleClientSecretGenerator.generateClientSecret() 호출

@Component
public class AppleClientSecretGenerator {

    @Value("${apple.team-id}")
    private String teamId;

    @Value("${apple.key-id}")
    private String keyId;

    @Value("${apple.private-key-path}")
    private String privateKeyPath;

    @Value("${spring.security.oauth2.client.registration.apple.client-id}")
    private String clientId;

    public String generateClientSecret() {
        Date expirationDate = Date.from(
            LocalDateTime.now().plusDays(180)
                .atZone(ZoneId.systemDefault())
                .toInstant()
        );

        return Jwts.builder()
            .setHeaderParam("kid", keyId)
            .setHeaderParam("alg", "ES256")
            .setIssuer(teamId)              // Apple Team ID
            .setIssuedAt(new Date())
            .setExpiration(expirationDate)  // 180일 후
            .setAudience("https://appleid.apple.com")
            .setSubject(clientId)           // Apple Client ID
            .signWith(getPrivateKey(), SignatureAlgorithm.ES256)
            .compact();
    }
}

https://developer.apple.com/documentation/AccountOrganizationalDataSharing/creating-a-client-secret

 

Creating a client secret | Apple Developer Documentation

Generate a signed token to identify your client application.

developer.apple.com

 

5-2. AppleOAuth2Config - Token Client 커스터마이징

@Configuration
@RequiredArgsConstructor
public class AppleOAuth2Config {

    private final AppleClientSecretGenerator appleClientSecretGenerator;

    @Bean
    public OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> accessTokenResponseClient() {
        RestClientAuthorizationCodeTokenResponseClient client = 
            new RestClientAuthorizationCodeTokenResponseClient();

        client.setParametersConverter(grantRequest -> {
            MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();

            // 기본 필수 파라미터
            parameters.add(OAuth2ParameterNames.GRANT_TYPE, grantRequest.getGrantType().getValue());
            parameters.add(OAuth2ParameterNames.CODE, 
                grantRequest.getAuthorizationExchange().getAuthorizationResponse().getCode());
            
            // Authorization Request에서 실제 사용된 redirect_uri 가져오기 (템플릿이 아닌 실제 값)
            String redirectUri = grantRequest.getAuthorizationExchange()
                    .getAuthorizationRequest()
                    .getRedirectUri();
            parameters.add(OAuth2ParameterNames.REDIRECT_URI, redirectUri);
            parameters.add(OAuth2ParameterNames.CLIENT_ID, 
                grantRequest.getClientRegistration().getClientId());

            // Apple일 때만 client-secret 동적 생성
            if ("apple".equals(grantRequest.getClientRegistration().getRegistrationId())) {
                String clientSecret = appleClientSecretGenerator.generateClientSecret();
                parameters.add(OAuth2ParameterNames.CLIENT_SECRET, clientSecret);
            } else {
                // 다른 소셜 로그인(Kakao 등)은 yml 설정 사용
                parameters.add(OAuth2ParameterNames.CLIENT_SECRET, 
                    grantRequest.getClientRegistration().getClientSecret());
            }

            return parameters;
        });

        return client;
    }
}

https://docs.spring.io/spring-security/reference/6.5/servlet/oauth2/client/authorization-grants.html#oauth2-client-authorization-code-access-token-response

 

Authorization Grant Support :: Spring Security

This section describes Spring Security’s support for authorization grants.

docs.spring.io

 

5-3. Apple Token API 호출

서버 → Apple: POST https://appleid.apple.com/auth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code={authorization_code}
&redirect_uri={baseUrl}/login/oauth2/code/apple
&client_id={APPLE_CLIENT_ID}
&client_secret={동적생성된JWT}  ← 여기가 포인트!

5-4. Apple 응답

{
  "access_token": "...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "...",
  "id_token": "eyJraWQ..."  // OIDC ID Token (JWT)
}

 


6️⃣ ID Token 검증 및 파싱

// Spring Security가 자동 처리
// - jwk-set-uri(https://appleid.apple.com/auth/keys)에서 Apple 공개키 가져옴
// - id_token의 서명 검증 (ES256)
// - aud, iss, exp, nonce 검증
// - JWT 파싱하여 사용자 정보 추출

https://developer.apple.com/documentation/signinwithapple/verifying-a-user

 

Verifying a user | Apple Developer Documentation

Check the validity and integrity of a user’s identity token.

developer.apple.com

 

ID Token 내용 예시

{
  "iss": "https://appleid.apple.com",
  "aud": "{APPLE_CLIENT_ID}",
  "sub": "001234.abc123def456...",  // Apple User ID (socialId)
  "email": "user@example.com",
  "email_verified": true,
  "is_private_email": false,
  "nonce": "...",
  "exp": 1234567890,
  "iat": 1234567890
}

 


7️⃣ 사용자 정보 처리

// CustomOidcUserService.loadUser() 실행

@Service
@RequiredArgsConstructor
public class CustomOidcUserService extends OidcUserService {

    private final UserRepository userRepository;

    @Override
    @Transactional
    public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
        OidcUser oidcUser = super.loadUser(userRequest);

        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        SocialType socialType = SocialType.fromRegistrationId(registrationId);

        String socialId = oidcUser.getAttribute("sub");      // "001234.abc..."
        String email = oidcUser.getAttribute("email");       // "user@example.com"
        String profileImage = oidcUser.getAttribute("picture"); // Apple은 항상 null

        User user = getOrCreateUser(socialType, socialId, email, profileImage);

        return UserPrincipal.buildUserPrincipal(user);
    }
}

 


8️⃣ DB 조회/생성

// CustomOidcUserService.getOrCreateUser()

private User getOrCreateUser(SocialType socialType, String socialId, 
                              String email, String profileImage) {
    return userRepository.findBySocialTypeAndSocialId(socialType, socialId)
        .map(user -> {
            // 탈퇴 사용자 재가입 처리
            if (user.isWithdrawn()) {
                user.reactivate(email, profileImage);
                return user;
            }
            return user;
        })
        .orElseGet(() -> {
            // 신규 사용자 생성
            User newUser = User.createSocialUser(socialType, socialId, email, profileImage);
            return userRepository.save(newUser);
        });
}

User 엔티티 생성

public static User createSocialUser(SocialType socialType, String socialId, 
                                     String email, String profileImage) {
    return User.builder()
        .socialType(socialType)
        .socialId(socialId)
        .email(email)
        .profileImage(profileImage)
        .role(Role.USER)
        .status(UserStatus.PENDING)  // 온보딩 전 상태
        .build();
}

 


9️⃣ JWT 토큰 발급

// OAuth2AuthenticationSuccessHandler.onAuthenticationSuccess() 실행

@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                    Authentication authentication) throws IOException {
    UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();

    // JWT 토큰 생성
    JwtDTO tokens = jwtUtil.generateTokens(userPrincipal.getId(), userPrincipal.getRole());
    // - accessToken: 15분 (900000ms)
    // - refreshToken: 14일 (1209600000ms)

    // RefreshToken 저장 및 쿠키 설정
    long expiration = jwtProperties.getRefreshTokenExpiration();
    refreshTokenService.saveRefreshToken(userId, tokens.refreshToken(), expiration);
    CookieUtil.addRefreshTokenCookie(response, domain, tokens.refreshToken(), expiration);
}

 


🔟 프론트엔드로 리다이렉트

// jwt.redirect-uri 사용 (환경변수로 설정 가능)
// 기본값: http://localhost:5173/auth/callback?registered=%s&token=%s&email=%s

boolean isRegistered = userPrincipal.getStatus() == UserStatus.ACTIVE;
String email = userPrincipal.getEmail() != null ?
    UriUtils.encode(userPrincipal.getEmail(), StandardCharsets.UTF_8) : "";

String targetUrl = String.format(jwtProperties.getRedirectUri(),
    isRegistered,           // 온보딩 완료 여부
    tokens.accessToken(),   // Access Token
    email                   // 이메일 (URL 인코딩)
);

getRedirectStrategy().sendRedirect(request, response, targetUrl);

 


1️⃣1️⃣ 로그인 완료

서버 → 프론트엔드: 302 Redirect
프론트엔드: accessToken 저장, refreshToken은 HttpOnly 쿠키에 자동 저장됨
사용자: 로그인 완료! 🎉

 


📋 application.yml 설정

spring:
  security:
    oauth2:
      client:
        registration:
          apple:
            client-id: ${APPLE_CLIENT_ID}
            client-secret: dummy-will-be-replaced-at-runtime  # 동적 생성됨
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
            client-authentication-method: client_secret_post
            authorization-grant-type: authorization_code
            scope:
              - openid
              - email
            client-name: Apple
        provider:
          apple:
            authorization-uri: https://appleid.apple.com/auth/authorize?response_mode=form_post
            token-uri: https://appleid.apple.com/auth/token
            jwk-set-uri: https://appleid.apple.com/auth/keys
            user-name-attribute: sub

jwt:
  secret: ${JWT_SECRET}
  access-token-expiration: 900000      # 15분 (밀리초)
  refresh-token-expiration: 1209600000  # 14일 (밀리초)
  redirect-uri: ${JWT_REDIRECT_URI:http://localhost:5173/auth/callback?registered=%s&token=%s&email=%s}
  login-failure-redirect-uri: ${JWT_LOGIN_FAILURE_REDIRECT_URI:http://localhost:5173/login?error=true}

apple:
  team-id: ${APPLE_TEAM_ID}
  key-id: ${APPLE_KEY_ID}
  private-key-path: ${APPLE_PRIVATE_KEY_PATH:keys/AppleAuthKey.p8}

 


🔑 핵심 포인트 요약

구분 설명
client-secret 동적 생성 Apple은 고정된 secret이 아닌 private key로 서명한 JWT 사용
OIDC 사용 id_token으로 사용자 정보를 안전하게 전달
STATELESS OAuth2 HttpCookieOAuth2AuthorizationRequestRepository로 쿠키 기반 state 관리
탈퇴 사용자 처리 user.reactivate()로 재가입 지원
Spring Security 자동 처리 대부분의 검증과 플로우는 프레임워크가 처리

 


🛠 커스터마이징한 컴포넌트

컴포넌트 역할
AppleOAuth2Config client-secret 동적 생성 로직
AppleClientSecretGenerator ES256 JWT 생성
CustomOidcUserService 사용자 DB 저장/조회
OAuth2AuthenticationSuccessHandler JWT 발급 및 리다이렉트
OAuth2AuthenticationFailureHandler 로그인 실패 시 에러 리다이렉트
HttpCookieOAuth2AuthorizationRequestRepository STATELESS 환경 OAuth2 state 관리