본문 바로가기
TIL

애플 소셜 로그인 (with. Spring)

by wch_t 2024. 11. 5.

앱스토어에 등록하기 위해서, 반드시 구현을 해야 하는 애플 로그인.

공식 문서를 열심히 탐독하고 적용한 과정, 그리고 우여곡절한 경험을 글로 남기고자 한다.

 


1. 인증 과정

 

 

 

 


2. Apple 서버에 로그인 권한 요청

https://developer.apple.com/documentation/sign_in_with_apple/request_an_authorization_to_the_sign_in_with_apple_server

 

사용자가 '애플 로그인' 버튼을 클릭할 때 나타나는 로그인 페이지 url을 만드는 것부터 중요하다.

특히 scope와 response_mode에 따라서 프론트와 서버에서의 구현이 달라질 수 있다.

 

[Request]

https://appleid.apple.com/auth/authorize?
client_id=[CLIENT_ID]&redirect_uri=[REDIRECT_URL]&response_type=code id_token&
state=[STATE]&scope=[SCOPES]&response_mode=form_post

 

[Response]

사용자의 애플 로그인이 성공하면, Apple Serve로부터 다음과 같은 정보를 응답 받을 수 있다.

{
     "authorization": {
          "code": "[CODE]",
          "id_token": "[ID_TOKEN]",
          "state": "[STATE]"
     },
     "user": {
          "email": "[EMAIL]",
          "name": {
               "firstName": "[FIRST_NAME]",
               "lastName": "[LAST_NAME]"
          }
     }
}

 

 

1) scope

개발하고 있는 서비스에서 User의 "name, email" 정보 중 어느 한 값이라도 필요하다면, Query Parameter에 scope를 반드시 지정해줘야 한다.

 

참고로 애플은 사용자가 앱을 처음 인증할 때, 즉 회원가입할 때만 User 객체를 응답해주고 있다.

email은 로그인 할 때 발급되는 id_token에서도 'email' 클레임 필드로 조회가 가능하다.

단, 이것도 scope 미지정 시 조회가 불가능했다..

 

*however, the user’s email is provided in the identity token for all requests

공식문서에는 모든 요청에서 email을 제공받을 수 있다고 했지만, 테스트 당시에는 email 필드를 확인할 수 없었다..

개발 시에 한 번 테스트 해보는 것이 좋을 것 같다.

 

 

2) response_mode

 

위의 설명을 잘 읽어보면, 어떠한 scope라도 지정될 시 response_mode는 반드시 form_post 이어야 한다.

form_post는 HTTP POST 요청으로 application/x-www-form-urlencoded 타입으로 응답 결과를 redirect_URI로 반환한다.

 

 

 

 

이러할 경우 애플 서버는 자동으로 응답 form을 redirect_uri로 제출(submit)하는 스크립트를 포함하여, 백엔드 서버(Client)의 API를 호출한다. 따라서 백엔드 서버가 로그인 로직을 모두 수행하고 Json으로 응답을 하면 Html로 바로 응답되어, 프론트에서는 토큰을 활용할 수가 없게 된다.

 

해결 방법은 2가지가 있다.

- 백엔드 서버에서 응답 값을 query string에 담아서 redirect를 한다.

- 프론트에서 팝업 방식으로 구현해, 일반적인 OAuth 플로우처럼 프론트에서 애플 서버 응답을 받아 백엔드 API를 호출하는 방법이 있다.

   (본 프로젝트에서는 팝업 방식을 채택했다.)

 

 

* 코드 (로그인 페이지 url 반환)

public String getAppleLogin() {
    return "https://appleid.apple.com/auth/authorize"
            + "?client_id=" + APPLE_CLIENT_ID
            + "&redirect_uri=" + APPLE_REDIRECT_URL
            + "&response_type=code%20id_token&scope=name%20email&response_mode=form_post";
}

 

 


3. id_token 검증이 왜 필요할까?

https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/authenticating_users_with_sign_in_with_apple

 

id_token은 jwt 형태를 따른다. 참고로 OAuth 인증 과정에서 나오는 AccessToken, RefreshToken은 jwt 형식이 아니다

따라서 id_token의 헤더와 페이로드는 누구나 그 정보를 디코딩하여 확인하고 조작이 가능하다.

 

이를 위해 id_token 검증이 필요한데, jwt 인증을 할 때 자신의 비밀키로 암호화 복호화하는 것처럼

