Skip to content

Commit 496b08b

Browse files
authored
merge: feature/oauth into develop
2 parents cb3897a + ea20d6a commit 496b08b

37 files changed

+544
-81
lines changed
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package noweekend.client.apple
2+
3+
import org.springframework.cloud.openfeign.FeignClient
4+
import org.springframework.http.MediaType
5+
import org.springframework.http.ResponseEntity
6+
import org.springframework.web.bind.annotation.GetMapping
7+
import org.springframework.web.bind.annotation.PostMapping
8+
import org.springframework.web.bind.annotation.RequestParam
9+
10+
@FeignClient(
11+
value = "apple-api",
12+
url = "\${oauth.apple.api-url}",
13+
)
14+
internal interface AppleApi {
15+
@GetMapping(
16+
value = ["/auth/keys"],
17+
consumes = [MediaType.APPLICATION_JSON_VALUE],
18+
)
19+
fun getPublicKeys(): ApplePublicKeyResponse
20+
21+
@PostMapping(
22+
value = ["/auth/token"],
23+
consumes = [MediaType.APPLICATION_FORM_URLENCODED_VALUE],
24+
)
25+
fun getAccessToken(
26+
@RequestParam("code") code: String,
27+
@RequestParam("client_id") clientId: String,
28+
@RequestParam("client_secret") clientSecret: String,
29+
@RequestParam("grant_type") grantType: String,
30+
): AppleTokens
31+
32+
@PostMapping(
33+
value = ["/auth/revoke"],
34+
consumes = [MediaType.APPLICATION_FORM_URLENCODED_VALUE],
35+
)
36+
fun revokeToken(
37+
@RequestParam("client_id") clientId: String,
38+
@RequestParam("client_secret") clientSecret: String,
39+
@RequestParam("token") token: String,
40+
): ResponseEntity<Any>
41+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package noweekend.client.apple
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper
4+
import com.fasterxml.jackson.module.kotlin.readValue
5+
import io.jsonwebtoken.Claims
6+
import io.jsonwebtoken.Jwts
7+
import io.jsonwebtoken.SignatureAlgorithm
8+
import noweekend.client.common.OAuthClient
9+
import noweekend.client.common.OAuthInfo
10+
import noweekend.client.common.OAuthLoginParams
11+
import noweekend.client.common.Revocable
12+
import noweekend.client.properties.AppleAuthProperties
13+
import noweekend.core.domain.user.ProviderType
14+
import org.bouncycastle.util.io.pem.PemReader
15+
import org.springframework.core.io.FileSystemResource
16+
import org.springframework.stereotype.Component
17+
import java.io.FileReader
18+
import java.math.BigInteger
19+
import java.security.KeyFactory
20+
import java.security.PrivateKey
21+
import java.security.spec.PKCS8EncodedKeySpec
22+
import java.security.spec.RSAPublicKeySpec
23+
import java.time.LocalDateTime
24+
import java.time.ZoneId
25+
import java.util.Base64
26+
import java.util.Date
27+
28+
@Component
29+
class AppleClient internal constructor(
30+
private val appleApi: AppleApi,
31+
private val appleOAuthProperties: AppleAuthProperties,
32+
private val objectMapper: ObjectMapper,
33+
) : OAuthClient, Revocable {
34+
override fun supports(providerType: ProviderType): Boolean {
35+
return providerType == ProviderType.APPLE
36+
}
37+
38+
override fun requestOAuthInfo(params: OAuthLoginParams): OAuthInfo {
39+
val appleTokens = requestAccessToken(params)
40+
if (appleTokens?.idToken == null) throw IllegalStateException("cannot find idToken from apple")
41+
val claims = getClaims(appleTokens.idToken)
42+
return AppleOAuthInfo(claims.subject, appleTokens.refreshToken)
43+
}
44+
45+
override fun revokeToken(token: String): Boolean {
46+
val clientId = appleOAuthProperties.appId
47+
val clientSecret = makeClientSecret(
48+
keyId = appleOAuthProperties.keyId,
49+
teamId = appleOAuthProperties.teamId,
50+
clientId = clientId,
51+
secretKeyFilePath = appleOAuthProperties.secretKeyFilePath,
52+
)
53+
return appleApi.revokeToken(clientId, clientSecret, token).statusCode.is2xxSuccessful
54+
}
55+
56+
private fun requestAccessToken(params: OAuthLoginParams): AppleTokens? {
57+
val clientId = appleOAuthProperties.appId
58+
val clientSecret = makeClientSecret(
59+
keyId = appleOAuthProperties.keyId,
60+
teamId = appleOAuthProperties.teamId,
61+
clientId = clientId,
62+
secretKeyFilePath = appleOAuthProperties.secretKeyFilePath,
63+
)
64+
65+
return appleApi.getAccessToken(params.getCode(), clientId, clientSecret, AUTHORIZATION_CODE)
66+
}
67+
68+
private fun makeClientSecret(keyId: String, teamId: String, clientId: String, secretKeyFilePath: String): String {
69+
val now = LocalDateTime.now()
70+
val expirationDate = Date.from(now.plusDays(30).atZone(ZoneId.systemDefault()).toInstant())
71+
return Jwts.builder()
72+
.setHeaderParam(KEY_KID, keyId)
73+
.setHeaderParam(KEY_ALGORITHM, "ES256")
74+
.setIssuer(teamId)
75+
.setIssuedAt(Date(System.currentTimeMillis()))
76+
.setExpiration(expirationDate)
77+
.setAudience(APPLE_AUDIENCE)
78+
.setSubject(clientId)
79+
.signWith(getPrivateKey(secretKeyFilePath), SignatureAlgorithm.ES256)
80+
.compact()
81+
}
82+
83+
private fun getPrivateKey(secretKeyPath: String): PrivateKey {
84+
val resource = FileSystemResource(secretKeyPath)
85+
val keyReader = FileReader(resource.uri.path)
86+
PemReader(keyReader).use { reader ->
87+
val content = reader.readPemObject().content
88+
return KeyFactory.getInstance("EC")
89+
.generatePrivate(PKCS8EncodedKeySpec(content))
90+
}
91+
}
92+
93+
private fun getClaims(identityToken: String): Claims {
94+
val header = identityToken.substring(0, identityToken.indexOf("."))
95+
val headerMap: Map<String, String> = objectMapper.readValue(String(Base64.getDecoder().decode(header)))
96+
val key = getPublicKey(headerMap[KEY_KID], headerMap[KEY_ALGORITHM])
97+
98+
val nBytes = Base64.getUrlDecoder().decode(key.n)
99+
val eBytes = Base64.getUrlDecoder().decode(key.e)
100+
101+
val n = BigInteger(1, nBytes)
102+
val e = BigInteger(1, eBytes)
103+
104+
val publicKeySpec = RSAPublicKeySpec(n, e)
105+
val keyFactory = KeyFactory.getInstance(key.kty)
106+
val publicKey = keyFactory.generatePublic(publicKeySpec)
107+
108+
return Jwts.parserBuilder().setSigningKey(publicKey).build().parseClaimsJws(identityToken).body
109+
}
110+
111+
private fun getPublicKey(kid: String?, alg: String?): ApplePublicKey {
112+
if (kid == null || alg == null) throw IllegalStateException("cannot get public key from apple")
113+
return appleApi.getPublicKeys().keys.firstOrNull { key -> key.kid == kid && key.alg == alg }
114+
?: throw IllegalStateException("cannot find public key from apple")
115+
}
116+
117+
companion object {
118+
private const val KEY_KID = "kid"
119+
private const val KEY_ALGORITHM = "alg"
120+
121+
private const val AUTHORIZATION_CODE = "authorization_code"
122+
private const val APPLE_AUDIENCE = "https://appleid.apple.com"
123+
}
124+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package noweekend.client.apple
2+
3+
import noweekend.client.common.OAuthLoginParams
4+
import noweekend.core.domain.user.ProviderType
5+
6+
class AppleLoginParams(private val code: String) : OAuthLoginParams {
7+
override fun getProviderType(): ProviderType = ProviderType.APPLE
8+
override fun getCode(): String = code
9+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package noweekend.client.apple
2+
3+
import noweekend.client.common.OAuthInfo
4+
import noweekend.core.domain.user.ProviderType
5+
6+
class AppleOAuthInfo(
7+
private val id: String,
8+
private val revocableToken: String,
9+
) : OAuthInfo {
10+
override fun getProviderType(): ProviderType = ProviderType.APPLE
11+
12+
override fun getProviderId(): String = id
13+
14+
override fun getProviderRevocableToken(): String = revocableToken
15+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package noweekend.client.apple
2+
3+
data class ApplePublicKeyResponse(val keys: List<ApplePublicKey>)
4+
5+
data class ApplePublicKey(
6+
val kty: String,
7+
val kid: String,
8+
val use: String,
9+
val alg: String,
10+
val n: String,
11+
val e: String,
12+
)
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package noweekend.client.apple
2+
3+
import com.fasterxml.jackson.annotation.JsonProperty
4+
5+
data class AppleTokens(
6+
@JsonProperty("access_token")
7+
val accessToken: String,
8+
9+
@JsonProperty("expires_in")
10+
val expiresIn: String,
11+
12+
@JsonProperty("id_token")
13+
val idToken: String,
14+
15+
@JsonProperty("refresh_token")
16+
val refreshToken: String,
17+
18+
@JsonProperty("token_type")
19+
val tokenType: String,
20+
21+
@JsonProperty("error")
22+
val error: String? = null,
23+
)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package noweekend.client.common
2+
3+
import noweekend.core.domain.user.ProviderType
4+
5+
interface OAuthClient {
6+
fun supports(providerType: ProviderType): Boolean
7+
fun requestOAuthInfo(params: OAuthLoginParams): OAuthInfo
8+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package noweekend.client.common
2+
3+
import noweekend.core.domain.user.ProviderType
4+
5+
interface OAuthInfo {
6+
fun getProviderType(): ProviderType
7+
fun getProviderId(): String
8+
fun getProviderRevocableToken(): String?
9+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package noweekend.client.common
2+
3+
import noweekend.core.domain.user.ProviderType
4+
5+
interface OAuthLoginParams {
6+
fun getProviderType(): ProviderType
7+
fun getCode(): String
8+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package noweekend.client.common
2+
3+
interface Revocable {
4+
fun revokeToken(token: String): Boolean
5+
}

noweekend-clients/client-oauth/src/main/kotlin/noweekend/client/config/ComponentScanConfig.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import org.springframework.context.annotation.Configuration
66
@ComponentScan(
77
basePackages = [
88
"noweekend.client.google",
9+
"noweekend.client.apple",
10+
"noweekend.client.common",
911
],
1012
)
1113
@Configuration("clientsConfig")
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package noweekend.client.config
2+
3+
import noweekend.client.properties.AppleAuthProperties
4+
import noweekend.client.properties.GoogleAuthProperties
5+
import org.springframework.boot.context.properties.EnableConfigurationProperties
6+
import org.springframework.context.annotation.Configuration
7+
8+
@Configuration
9+
@EnableConfigurationProperties(
10+
AppleAuthProperties::class,
11+
GoogleAuthProperties::class,
12+
)
13+
class OAuthConfig

noweekend-clients/client-oauth/src/main/kotlin/noweekend/client/config/OauthFeignConfig.kt renamed to noweekend-clients/client-oauth/src/main/kotlin/noweekend/client/config/OAuthFeignConfig.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import org.springframework.context.annotation.Configuration
66
@EnableFeignClients(
77
basePackages = [
88
"noweekend.client.google",
9-
// "noweekend.client.apple",
9+
"noweekend.client.apple",
1010
],
1111
)
1212
@Configuration
13-
internal class OauthFeignConfig
13+
internal class OAuthFeignConfig
Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,44 @@
11
package noweekend.client.google
22

3-
import noweekend.client.model.GoogleEmailResult
3+
import noweekend.client.common.OAuthClient
4+
import noweekend.client.common.OAuthInfo
5+
import noweekend.client.common.OAuthLoginParams
6+
import noweekend.client.properties.GoogleAuthProperties
7+
import noweekend.core.domain.user.ProviderType
48
import org.springframework.stereotype.Component
59

610
@Component
711
class GoogleClient internal constructor(
8-
private val googleApi: GoogleApi,
9-
) {
10-
fun getEmail(request: GoogleUserInfoRequest): GoogleEmailResult {
11-
val tokenHeader = "Bearer ${request.accessToken}"
12-
val response = googleApi.getUserInfo(tokenHeader)
13-
return response.result()
12+
private val googleTokenApi: GoogleTokenApi,
13+
private val googleResourceApi: GoogleResourceApi,
14+
private val googleAuthProperties: GoogleAuthProperties,
15+
) : OAuthClient {
16+
17+
override fun supports(providerType: ProviderType): Boolean {
18+
return providerType == ProviderType.GOOGLE
19+
}
20+
21+
override fun requestOAuthInfo(params: OAuthLoginParams): OAuthInfo {
22+
val accessToken = requestAccessToken(params)
23+
return requestAuthInfo(accessToken)
24+
}
25+
26+
private fun requestAccessToken(params: OAuthLoginParams): String {
27+
return googleTokenApi.getGoogleToken(
28+
code = params.getCode(),
29+
redirectUri = DEFAULT_REDIRECT_URI,
30+
clientId = googleAuthProperties.clientId,
31+
clientSecret = googleAuthProperties.clientSecret,
32+
grantType = GOOGLE_AUTHORIZATION_TYPE,
33+
).accessToken
34+
}
35+
36+
private fun requestAuthInfo(accessToken: String): GoogleOAuthInfo {
37+
return googleResourceApi.getUserInfo(accessToken)
38+
}
39+
40+
companion object {
41+
private const val GOOGLE_AUTHORIZATION_TYPE = "authorization_code"
42+
private const val DEFAULT_REDIRECT_URI = "postmessage"
1443
}
1544
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package noweekend.client.google
2+
3+
import noweekend.client.common.OAuthLoginParams
4+
import noweekend.core.domain.user.ProviderType
5+
6+
class GoogleLoginParams(private val code: String) : OAuthLoginParams {
7+
override fun getProviderType(): ProviderType = ProviderType.GOOGLE
8+
override fun getCode(): String = code
9+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package noweekend.client.google
2+
3+
import com.fasterxml.jackson.annotation.JsonProperty
4+
import noweekend.client.common.OAuthInfo
5+
import noweekend.core.domain.user.ProviderType
6+
7+
data class GoogleOAuthInfo(
8+
@JsonProperty("id")
9+
val id: String,
10+
11+
@JsonProperty("email")
12+
val email: String?,
13+
) : OAuthInfo {
14+
override fun getProviderId(): String = id
15+
override fun getProviderRevocableToken(): String? = null
16+
override fun getProviderType(): ProviderType = ProviderType.GOOGLE
17+
}

0 commit comments

Comments
 (0)