-
[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
내가 일반적으로 만들고 사용하는 단순한 JWT의 형태는 아니었지만 이전에 JWS 관련 공부한 내용이 어렴풋이 기억이 났다.
내용도 완전하게 기억이 남진 않고 당시에 이해도 완벽하진 않았지만 그래도 어디서 본건 있어서 그런가 바로 JWS 형태라는건 알았다.
공개키 획득
우리가 가지고 있는 정보는 Access Token이다. 이 액세스 토큰의 헤더에는 시그니처 알고리즘과 kid와 같은 JWS 헤더 정보를 담고 있다.
일반적으로 우리가 아는 JWT에서 비밀키를 통한 인가 정보가 토큰으로써 표현되는 것이다.
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 유효성을 검증할 수 있다.
클라이언트 비밀 값 생성
애플 인가로 넘어온 액세스 토큰을 갱신하려면 결국 토큰을 재 요청해야 한다.
애플 인가 코드는 일회성 코드로 요청마다 새롭게 발급해야하고 액세스 토큰이 만료되면 번거로울 것이다.
따라서 액세스 토큰 갱신 시점에 클라이언트 비밀 값(클라이언트 시크릿)을 생성하고 이를 바탕으로 액세스 토큰을 갱신할 수 있다.
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를 검증하는 경우 두 가지를 테스트로 수행했다.
참고자료
'스터디 & 프로젝트 > Mineme 프로젝트' 카테고리의 다른 글
[Spring Boot] 서비스 레이어 리팩터링하기 (0) 2023.03.15 네이밍 컨벤션 정하기 (0) 2023.02.10 [Spring boot] 응답 값 직렬화. (0) 2022.12.13