Apple OAuth 콜백 CORS 차단
목차
Apple OAuth를 처음 붙이던 날, 구글이랑 카카오는 30분도 안 걸렸는데 Apple만 반나절을 날렸다. 결국 세 군데를 동시에 고쳐야 했고, 각각의 버그가 서로 다른 HTTP 상태 코드로 나타나서 처음엔 원인 파악 자체가 헷갈렸다.
Apple OAuth가 다른 이유
OAuth 2.0 스펙상 authorization code를 콜백으로 전달하는 방법은 크게 두 가지다. 대부분의 provider는 response_mode=query를 기본으로 써서 GET /callback?code=... 형태로 redirect한다. Apple은 여기서 달리 간다. response_mode=form_post를 강제하는데, 이 경우 브라우저가 Apple 서버로부터 HTML form을 받아서 자동으로 POST 요청을 우리 서버에 날린다.
이게 왜 문제가 되느냐면, POST 요청은 브라우저의 CORS preflight 대상이 되는 조건을 더 쉽게 만족한다. Content-Type: application/x-www-form-urlencoded로 오기 때문에 단순 요청(simple request)처럼 보이지만, 실제로는 accounts.apple.com origin에서 우리 도메인으로 cross-origin POST가 날아오는 구조다. 서버 사이드에서 CORS 헤더를 제대로 안 열어주면 브라우저가 막아버린다.
거기다 Apple의 콜백은 Apple 서버 IP에서 직접 오는 게 아니라 사용자 브라우저가 리디렉션을 거쳐 POST를 날리는 방식이라, 요청 자체는 사용자 브라우저발이다. 그런데 User-Agent가 없거나 비표준인 경우 봇 차단 필터가 그 요청을 봇으로 판단해 403을 내뱉는다. 실제로 이게 처음 마주친 증상이었다. 콜백 URL 자체는 맞는데 403이 뜨니까 처음엔 인증 문제인 줄 알았다.
정리하면 세 개의 독립적인 버그가 연달아 숨어 있었다.
| 버그 | 증상 | 원인 | 해결책 |
|---|---|---|---|
| 봇 필터 차단 | 콜백 403 | BotBlockFilter가 Apple 콜백 UA를 봇으로 판단 | 콜백 URL 패턴 예외 추가 |
| CORS 차단 | 콜백 400 | WebMvcConfig에 Apple 도메인 미포함 | allowedOrigins에 accounts.apple.com 추가 |
| 세션 유실 | 로그인 실패 (리디렉션 루프) | POST 콜백 후 세션 컨텍스트 재설정 누락 | 콜백 핸들러에서 세션 명시 재설정 |
403을 잡고 나니 400이 나왔고, 400을 잡고 나니 로그인은 되는 것 같은데 세션이 없어서 로그인 페이지로 다시 튕겼다. 한 단계씩 까는 느낌이었다.
수정 흐름과 코드
수정 순서는 요청이 서버에 도달하는 순서대로 따라갔다.
BotBlockFilter → Apple 콜백 URL 예외 추가
↓
WebMvcConfig → CORS 허용 origin 추가
↓
OAuthController → 세션 재설정 로직 추가
BotBlockFilter는 URL 패턴 기준으로 예외를 처리했다. Apple 콜백 엔드포인트를 화이트리스트에 넣는 식이다. CORS 설정은 WebMvcConfigurer의 addCorsMappings에 Apple 콜백 경로와 origin을 추가했다.
세션 처리가 좀 손이 갔는데, form_post 방식은 기존 GET 기반 콜백과 달리 세션 컨텍스트가 POST 요청 처리 중에 초기화되는 타이밍 문제가 있었다. 그래서 OAuthController에서 콜백 처리 완료 후 명시적으로 세션에 로그인 정보를 다시 써줬다.
// Apple OAuth 콜백 처리 후 세션 재설정
session.setAttribute("loginUser", member);
session.setAttribute("sysId", member.getSysId());
다른 provider에서는 필요 없던 코드인데 Apple만 이게 필요했다. 뭔가 POST 처리 과정에서 Spring Security 세션 컨텍스트가 한 번 flush되는 것 같은데, 정확한 내부 동작까지는 더 파봐야 한다.
Apple의 최초 인증 데이터 문제
이번 작업에서 세션 버그보다 더 주의해야 했던 부분이 있다. Apple은 사용자 이름과 이메일을 최초 인증 때 딱 한 번만 넘겨준다. 사용자가 처음 "Apple로 로그인"을 승인할 때 id_token과 함께 user 파라미터로 이름/이메일이 온다. 그 이후 재인증부터는 user 파라미터가 아예 없다.
이게 Apple의 프라이버시 정책상 의도된 설계다. 앱이 사용자 정보를 과도하게 반복 수집하지 못하게 막는 거다. 개발자 입장에서는 첫 콜백 때 반드시 DB에 저장해야 한다는 의미다. 만약 그 타이밍을 놓치면 이후에 사용자 정보를 복구할 방법이 없고, 사용자가 앱 연동을 해제했다가 다시 연동해야 다시 받을 수 있다.
그래서 콜백 핸들러 안에서 user 파라미터 존재 여부를 체크하고, 있으면 저장, 없으면 기존 저장된 값 사용하는 분기를 넣었다.
// user 파라미터는 최초 인증 시에만 전달됨
String userJson = request.getParameter("user");
if (userJson != null && !userJson.isEmpty()) {
// 최초 인증: 이름/이메일 파싱 후 저장
AppleUser appleUser = objectMapper.readValue(userJson, AppleUser.class);
member.setName(appleUser.getName().getFullName());
member.setEmail(appleUser.getEmail());
memberService.save(member);
}
구글/카카오는 매번 인증마다 프로필 정보를 갱신할 수 있어서 이런 처리가 불필요한데, Apple은 이 분기를 빠뜨리면 첫 연동 후 이름이 null로 저장되는 문제가 생긴다.
배운 것
SNS 로그인은 provider마다 동작 방식이 달라서 "공통 모듈로 만들겠다"는 생각을 너무 이르게 하면 안 된다. 구현하다 보면 Apple처럼 콜백 방식부터 다른 경우가 있어서, 공통 인터페이스 뒤에 provider별 구현을 두되 각 provider 특수 케이스를 억지로 추상화하려 들면 오히려 코드가 복잡해진다.
그리고 OAuth 디버깅은 로컬에서 하기가 까다롭다. Apple은 특히 localhost 콜백을 지원 안 하기 때문에 ngrok 같은 걸 써서 실제 도메인처럼 우회해야 한다. 이 환경 차이 때문에 로컬 테스트와 실서버 동작이 다를 때가 있어서, 처음부터 스테이징 환경에서 붙이는 게 낫다는 걸 이번에 다시 한번 확인했다.
댓글 0
첫 댓글 달아줘.