diff --git a/src/main/kotlin/com/peakda/server/common/exception/ErrorCode.kt b/src/main/kotlin/com/peakda/server/common/exception/ErrorCode.kt index 9e267e1..90daeef 100644 --- a/src/main/kotlin/com/peakda/server/common/exception/ErrorCode.kt +++ b/src/main/kotlin/com/peakda/server/common/exception/ErrorCode.kt @@ -16,6 +16,7 @@ enum class ErrorCode( REFRESH_TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "리프레시 토큰이 유효하지 않습니다."), OAUTH2_PROVIDER_NOT_SUPPORTED(HttpStatus.BAD_REQUEST, "지원하지 않는 소셜 로그인입니다."), + APPLE_TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "Apple 인증 토큰이 유효하지 않습니다."), NICKNAME_DUPLICATED(HttpStatus.CONFLICT, "이미 사용 중인 닉네임입니다."), NICKNAME_INVALID(HttpStatus.BAD_REQUEST, "닉네임은 특수문자를 제외하고 2~10자로 입력해 주세요."), diff --git a/src/main/kotlin/com/peakda/server/common/security/SecurityConfig.kt b/src/main/kotlin/com/peakda/server/common/security/SecurityConfig.kt index fbb1f6c..fbe5818 100644 --- a/src/main/kotlin/com/peakda/server/common/security/SecurityConfig.kt +++ b/src/main/kotlin/com/peakda/server/common/security/SecurityConfig.kt @@ -37,7 +37,8 @@ class SecurityConfig( "/v3/api-docs.yaml", "/oauth2/**", "/login/oauth2/**", - "/api/auth/refresh" + "/api/auth/refresh", + "/api/auth/oauth/apple" ) } diff --git a/src/main/kotlin/com/peakda/server/domain/auth/application/AppleLoginService.kt b/src/main/kotlin/com/peakda/server/domain/auth/application/AppleLoginService.kt new file mode 100644 index 0000000..8877960 --- /dev/null +++ b/src/main/kotlin/com/peakda/server/domain/auth/application/AppleLoginService.kt @@ -0,0 +1,95 @@ +package com.peakda.server.domain.auth.application + +import com.peakda.server.common.security.cookie.CookieProperties +import com.peakda.server.common.security.cookie.CookieUtils +import com.peakda.server.common.security.jwt.JwtProperties +import com.peakda.server.common.security.jwt.JwtTokenGenerator +import com.peakda.server.common.security.principal.PrincipalDetails +import com.peakda.server.domain.auth.oauth.apple.AppleIdTokenVerifier +import com.peakda.server.domain.auth.oauth.model.AppleOAuth2UserInfo +import com.peakda.server.domain.auth.oauth.model.OAuth2LoginType +import com.peakda.server.domain.auth.presentation.response.AppleLoginResponse +import com.peakda.server.domain.auth.signup.application.SignupSessionService +import com.peakda.server.domain.user.entity.User +import com.peakda.server.domain.user.repository.UserRepository +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.LoggerFactory +import org.springframework.http.ResponseCookie +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +/** + * Apple 네이티브 로그인 처리. + * + * iOS 가 보낸 id_token 을 검증한 뒤 기존 회원이면 로그인 토큰을, 신규면 가입 세션 토큰을 쿠키로 발급한다. + * 쿠키 세팅 방식은 웹 리다이렉트 플로우의 OAuth2AuthenticationSuccessHandler 와 동일하다. + */ +@Service +class AppleLoginService( + private val appleIdTokenVerifier: AppleIdTokenVerifier, + private val userRepository: UserRepository, + private val signupSessionService: SignupSessionService, + private val jwtTokenGenerator: JwtTokenGenerator, + private val refreshTokenService: RefreshTokenService, + private val cookieProperties: CookieProperties, + private val jwtProperties: JwtProperties, +) { + + private val log = LoggerFactory.getLogger(this::class.java) + + @Transactional + fun login(identityToken: String, response: HttpServletResponse): AppleLoginResponse { + val claims = appleIdTokenVerifier.verify(identityToken) + val userInfo = AppleOAuth2UserInfo(sub = claims.sub, email = claims.email) + + val user = userRepository.findByProviderAndProviderId(OAuth2LoginType.APPLE, userInfo.getProviderId()) + return if (user != null) { + issueLoginCookies(user, response) + AppleLoginResponse(signupRequired = false) + } else { + issueSignupCookie(userInfo, response) + AppleLoginResponse(signupRequired = true) + } + } + + private fun issueLoginCookies(user: User, response: HttpServletResponse) { + val userId = requireNotNull(user.id) + log.info("Apple 로그인 성공. userId={}", userId) + + val tokenResponse = jwtTokenGenerator.generateToken( + userId = userId, + email = user.email, + authorities = listOf("${PrincipalDetails.ROLE_PREFIX}${user.role.name}"), + ) + refreshTokenService.saveRefreshToken(userId, tokenResponse.refreshToken) + + response.addCookie( + CookieUtils.createAccessTokenCookie( + token = tokenResponse.accessToken, + maxAge = jwtProperties.accessTokenValidityInSeconds, + properties = cookieProperties, + ), + ) + response.addCookie( + CookieUtils.createRefreshTokenCookie( + token = tokenResponse.refreshToken, + maxAge = jwtProperties.refreshTokenValidityInSeconds, + properties = cookieProperties, + ), + ) + response.addCookie(CookieUtils.deleteSignupTokenCookie(cookieProperties)) + } + + private fun issueSignupCookie(userInfo: AppleOAuth2UserInfo, response: HttpServletResponse) { + val signupSession = signupSessionService.createOrRefresh(OAuth2LoginType.APPLE, userInfo) + log.info("Apple 회원가입 필요. signupSessionId={}", signupSession.id) + + response.addCookie(CookieUtils.createSignupTokenCookie(signupSession.token, cookieProperties)) + response.addCookie(CookieUtils.deleteAccessTokenCookie(cookieProperties)) + response.addCookie(CookieUtils.deleteRefreshTokenCookie(cookieProperties)) + } + + private fun HttpServletResponse.addCookie(cookie: ResponseCookie) { + addHeader("Set-Cookie", cookie.toString()) + } +} diff --git a/src/main/kotlin/com/peakda/server/domain/auth/oauth/apple/AppleClaims.kt b/src/main/kotlin/com/peakda/server/domain/auth/oauth/apple/AppleClaims.kt new file mode 100644 index 0000000..a9dd296 --- /dev/null +++ b/src/main/kotlin/com/peakda/server/domain/auth/oauth/apple/AppleClaims.kt @@ -0,0 +1,7 @@ +package com.peakda.server.domain.auth.oauth.apple + +/** Apple id_token 검증 결과에서 추출한 사용자 식별 정보. */ +data class AppleClaims( + val sub: String, + val email: String?, +) diff --git a/src/main/kotlin/com/peakda/server/domain/auth/oauth/apple/AppleExceptions.kt b/src/main/kotlin/com/peakda/server/domain/auth/oauth/apple/AppleExceptions.kt new file mode 100644 index 0000000..e5905e9 --- /dev/null +++ b/src/main/kotlin/com/peakda/server/domain/auth/oauth/apple/AppleExceptions.kt @@ -0,0 +1,7 @@ +package com.peakda.server.domain.auth.oauth.apple + +import com.peakda.server.common.exception.BusinessException +import com.peakda.server.common.exception.ErrorCode + +/** Apple id_token 검증 실패 (서명·issuer·audience·만료 등). */ +class AppleTokenInvalidException : BusinessException(ErrorCode.APPLE_TOKEN_INVALID) diff --git a/src/main/kotlin/com/peakda/server/domain/auth/oauth/apple/AppleIdTokenVerifier.kt b/src/main/kotlin/com/peakda/server/domain/auth/oauth/apple/AppleIdTokenVerifier.kt new file mode 100644 index 0000000..394e09c --- /dev/null +++ b/src/main/kotlin/com/peakda/server/domain/auth/oauth/apple/AppleIdTokenVerifier.kt @@ -0,0 +1,59 @@ +package com.peakda.server.domain.auth.oauth.apple + +import io.jsonwebtoken.Claims +import io.jsonwebtoken.JwtException +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.LocatorAdapter +import io.jsonwebtoken.ProtectedHeader +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import java.security.Key + +/** + * Apple 네이티브 로그인의 id_token(JWT)을 검증한다. + * + * 1. 헤더의 `kid` 로 [ApplePublicKeyClient] 에서 서명 공개키를 찾아 서명을 검증한다. + * 2. `iss == https://appleid.apple.com`, `aud == app.apple.client-id`, 만료 여부를 검증한다. + * 3. `sub`(providerId)·`email` 을 추출해 [AppleClaims] 로 반환한다. + * + * 검증에 실패하면 [AppleTokenInvalidException] 을 던진다. + */ +@Component +class AppleIdTokenVerifier( + private val applePublicKeyClient: ApplePublicKeyClient, + private val appleProperties: AppleProperties, +) { + + private val log = LoggerFactory.getLogger(this::class.java) + + fun verify(identityToken: String): AppleClaims { + val claims = parseClaims(identityToken) + return AppleClaims( + sub = claims.subject ?: throw AppleTokenInvalidException(), + email = claims["email"] as? String, + ) + } + + private fun parseClaims(identityToken: String): Claims { + try { + return Jwts.parser() + .keyLocator(keyLocator) + .requireIssuer(AppleProperties.ISSUER) + .requireAudience(appleProperties.clientId) + .build() + .parseSignedClaims(identityToken) + .payload + } catch (e: JwtException) { + log.debug("Apple id_token 검증 실패: {}", e.message) + throw AppleTokenInvalidException() + } catch (e: IllegalArgumentException) { + log.debug("Apple id_token 검증 실패: {}", e.message) + throw AppleTokenInvalidException() + } + } + + private val keyLocator = object : LocatorAdapter() { + override fun locate(header: ProtectedHeader): Key? = + header.keyId?.let { applePublicKeyClient.findPublicKey(it) } + } +} diff --git a/src/main/kotlin/com/peakda/server/domain/auth/oauth/apple/AppleProperties.kt b/src/main/kotlin/com/peakda/server/domain/auth/oauth/apple/AppleProperties.kt new file mode 100644 index 0000000..004191c --- /dev/null +++ b/src/main/kotlin/com/peakda/server/domain/auth/oauth/apple/AppleProperties.kt @@ -0,0 +1,19 @@ +package com.peakda.server.domain.auth.oauth.apple + +import org.springframework.boot.context.properties.ConfigurationProperties + +/** + * Apple 네이티브 로그인(id_token 검증) 설정. + * + * [clientId] 는 id_token 의 `aud` 클레임과 일치해야 하는 Apple 앱의 Bundle ID(또는 Services ID)다. + * issuer / JWKS URL 은 Apple 고정값이므로 상수로 둔다. + */ +@ConfigurationProperties(prefix = "app.apple") +data class AppleProperties( + val clientId: String, +) { + companion object { + const val ISSUER = "https://appleid.apple.com" + const val JWKS_URL = "https://appleid.apple.com/auth/keys" + } +} diff --git a/src/main/kotlin/com/peakda/server/domain/auth/oauth/apple/ApplePublicKeyClient.kt b/src/main/kotlin/com/peakda/server/domain/auth/oauth/apple/ApplePublicKeyClient.kt new file mode 100644 index 0000000..4c88540 --- /dev/null +++ b/src/main/kotlin/com/peakda/server/domain/auth/oauth/apple/ApplePublicKeyClient.kt @@ -0,0 +1,53 @@ +package com.peakda.server.domain.auth.oauth.apple + +import io.jsonwebtoken.security.JwkSet +import io.jsonwebtoken.security.Jwks +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import org.springframework.web.client.RestClient +import java.security.Key +import java.util.concurrent.atomic.AtomicReference + +/** + * Apple 공개키(JWKS)를 조회하고 `kid` 로 서명 키를 해석한다. + * + * Apple 은 키를 주기적으로 회전하므로 결과를 캐시하되, 요청한 `kid` 가 캐시에 없으면 + * (= 키 회전 직후) 한 번 강제로 다시 조회한다. + */ +@Component +class ApplePublicKeyClient( + restClientBuilder: RestClient.Builder, +) { + + private val log = LoggerFactory.getLogger(this::class.java) + private val restClient: RestClient = restClientBuilder.build() + private val cachedKeys = AtomicReference>(emptyMap()) + + /** + * 주어진 [kid] 에 해당하는 Apple 공개키를 반환한다. 캐시 미스 시 JWKS 를 강제 갱신해 한 번 더 시도한다. + * 해당 키를 끝내 찾지 못하면 null 을 반환한다. + */ + fun findPublicKey(kid: String): Key? { + cachedKeys.get()[kid]?.let { return it } + return refresh()[kid] + } + + private fun refresh(): Map { + val keys = fetchKeys() + cachedKeys.set(keys) + return keys + } + + private fun fetchKeys(): Map { + val body = restClient.get() + .uri(AppleProperties.JWKS_URL) + .retrieve() + .body(String::class.java) + ?: throw IllegalStateException("Apple JWKS 응답이 비어 있습니다.") + + val jwkSet: JwkSet = Jwks.setParser().build().parse(body) + return jwkSet.getKeys().associate { jwk -> + requireNotNull(jwk.getId()) { "Apple JWK 에 kid 가 없습니다." } to jwk.toKey() + }.also { log.debug("Apple JWKS refreshed. kids={}", it.keys) } + } +} diff --git a/src/main/kotlin/com/peakda/server/domain/auth/oauth/model/AppleOAuth2UserInfo.kt b/src/main/kotlin/com/peakda/server/domain/auth/oauth/model/AppleOAuth2UserInfo.kt new file mode 100644 index 0000000..07a935d --- /dev/null +++ b/src/main/kotlin/com/peakda/server/domain/auth/oauth/model/AppleOAuth2UserInfo.kt @@ -0,0 +1,20 @@ +package com.peakda.server.domain.auth.oauth.model + +/** + * Apple id_token 검증 결과로부터 만든 사용자 정보. + * + * 다른 provider 와 달리 OAuth2 userinfo 응답 맵이 아니라 검증된 id_token 클레임의 + * `sub`(providerId)·`email` 로 구성한다. Apple 은 이름·프로필 이미지를 토큰에 담지 않으므로 + * [getProfileImageUrl] 은 항상 null 이다. + */ +class AppleOAuth2UserInfo( + private val sub: String, + private val email: String?, +) : OAuth2UserInfo(emptyMap()) { + + override fun getProviderId(): String = sub + + override fun getEmail(): String? = email + + override fun getProfileImageUrl(): String? = null +} diff --git a/src/main/kotlin/com/peakda/server/domain/auth/presentation/AuthController.kt b/src/main/kotlin/com/peakda/server/domain/auth/presentation/AuthController.kt index f87cbc1..f114f10 100644 --- a/src/main/kotlin/com/peakda/server/domain/auth/presentation/AuthController.kt +++ b/src/main/kotlin/com/peakda/server/domain/auth/presentation/AuthController.kt @@ -3,7 +3,10 @@ package com.peakda.server.domain.auth.presentation import com.peakda.server.common.response.ApiResponse import com.peakda.server.common.security.principal.PrincipalDetails import com.peakda.server.common.security.principal.SignupSessionPrincipal +import com.peakda.server.domain.auth.application.AppleLoginService import com.peakda.server.domain.auth.application.AuthService +import com.peakda.server.domain.auth.presentation.request.AppleLoginRequest +import com.peakda.server.domain.auth.presentation.response.AppleLoginResponse import com.peakda.server.domain.auth.presentation.response.UserInfoResponse import com.peakda.server.domain.auth.signup.presentation.request.SignupCompleteRequest import com.peakda.server.domain.auth.signup.presentation.response.NicknameCheckResponse @@ -20,8 +23,17 @@ import org.springframework.web.multipart.MultipartFile @RequestMapping("/api/auth") class AuthController( private val authService: AuthService, + private val appleLoginService: AppleLoginService, ) : AuthControllerDocs { + override fun appleLogin( + request: AppleLoginRequest, + response: HttpServletResponse, + ): ResponseEntity> { + val result = appleLoginService.login(request.identityToken, response) + return ResponseEntity.ok(ApiResponse.success(HttpStatus.OK, result)) + } + override fun getCurrentUser( principal: PrincipalDetails, ): ResponseEntity> { diff --git a/src/main/kotlin/com/peakda/server/domain/auth/presentation/AuthControllerDocs.kt b/src/main/kotlin/com/peakda/server/domain/auth/presentation/AuthControllerDocs.kt index a258abb..cfbd163 100644 --- a/src/main/kotlin/com/peakda/server/domain/auth/presentation/AuthControllerDocs.kt +++ b/src/main/kotlin/com/peakda/server/domain/auth/presentation/AuthControllerDocs.kt @@ -5,6 +5,8 @@ import com.peakda.server.common.openapi.ApiErrorResponses import com.peakda.server.common.response.ApiResponse import com.peakda.server.common.security.principal.PrincipalDetails import com.peakda.server.common.security.principal.SignupSessionPrincipal +import com.peakda.server.domain.auth.presentation.request.AppleLoginRequest +import com.peakda.server.domain.auth.presentation.response.AppleLoginResponse import com.peakda.server.domain.auth.presentation.response.UserInfoResponse import com.peakda.server.domain.auth.signup.presentation.request.SignupCompleteRequest import com.peakda.server.domain.auth.signup.presentation.response.NicknameCheckResponse @@ -33,6 +35,20 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody as SwaggerRequestBod @Tag(name = "Auth", description = "로그인, 회원가입, 토큰 관리 API") interface AuthControllerDocs { + @Operation( + summary = "Apple 네이티브 로그인", + description = "iOS Apple 로그인 SDK 가 발급한 identity token 을 검증한다. " + + "기존 회원이면 access-token·refresh-token 쿠키를 발급(signupRequired=false)하고, " + + "신규 사용자면 signup-token 쿠키를 발급(signupRequired=true)하여 회원가입 완료가 필요함을 알린다.", + ) + @ApiErrorResponses(ErrorCode.INVALID_REQUEST, ErrorCode.APPLE_TOKEN_INVALID) + @PostMapping("/oauth/apple") + fun appleLogin( + @Valid @RequestBody request: AppleLoginRequest, + @Parameter(hidden = true) + response: HttpServletResponse, + ): ResponseEntity> + @Operation( summary = "내 정보 조회", description = "access-token 쿠키로 현재 로그인한 사용자의 정보를 조회합니다.", diff --git a/src/main/kotlin/com/peakda/server/domain/auth/presentation/request/AppleLoginRequest.kt b/src/main/kotlin/com/peakda/server/domain/auth/presentation/request/AppleLoginRequest.kt new file mode 100644 index 0000000..c1e044f --- /dev/null +++ b/src/main/kotlin/com/peakda/server/domain/auth/presentation/request/AppleLoginRequest.kt @@ -0,0 +1,15 @@ +package com.peakda.server.domain.auth.presentation.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank + +@Schema(description = "Apple 네이티브 로그인 요청") +data class AppleLoginRequest( + @field:Schema( + description = "iOS Apple 로그인 SDK 가 발급한 identity token (JWT)", + example = "eyJraWQiOiJ...", + requiredMode = Schema.RequiredMode.REQUIRED, + ) + @field:NotBlank + val identityToken: String, +) diff --git a/src/main/kotlin/com/peakda/server/domain/auth/presentation/response/AppleLoginResponse.kt b/src/main/kotlin/com/peakda/server/domain/auth/presentation/response/AppleLoginResponse.kt new file mode 100644 index 0000000..12554c1 --- /dev/null +++ b/src/main/kotlin/com/peakda/server/domain/auth/presentation/response/AppleLoginResponse.kt @@ -0,0 +1,14 @@ +package com.peakda.server.domain.auth.presentation.response + +import io.swagger.v3.oas.annotations.media.Schema + +@Schema(description = "Apple 로그인 결과") +data class AppleLoginResponse( + @field:Schema( + description = "추가 회원가입이 필요한지 여부. " + + "true 면 signup-token 쿠키가 발급되어 회원가입 완료(/api/auth/signup/complete)가 필요하고, " + + "false 면 access-token·refresh-token 쿠키가 발급되어 로그인이 완료된 상태다.", + example = "false", + ) + val signupRequired: Boolean, +) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4b3a52c..73096ed 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -70,6 +70,8 @@ app: allowed-origins: ${CORS_ALLOWED_ORIGINS} oauth2: redirect-uri: ${OAUTH2_REDIRECT_URI} + apple: + client-id: ${APPLE_CLIENT_ID} openapi: servers: local: http://localhost:8080 diff --git a/src/test/kotlin/com/peakda/server/domain/auth/oauth/apple/AppleIdTokenVerifierTest.kt b/src/test/kotlin/com/peakda/server/domain/auth/oauth/apple/AppleIdTokenVerifierTest.kt new file mode 100644 index 0000000..4650067 --- /dev/null +++ b/src/test/kotlin/com/peakda/server/domain/auth/oauth/apple/AppleIdTokenVerifierTest.kt @@ -0,0 +1,135 @@ +package com.peakda.server.domain.auth.oauth.apple + +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.security.Jwks +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +import org.junit.jupiter.api.Test +import org.springframework.http.MediaType +import org.springframework.test.web.client.ExpectedCount +import org.springframework.test.web.client.MockRestServiceServer +import org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo +import org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess +import org.springframework.web.client.RestClient +import java.security.KeyPair +import java.security.KeyPairGenerator +import java.security.interfaces.RSAPrivateKey +import java.security.interfaces.RSAPublicKey +import java.time.Instant +import java.util.Date + +class AppleIdTokenVerifierTest { + + private val clientId = "com.peakda.app" + private val keyId = "test-key-id" + private val keyPair: KeyPair = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair() + + /** Apple JWKS 엔드포인트가 [keyPair] 의 공개키를 [keyId] 로 내려주도록 응답을 세팅한다. */ + private fun verifierWithServer(): Pair { + val builder = RestClient.builder() + val server = MockRestServiceServer.bindTo(builder).build() + server.expect(ExpectedCount.manyTimes(), requestTo(AppleProperties.JWKS_URL)) + .andRespond(withSuccess(jwksJson(), MediaType.APPLICATION_JSON)) + + val publicKeyClient = ApplePublicKeyClient(builder) + val verifier = AppleIdTokenVerifier(publicKeyClient, AppleProperties(clientId)) + return verifier to server + } + + private fun jwksJson(): String { + val jwk = Jwks.builder() + .key(keyPair.public as RSAPublicKey) + .id(keyId) + .build() + // Apple JWKS 는 {"keys":[ <공개 JWK> ]} 형태다. jjwt 는 단일 공개 JWK 직렬화(Jwks.json)만 + // public 으로 제공하므로 keys 배열로 감싸 동일 구조를 만든다. + return """{"keys":[${Jwks.json(jwk)}]}""" + } + + private fun signedToken( + sub: String = "apple-sub-123", + email: String? = "user@privaterelay.appleid.com", + audience: String = clientId, + issuer: String = AppleProperties.ISSUER, + expiresAt: Instant = Instant.now().plusSeconds(600), + kid: String = keyId, + ): String = Jwts.builder() + .header().keyId(kid).and() + .issuer(issuer) + .audience().add(audience).and() + .subject(sub) + .apply { email?.let { claim("email", it) } } + .issuedAt(Date.from(Instant.now())) + .expiration(Date.from(expiresAt)) + .signWith(keyPair.private as RSAPrivateKey) + .compact() + + @Test + fun `유효한 id_token 에서 sub 와 email 을 추출한다`() { + val (verifier, _) = verifierWithServer() + + val claims = verifier.verify(signedToken()) + + assertThat(claims.sub).isEqualTo("apple-sub-123") + assertThat(claims.email).isEqualTo("user@privaterelay.appleid.com") + } + + @Test + fun `email 이 없는 토큰도 검증되고 email 은 null 이다`() { + val (verifier, _) = verifierWithServer() + + val claims = verifier.verify(signedToken(email = null)) + + assertThat(claims.sub).isEqualTo("apple-sub-123") + assertThat(claims.email).isNull() + } + + @Test + fun `audience 가 client-id 와 다르면 예외를 던진다`() { + val (verifier, _) = verifierWithServer() + + assertThatThrownBy { verifier.verify(signedToken(audience = "com.other.app")) } + .isInstanceOf(AppleTokenInvalidException::class.java) + } + + @Test + fun `issuer 가 Apple 이 아니면 예외를 던진다`() { + val (verifier, _) = verifierWithServer() + + assertThatThrownBy { verifier.verify(signedToken(issuer = "https://evil.example.com")) } + .isInstanceOf(AppleTokenInvalidException::class.java) + } + + @Test + fun `만료된 토큰이면 예외를 던진다`() { + val (verifier, _) = verifierWithServer() + + assertThatThrownBy { verifier.verify(signedToken(expiresAt = Instant.now().minusSeconds(60))) } + .isInstanceOf(AppleTokenInvalidException::class.java) + } + + @Test + fun `다른 키로 서명된 토큰이면 예외를 던진다`() { + val (verifier, _) = verifierWithServer() + val attackerKey = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }.generateKeyPair() + val forged = Jwts.builder() + .header().keyId(keyId).and() + .issuer(AppleProperties.ISSUER) + .audience().add(clientId).and() + .subject("apple-sub-123") + .expiration(Date.from(Instant.now().plusSeconds(600))) + .signWith(attackerKey.private as RSAPrivateKey) + .compact() + + assertThatThrownBy { verifier.verify(forged) } + .isInstanceOf(AppleTokenInvalidException::class.java) + } + + @Test + fun `알 수 없는 kid 면 예외를 던진다`() { + val (verifier, _) = verifierWithServer() + + assertThatThrownBy { verifier.verify(signedToken(kid = "unknown-kid")) } + .isInstanceOf(AppleTokenInvalidException::class.java) + } +}