ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring Boot] Apple OAuth 적용하기
    스터디 & 프로젝트/Mineme 프로젝트 2023. 2. 26. 16:00

    지금까지 Github, Google, 카카오에서 제공하는 OAuth API를 사용했었다.

    하지만 별도로 진행하는 사이드 프로젝트에서 Apple 인가를 사용하기로 결정났고 내 파트로 정해졌다.

     

    다른 API 벤더에 비해서 더 많은 로직을 요구하긴 한다. 하지만 그래도 천천히 따라해보면 크게 어렵진 않다.

     

    우선 iOS 앱에서 최초 인가 이후 Access Token과 Authorization Code(인가 코드)가 함께 API로 넘어온다.

    우리가 아는 일반적인 리디렉션과 콜백을 통한 OAuth 프로토콜 동작 플로우는 여기까지는 동일하다.

     


    공개키를 통한 검증

    API 요청으로 받은 액세스 토큰과 인가 코드를 통해서 액세스 토큰의 유효성을 검증해야 한다.

    보통 내가 확인할 때에는 다른 벤더의 경우 OAuth API를 활용할 클라이언트 ID와 클라이언트 시크릿을 함께 제공한다.

     

    하지만 애플의 경우 비밀키 파일(p8)을 개발자 페이지에서 클라이언트 생성 시 제공하고 이를 바탕으로 클라이언트 시크릿을 매 요청마다 생성해야 한다.

     

    JWS

     

    [OAuth 2.0 API 보안] JWS를 이용한 메시지 수준 보안

    0. JSON 웹 서명을 이용한 메시지 수준 보안 JSON : JavaScript Object Notation 1. JWT의 이해 JWT(JSON Web Token)은 데이터를 전송하기 위한 컨테이너를 정의한다. OIDC가 이 JWT를 사용해서 ID 토큰을 나타낸다. eyJh

    www.floodnut.com

    내가 일반적으로 만들고 사용하는 단순한 JWT의 형태는 아니었지만 이전에 JWS 관련 공부한 내용이 어렴풋이 기억이 났다.

    내용도 완전하게 기억이 남진 않고 당시에 이해도 완벽하진 않았지만 그래도 어디서 본건 있어서 그런가 바로 JWS 형태라는건 알았다.

     

    공개키 획득

     

    Fetch Apple’s public key for verifying token signature | Apple Developer Documentation

    Fetch Apple’s public key to verify the ID token signature.

    developer.apple.com

    우리가 가지고 있는 정보는 Access Token이다. 이 액세스 토큰의 헤더에는 시그니처 알고리즘과 kid와 같은 JWS 헤더 정보를 담고 있다.

    일반적으로 우리가 아는 JWT에서 비밀키를 통한 인가 정보가 토큰으로써 표현되는 것이다.

     

    https://appleid.apple.com/auth/keys

    API 문서에서 처럼 GET 요청을 보내면 위 사진과 같은 공개키 정보를 알 수 있다.

    우리가 가지고 있는 액세스 토큰의 JWS 헤더에서 시그니처 알고리즘(alg)와 kid과 동일한 키를 사용하면 된다.

    /** Apple get Public Key. **/
    public static Mono<Apple.Keys> getPublicKeys() {
        return HttpClientUtil.getClient(APPLE_ID_BASE_API)
            .get()
            .uri("/auth/keys")
            .header(HttpHeaders.CONTENT_TYPE, CONTENT_TYPE_FORM_URL_ENCODED)
            .retrieve()
            .bodyToMono(Apple.Keys.class);
    }

    위와 같이 HTTP GET 요청을 보낼 Mono 클라이언트를 생성했다.

     

    public static Map<String, String> getHeader(String token) {
    		try {
    			String[] chunks = token.split("\\.");
    			Base64.Decoder decoder = Base64.getUrlDecoder();
    			String header = new String(decoder.decode(chunks[0]));
    
    			ObjectMapper objMapper = new ObjectMapper();
    
    			return objMapper.readValue(header, new TypeReference<Map<String, String>>() {
    			});
    		} catch (JsonProcessingException e) {
    			throw new CustomException(ErrorCode.INVALID_TOKEN);
    		}
    	}

     

        /* 공개키 요청 */
        List<Apple.Key> keys = AuthClientUtil.getPublicKeys().block().getKeys();
    
        /* 공개 키 확인 */
        Map<String, String> header = JwtUtil.getHeader(dto.getAccessToken());
    
        Apple.Key validKey = getValidKey(keys, header.get("kid"), header.get("alg")).orElseThrow(
            () -> new CustomException(ErrorCode.INVALID_TOKEN));

     

    그리고 액세스 토큰 헤더를 Map 객체로 반환하고 이를 통해서 APPLE API로 요청한 공개키와 동일한 kid를 가진 키를 찾는다.

     

    public static PublicKey getDecodedKey(Apple.Key validKey) throws InvalidKeySpecException, NoSuchAlgorithmException {
    
        byte[] nBytes = Base64.getUrlDecoder().decode(validKey.getN());
        byte[] eBytes = Base64.getUrlDecoder().decode(validKey.getE());
    
        BigInteger n = new BigInteger(1, nBytes);
        BigInteger e = new BigInteger(1, eBytes);
    
        RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(n, e);
        KeyFactory keyFactory = KeyFactory.getInstance(validKey.getKty());
        return keyFactory.generatePublic(publicKeySpec);
    }

    해당 키는 Base64로 인코딩 되어있으니 이를 디코딩하여 공개키로 사용할 수 있다.

     

    /* 공개 키를 통한 Identity Token 검증 */
    if (!jwtTokenProvider.validate(dto.getAccessToken(), key))
        throw new CustomException(ErrorCode.INVALID_TOKEN);

    이렇게 가져온 키로 액세스토큰의 JWS 유효성을 검증할 수 있다.

     


    클라이언트 비밀 값 생성

     

    Generate and validate tokens | Apple Developer Documentation

    Validate an authorization grant code delivered to your app to obtain tokens, or validate an existing refresh token.

    developer.apple.com

    애플 인가로 넘어온 액세스 토큰을 갱신하려면 결국 토큰을 재 요청해야 한다.

    애플 인가 코드는 일회성 코드로 요청마다 새롭게 발급해야하고 액세스 토큰이 만료되면 번거로울 것이다.

     

    따라서 액세스 토큰 갱신 시점에 클라이언트 비밀 값(클라이언트 시크릿)을 생성하고 이를 바탕으로 액세스 토큰을 갱신할 수 있다.

     

    public static String getAppleClientSecret(String teamId, String clientId, String keyId, String keyPath) {
       try {
          return Jwts.builder()
             .setHeaderParam("kid", keyId)
             .setIssuer(teamId)
             .setSubject(clientId)
             .setIssuedAt(new Date(Calendar.getInstance().getTimeInMillis()))
             .setExpiration(new Date(Calendar.getInstance().getTimeInMillis() + EXPIRED_TIME))
             .setAudience("https://appleid.apple.com")
             .claim("bid", "__bid__")
             .signWith(SignatureAlgorithm.ES256, AuthUtil.getPrivateKey(keyPath)) //여기서 사용할 비밀키
             .compact();
            }
            //...
    }

     

    public static PrivateKey getPrivateKey(String keyPath) throws IOException {
        ClassPathResource resource = new ClassPathResource(keyPath);
        String privateKey = new String(Files.readAllBytes(Paths.get(resource.getURI())));
        Reader pemReader = new StringReader(privateKey);
        PEMParser pemParser = new PEMParser(pemReader);
        JcaPEMKeyConverter converter = new JcaPEMKeyConverter();
        PrivateKeyInfo object = (PrivateKeyInfo)pemParser.readObject();
        return converter.getPrivateKey(object);
    }

    우리가 앞서 제공받은 비밀키 파일(p8) 파일로 새롭게 JWS를 생성한다. 해당 값이 유효하다면 애플의 API 서버가 다음과 같은 토큰 정보를 반환할 것이다.

     

    {
      "access_token": "adg61...67Or9",
      "token_type": "Bearer",
      "expires_in": 3600,
      "refresh_token": "rca7...lABoQ",
      "id_token": "eyJra...96sZg"
    }

    위 토큰 정보 중 리프레시 토큰이 있고 최초 액세스 토큰 발급(갱신) 이후 재 갱신이 필요할 때 이 리프레시 토큰을 이용할 수 있다.

     


    액세스 토큰의 고유 정보

    발급, 검증, 갱신 과정에서 넘어오는 Identity Token에는 애플 사용자 정보로 등록된 고유 값이나 이메일 등이 포함될 수 있다.

    애플 인가를 통해서 사용자의 유일한 정보를 파악하려고 할때 토큰 클레임에서의 값을 확인하여 개발하고자 하는 앱의 등록 정보에 포함시키면 된다.

     


    테스트 코드의 작성

    보통 OAuth를 사용한다면 최초로 사용자 계정에 로그인해서 토큰이나 인가 코드를 넘겨받는 수동적인 과정이 포함된다.

    이런 상황에서 단위 테스트 코드를 어떻게 작성할 수 있을까 고민했다.

     

    결론부터 말하면 OAuth 플로우 자체를 테스트 코드를 통해서 검증할 방법을 마땅하게 찾지는 못했다.

    그 대신 액세스 토큰 자체를 생성하거나 검증하는 과정은 외부 API를 타지 않으므로 단위 테스트를 수행할 대상으로 잡았다.

     

    우선 생성된 액세스 토큰 자체는 외부에서 이미 비밀키를 통해서 서명되어 있다.

    그래서 비밀키로 생성된 JWS(액세스 토큰)을 임의의 공개키로 검증하는 테스트를 작성했다.

     

    잘못된 공개키로 JWS를 검증하는 경우, 정상적인 공개키로 JWS를 검증하는 경우 두 가지를 테스트로 수행했다.

     


    참고자료

     

    Spring API서버에서 Apple 인증(로그인 , 회원가입) 처리하기

    들어가며 사이드 프로젝트를 진행하던 도중 APP에서 Apple 로그인을 적용해야 했다. https://apps.apple.com/kr/app/%EA%B8%80%EC%9D%84%EB%8B%B4%EB%8B%A4/id1517289762 ‎글을담다 ‎마음 속 와 닿은 글을 손쉽게 담는, '

    hwannny.tistory.com

     

    댓글

Designed by Tistory.