애플 서버도 애플 서버만을 가지고 비밀키로 서명을 하고 id_token을 생성한다.

 

그리고 이를 복호화 할 수 있는 RSA 암호화 방식의 공개키를 제공한다.

즉, 공개키를 발급받아 복호화가 가능한지 확인해 응답 받은 id_token이 유효한 토큰인지 확인해야 한다.

 

 

cf. 회원탈퇴, 애플 access/refresh token 발급에서 필요한 client_secret 또한 jwt이다.

service private key로 암호화하고, 애플 서버의 public key로 복호화하여 검증한다.

 

 


4. id_token 검증

 

위에서 설명했듯이, id_token을 검증하기 위해서 애플의 공개키가 필요하다.

 

구현은 우선 Feign Client를 사용해 public key List를 응답으로 받는다. 그 중 id_token의 헤더에 있는 kid, alg 과 동일한 public key를 사용해 RSA public key를 생성 및 id_token에 서명하여 검증하면 된다.

 

 

[Request]

Fetch Apple’s public key for verifying token signature

 

[Response]

JWKSet.Keys

 

* 코드 (서명 검증한 id_token Claim 반환)

@FeignClient(name = "appleAuthFeignClient", url = "https://appleid.apple.com/auth")
public interface AppleAuthClient {

    @GetMapping(value = "/keys")
    ApplePublicKey getPublicKey();
    
}
@Getter
public static class ApplePublicKey {
    // The endpoint can return multiple keys, and the count of keys can vary over time.
    private List<Key> keys;

    @Getter
    public static class Key {
        private String kty;
        private String kid;
        private String use;
        private String alg;
        private String n;
        private String e;
    }

    public Optional<Key> getMatchedKeyBy(String kid, String alg) {
        return this.keys.stream()
                .filter(key -> key.getKid().equals(kid) && key.getAlg().equals(alg))
                .findFirst();
    }
}
private Claims verifyIdToken(String id_token) {
    try {
        // id_token header
        String headerOfIdentityToken = id_token.substring(0, id_token.indexOf("."));
        Map<String, String> header = new ObjectMapper().readValue(
                new String(Base64.getDecoder().decode(headerOfIdentityToken), "UTF-8"), Map.class);

        // apple public key
        ApplePublicKey publicKey = appleAuthClient.getPublicKey();

        // id_token header kid, alg 값과 매칭되는 apple public key 조회
        ApplePublicKey.Key key = publicKey.getMatchedKeyBy(header.get("kid"), header.get("alg"))
                .orElseThrow(() -> new CustomException(AppleCustomError.APPLE_INVALID_SERVER_PUBLIC_KEY));

        // 매칭 public key로 RSA public key 생성
        byte[] nBytes = Base64.getUrlDecoder().decode(key.getN());
        byte[] eBytes = Base64.getUrlDecoder().decode(key.getE());

        BigInteger n = new BigInteger(1, nBytes);
        BigInteger e = new BigInteger(1, eBytes);

        RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(n, e);
        KeyFactory keyFactory = KeyFactory.getInstance(key.getKty());
        PublicKey rsaPublicKey = keyFactory.generatePublic(publicKeySpec);

        return Jwts.parser().setSigningKey(rsaPublicKey).parseClaimsJws(id_token).getBody();
    } catch (Exception e) {
        throw new CustomException(AppleCustomError.APPLE_INVALID_ID_TOKEN);
    }
}

 

 

검증된 id_token Claims에서 다음과 같이 사용자 고유 account_id와 email 정보를 조회할 수 있다.

AppleUserInfo appleUserInfo = AppleUserInfo.builder()
        .subject(claims.getSubject())
        .email(claims.get("email", String.class))
        .build();

 

 


5. 애플 Access Token  & Refresh Token 발급

사실 서비스에서 자체적으로 생성한 세션 ID나 Jwt를 사용해서 로그인을 유지한다면, 이 과정은 필요가 없다.

현재 프로젝트 또한 자체 생성한 Jwt를 사용해 로그인을 유지시키고 있다.

 

하지만 만약 애플의 token을 사용해 세션을 유지한다면 다른 OAuth와 다르게 refresh_token을 사용할 수 밖에 없다.

access_token의 만료 기간이 10분밖에 되지 않기 때문에, 만료 이후에 사용자가 계속 로그인을 시도를 해야 한다. 반면 refresh_token은 유효기간이 없다.

 

 

https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/verifying_a_user

 

