-
Rest API로 Apple 로그인을 구현프로젝트/WebRTC 화상통화 프로젝트 2023. 6. 6. 00:01
[1] Sign In with Apple REST API 문서 정리
프런트엔드에서 사용자의 Apple 로그인 수행 후 code 받아오기
GET https://appleid.apple.com/auth/authorize
위의 url에 client_id, redirect_uri, response_type 등을 담아 보내면 유저의 정보와 token을 받을 수 있습니다.
응답이 성공하면 다음과 같은 값을 받습니다.
- code
- id_token
- state
- user 정보
사용자는 Face ID 또는 Touch ID 또는 Apple ID password를 사용하여 로그인을 수행합니다.
이후 Apple ID Serviers는 ID 토큰, 인증 코드 및 사용자 식별자를 앱에 반환합니다.
요청 예시
GET https://appleid.apple.com/auth/authorize ?response_type=code &client_id={client_id} &redirect_uri={redirect_uri} &nonce={nonce}
실제 요청 예시
https://appleid.apple.com/auth/authorize ?client_id=com.your.package.name &redirect_uri=https://www.your.callback-url/login/callback/apple &response_type=code%20id_token &nonce=test &scope=name%20email &response_mode=form_post
FE에서 받은 Identity token 검증하기
절차는 다음과 같습니다.
- [1] JWS가 애플 Server의 공개키를 사용하여 E256으로 서명되었는지 확인한다.
- [2] nonce를 검증한다.
- [3] iss 필드에 https://appleid.apple.com 가 포함되었는지 확인한다.
- [4] aud 필드에 client_id가 포함되었는지 확인한다.
- [5] 토큰의 exp(만료일)이 만료되었는지 확인한다.
[1] 공개키 조회하는 REST API
실제로 조회해 보면 3개의 공개키가 조회됩니다.
{ "keys": [ { "kty": "RSA", "kid": "fh6Bs8C", "use": "sig", "alg": "RS256", "n": "u704gotMSZc6CSSVNCZ1d0S9dZKwO2BVzfdTKYz8wSNm7R_KIufOQf3ru7Pph1FjW6gQ8zgvhnv4IebkGWsZJlodduTC7c0sRb5PZpEyM6PtO8FPHowaracJJsK1f6_rSLstLdWbSDXeSq7vBvDu3Q31RaoV_0YlEzQwPsbCvD45oVy5Vo5oBePUm4cqi6T3cZ-10gr9QJCVwvx7KiQsttp0kUkHM94PlxbG_HAWlEZjvAlxfEDc-_xZQwC6fVjfazs3j1b2DZWsGmBRdx1snO75nM7hpyRRQB4jVejW9TuZDtPtsNadXTr9I5NjxPdIYMORj9XKEh44Z73yfv0gtw", "e": "AQAB" }, { "kty": "RSA", "kid": "W6WcOKB", "use": "sig", "alg": "RS256", "n": "2Zc5d0-zkZ5AKmtYTvxHc3vRc41YfbklflxG9SWsg5qXUxvfgpktGAcxXLFAd9Uglzow9ezvmTGce5d3DhAYKwHAEPT9hbaMDj7DfmEwuNO8UahfnBkBXsCoUaL3QITF5_DAPsZroTqs7tkQQZ7qPkQXCSu2aosgOJmaoKQgwcOdjD0D49ne2B_dkxBcNCcJT9pTSWJ8NfGycjWAQsvC8CGstH8oKwhC5raDcc2IGXMOQC7Qr75d6J5Q24CePHj_JD7zjbwYy9KNH8wyr829eO_G4OEUW50FAN6HKtvjhJIguMl_1BLZ93z2KJyxExiNTZBUBQbbgCNBfzTv7JrxMw", "e": "AQAB" }, { "kty": "RSA", "kid": "YuyXoY", "use": "sig", "alg": "RS256", "n": "1JiU4l3YCeT4o0gVmxGTEK1IXR-Ghdg5Bzka12tzmtdCxU00ChH66aV-4HRBjF1t95IsaeHeDFRgmF0lJbTDTqa6_VZo2hc0zTiUAsGLacN6slePvDcR1IMucQGtPP5tGhIbU-HKabsKOFdD4VQ5PCXifjpN9R-1qOR571BxCAl4u1kUUIePAAJcBcqGRFSI_I1j_jbN3gflK_8ZNmgnPrXA0kZXzj1I7ZHgekGbZoxmDrzYm2zmja1MsE5A_JX7itBYnlR41LOtvLRCNtw7K3EFlbfB6hkPL-Swk5XNGbWZdTROmaTNzJhV-lWT0gGm6V1qWAK2qOZoIDa_3Ud0Gw", "e": "AQAB" } ] }
해당 키 사이에서 Identity Token Header에 포함된 kid, alg가 일치하는 공개키를 사용하면 됩니다.
JWT header 값은 base64로 인코딩 되어 있기 때문에 디코딩하면 하여 kid, alg를 비교하는 로직을 작성합니다.
만약 다음과 같이 나온다면 public key 중에 세 번째 키를 사용하면 됩니다.
{ "kid": "YuyXoY", "alg": "RS256" }
암호화된 알고리즘은 RS256(SHA-256)을 사용하는 RSA(비대칭키 암호화방식)입니다.
RSA256 알고리즘에 대해 간단하게 소개하자면 공개 키(n, e), 개인 키(n, d)를 갖습니다.
이때 공개키의 n, e를 활용하여 public key를 생성하고 public key로 Identity Token의 서명을 검증하면 됩니다.
Auth0 라이브러리를 활용하면 쉽게 접근할 수 있습니다.
dependencies { implementation("com.auth0:jwks-rsa:0.21.2") implementation("com.auth0:java-jwt:4.2.1") }
AppleTokenVerifier
@Component class AppleTokenVerifier { val jwkProvider: JwkProvider = JwkProviderBuilder(APPLE_PUBLIC_KEYS_DOMAIN) .build() fun verify(identityToken: String) { //Jwt 토큰 Decode val decodeJWT = getDecodeJwtOrThrow(identityToken) //공개키로 서명되었는지 검사 verifyPublicKey(decodeJWT) //nonce 검사 verifyNonce(decodeJWT.getClaim("nonce").asString()) //issuer 검사 verifyIssuer(decodeJWT.issuer) //audience 검사 verifyAudience(decodeJWT.audience) //만료일 검사 verifyExpiration(decodeJWT.expiresAt) } private fun getDecodeJwtOrThrow(identityToken: String): DecodedJWT { try { return JWT.decode(identityToken) } catch (e: JWTDecodeException) { throw JWTDecodeException("Failed to decode token - $identityToken - ${e.message}") } } private fun verifyPublicKey(decodedJWT: DecodedJWT) { var algorithm: Algorithm? = null val keyId = decodedJWT.keyId try { algorithm = Algorithm.RSA256((jwkProvider.get(keyId).publicKey) as RSAPublicKey) } catch (e: JWKException) { throw JWKException("애플 header kid 값이 잘못 들어옴 - $keyId - ${e.message}") } val jwtVerifier = JWT.require(algorithm) .withIssuer(ISSUER) .build() try { jwtVerifier.verify(decodedJWT) } catch (e: JWTVerificationException) { throw JWTVerificationException("유효한 token이 아닙니다 - ${decodedJWT.claims["email"]} - ${e.message}") } } private fun verifyNonce(nonce: String) { if (NONCE != nonce) { throw SocialTokenException("Nonce does not match") } } private fun verifyIssuer(issuer: String) { if (issuer != ISSUER) { throw SocialTokenException("Invalid issuer") } } private fun verifyAudience(audience: List<String>) { if (audience.firstOrNull() != CLIENT_ID) { throw SocialTokenException("Invalid audience") } } private fun verifyExpiration(expiresAt: Date) { if (expiresAt.before(Date())) { throw SocialTokenException("Token has expired") } } companion object { const val APPLE_PUBLIC_KEYS_DOMAIN = "https://appleid.apple.com/auth/keys" const val NONCE = "test" const val ISSUER = "https://appleid.apple.com" const val CLIENT_ID = "com.your.package.name" } } class SocialTokenException(message: String) : RuntimeException(message) { }
client-id는 bundle id입니다.
bundle-id 예시) com.xxx.xxx
검증 이후 sub와 email 가져오기
decodeJWT.getClaim("sub").asString() decodeJWT.getClaim("email").asString()
Apple의 RefreshToken, AccessToken을 사용하지 않고 유저가 애플로그인이 가능하다는 사실만 확인하여 email과 sub만 얻고 싶다면 여기까지만 진행해도 됩니다.
ClientSecret 생성하기
Apple 문서에 따르면 만들어진 ClientSecret Jwt를 decode 하면 다음과 같아야 합니다.
{ "alg": "ES256", "kid": "ABC123DEFG" } { "iss": "DEF123GHIJ", "iat": 1437179036, "exp": 1493298100, "aud": "https://appleid.apple.com", "sub": "com.mytest.app" }
- 알고리즘(alg)은 ES256을 사용한다.
- Key ID(kid)는 Apple Developer 페이지에 명시되어 있는 KeyID
- Issuer(iss)는 Apple Developer 페이지에 명시되어있는 Team ID (10-character)
- IssueTime(iat)는 client secret이 생성된 일시를 입력한다.
- expirationTime(exp)는 client secret이 만료될 일시를 입력한다 (단, 현재시간으로 부터 15777000초, 즉 6개월을 초과하면 안 된다.)
- Audience(aud)는 "https://appleid.apple.com" 값을 입력.
- Subject(sub)는 App의 Bundle ID 값을 입력. ex) com.xxx.xxx와 같은 형식
gradle Import
//jwt implementation("io.jsonwebtoken:jjwt-api:0.11.2") runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.2") runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.2") // https://mvnrepository.com/artifact/org.bouncycastle/bcprov-jdk15on // Apple 로그인시 개인키를 파싱을 위한 라이브러리 implementation("org.bouncycastle:bcpkix-jdk15on:1.70")
Code
import io.jsonwebtoken.Jwts import io.jsonwebtoken.SignatureAlgorithm import org.bouncycastle.asn1.pkcs.PrivateKeyInfo import org.bouncycastle.openssl.PEMParser import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter import org.springframework.core.io.ClassPathResource import java.io.IOException import java.io.Reader import java.io.StringReader import java.nio.file.Files import java.nio.file.Paths import java.security.PrivateKey import java.time.LocalDateTime import java.time.ZoneId import java.util.* /** * client_secret 생성 * Apple Document URL ‣ https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens * * @return client_secret(jwt) */ object AppleSecretKeyGenerator { const val KEY_ID = "key-id" const val TEAM_ID = "team-id" const val SUB = "com.xxx.xxx" const val AUD = "https://appleid.apple.com" @Throws(IOException::class) fun generateSecretKey(): String { val expirationDate = Date.from(LocalDateTime.now().plusDays(30).atZone(ZoneId.systemDefault()).toInstant()) return Jwts.builder() .setHeaderParam("kid", KEY_ID) .setHeaderParam("alg", "ES256") .setIssuer(TEAM_ID) .setIssuedAt(Date(System.currentTimeMillis())) .setExpiration(expirationDate) .setAudience(AUD) .setSubject(SUB) .signWith(getPrivateKey(), SignatureAlgorithm.ES256) .compact() } @Throws(IOException::class) private fun getPrivateKey(): PrivateKey { val resource = ClassPathResource("static/apple/AuthKey_your-private-key-name.p8") val privateKey = String(Files.readAllBytes(Paths.get(resource.uri))) val pemReader: Reader = StringReader(privateKey) val pemParser = PEMParser(pemReader) val converter = JcaPEMKeyConverter() val objectValue = pemParser.readObject() as PrivateKeyInfo return converter.getPrivateKey(objectValue) } }
보통 소셜 로그인 시 Client-Secret을 제공해 주지만 Apple은 직접 만들어내야 합니다.
해당 Client-Secret은 FE에서 전달받은 Code를 AccessToken으로 교환하거나 Id Token을 받기 위해 사용합니다.
private key는 Apple Develop 페이지에서 다운로드한 확장자가 .p8인 파일입니다.
FE에서 받은 code AccessToken으로 교환
curl -v POST "https://appleid.apple.com/auth/token" \ -H 'content-type: application/x-www-form-urlencoded' \ -d 'client_id=CLIENT_ID' \ -d 'client_secret=CLIENT_SECRET' \ -d 'code=CODE' \ -d 'grant_type=authorization_code' \ -d 'redirect_uri=REDIRECT_URI'
참고자료
https://hwannny.tistory.com/71
https://whitepaek.tistory.com/61
'프로젝트 > WebRTC 화상통화 프로젝트' 카테고리의 다른 글
Sign In with Apple REST API 문서 정리 (1) 2023.05.16 JPA 동시성 문제 해결하기 (트랜잭션과 락) (0) 2022.08.09 nginx에 SSL 인증서 적용하기 (0) 2022.08.08 Jenkins로 Gitlab CI/CD 구축하기(Spring + MySQL + JenKins + Redis + Nginx) (0) 2022.08.06 letsencrypt 인증서 발급하고 OpenVidu에 적용하기 (0) 2022.08.05