그런데 한 가지 걸리는 점이 공식문서를 확인해보면, refresh_token의 검증을 하루에 한 번으로 제한을 걸고 있다.. 물론 페이지 이동 단계마다 refresh_token을 검증 받는 것 또한 매우 이상한 방식이긴 하다. 그러면 값을 user DB에 저장해서 단순 문자열 비교를 해서 refresh_token을 검증받는 것인지, refresh_token Verify API는 언제 사용되는지 고민되는 문제이다..

(혹시 글을 보고, 생각되는 답이 있다면 알려주시면 감사하겠습니다🙏)

 


 

아! 그래서 어떻게 애플 Access Token이랑 Refresh Token을 발급받느냐??

'애플 소셜 로그인 연동 해지' 를 위해서 위 token 발급이 필수적으로 필요해, 해당 글에서 다루고자 한다.

 

 


 

5. 마주친 이슈

1) ngrok 사용

 

네이버, 카카오 로그인은 http://localhost를 사용할 수 있었지만

애플 로그인의 경우는 Return URLs(redirect url)을 반드시 `https://`를 사용해야 한다.

 

따라서 ngrok를 사용해 로컬 환경을 외부 인터넷에서 접근이 가능하도록 하고, 해당 도메인과 URL을 등록해 테스트를 진행했다.

// ngrok 계정 인증 토큰을 등록
ngrok config add-authtoken $YOUR_AUTHTOKEN

// 로컬 웹 서버에 대한 public URL 생성
ngrok http $PORT_NUMBER

 


 

2) 로그인 응답 처리 방식 (form_post)

위의 response_mode에서 언급했었지만, API 설계 단계에서 프론트와 가장 먼저 짚고 넘어가야 할 부분이기에 다시금 적었다.

 

- 팝업 방식

이 경우에는 정말 간단하게 json으로 요청을 받고, 응답을 하면 된다.

// https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/configuring_your_webpage_for_sign_in_with_apple

{
     "authorization": {
       "code": "[CODE]",
       "id_token": "[ID_TOKEN]",
       "state": "[STATE]"
     },
     "user": {
       "email": "[EMAIL]",
       "name": {
         "firstName": "[FIRST_NAME]",
         "lastName": "[LAST_NAME]"
       }
     }
}

 

 

- redirect 방식

`redirect` 방식은 form으로 요청을 받고, redirect로 응답을 한다.

 

스프링의 경우. 애플 서버가 form 으로 응답을 보내기 때문에, 아래와 같이 받으면 된다.

cf. 회원가입 시 받게 되는 User는 @ModelAttribute로 매핑이 되지 않아 String으로 받고, 값이 있을 경우 ObjectMapper로 조회를 해야 한다.

public String appleToken(
    @ModelAttribute AppleLoginRequest appleLoginRequest
) {
    // 다음과 같이 redirect 하면 된다.
    return "redirect:/token?accessToken=" + accessToken + "&refreshToken=" + refreshToken;
}


@Getter
@Setter
public static class AppleLoginRequest {
    public String code;
    public String id_token;
    public String state;
    public String user;
}

@Getter
public static class AppleUser {
    private Name name;
    private String email;

    @Getter
    public static class Name {
        private String firstName;
        private String lastName;
    }
}

 


 

3) 애플 code 문자열 split

테스트 디버깅을 여러 번 하는 과정에서, 애플 로그인 응답 authorization code에 값이 여러 개가 들어있는 경우가 있었다.

기본적으로 애플 code의 만료기간은 5분에 해당되는데, 아직 만료되지도 사용하지도 않은 코드가 있는데 재요청을 한 경우에 code 값이 여러 개가 들어가는 것으로 추측된다.

 

아래와 같이 첫 번째 code만 사용할 수 있도록 별도로 전처리를 해주었다.

// 첫 번째 code 사용
String validCode = code.split(",")[0];

 

 


참고문헌

애플 공식문서(포스팅 관련 내용에 첨부)

 

1) 애플 인증 플로우 정리
https://hwannny.tistory.com/71

 

2) Spring OAuth OIDC

https://devnm.tistory.com/35

 

3) How to solve `invalid_client` error in Sing in with Apple

https://fluffy.es/how-to-solve-invalid_client-error-in-sign-in-with-apple/

 

4) 추후 참고(OAuth 라이브러리 사용)

https://junhyunny.github.io/spring-boot/spring-security/spring-security-oauth2-client-for-apple/

https://gengminy.tistory.com/56