Created
November 18, 2021 08:36
-
-
Save vihangpatil/70acb236af7158dc56e3e144f8650ee6 to your computer and use it in GitHub Desktop.
Kotlin Backend Client to verify AppleId Auth Code
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import arrow.core.Either | |
import arrow.core.left | |
import arrow.core.right | |
import com.typesafe.config.ConfigFactory | |
import io.github.config4k.extract | |
import io.jsonwebtoken.JwtBuilder | |
import io.jsonwebtoken.Jwts | |
import io.jsonwebtoken.SignatureAlgorithm | |
import io.ktor.client.* | |
import io.ktor.client.call.* | |
import io.ktor.client.features.* | |
import io.ktor.client.features.json.* | |
import io.ktor.client.features.logging.* | |
import io.ktor.client.request.* | |
import io.ktor.client.request.forms.* | |
import io.ktor.client.statement.* | |
import io.ktor.http.* | |
import org.slf4j.LoggerFactory | |
import java.security.KeyFactory | |
import java.security.PrivateKey | |
import java.security.spec.PKCS8EncodedKeySpec | |
import java.time.Instant | |
import java.util.* | |
// | |
// build.gradle.kts | |
// | |
/* | |
plugins { | |
`java-library` | |
kotlin("jvm") | |
kotlin("plugin.serialization") | |
} | |
dependencies { | |
implementation("io.ktor:ktor-client-cio:${Version.ktor}") | |
implementation("io.ktor:ktor-client-logging:${Version.ktor}") | |
implementation("io.ktor:ktor-client-jackson:${Version.ktor}") { | |
exclude(group = "org.jetbrains.kotlin", module = "kotlin-reflect") | |
} | |
implementation("io.jsonwebtoken:jjwt-api:${Version.jjwt}") | |
runtimeOnly("io.jsonwebtoken:jjwt-impl:${Version.jjwt}") | |
runtimeOnly("io.jsonwebtoken:jjwt-jackson:${Version.jjwt}") | |
implementation("io.arrow-kt:arrow-core:${Version.arrow}") | |
implementation("io.github.config4k:config4k:${Version.config4k}") | |
} | |
*/ | |
object AppleIdAuthClient { | |
private val logger by lazy { LoggerFactory.getLogger("AppleIdAuthClient") } | |
private val config by lazy { | |
val fileConfig by lazy<FileConfig> { | |
ConfigFactory.parseString( | |
""" | |
appleid-auth { | |
teamId = "" | |
keyId = "" | |
clientId = "" | |
privateKey = "" | |
} | |
""".trimIndent() | |
) | |
.resolve() | |
.getConfig("appleid-auth") | |
.extract() | |
} | |
Config( | |
teamId = fileConfig.teamId, | |
keyId = fileConfig.keyId, | |
clientId = fileConfig.clientId, | |
privateKey = KeyFactory | |
.getInstance("EC") | |
.generatePrivate( | |
PKCS8EncodedKeySpec( | |
Base64.getDecoder().decode(fileConfig.privateKey) | |
) | |
), | |
) | |
} | |
private val client by lazy { | |
HttpClient { | |
defaultRequest { | |
host = "appleid.apple.com" | |
url { | |
this.protocol = URLProtocol.HTTPS | |
} | |
expectSuccess = false | |
} | |
install(Logging) { | |
logger = Logger.DEFAULT | |
level = LogLevel.ALL | |
} | |
install(UserAgent) { | |
agent = "arcane-platform" | |
} | |
install(JsonFeature) { | |
serializer = JacksonSerializer() | |
} | |
} | |
} | |
suspend fun fetchApplePublicKey(): Either<String, JWKSet> { | |
val response: HttpResponse = client.get(path = "auth/keys") | |
return when (response.status.value) { | |
200 -> response.receive<JWKSet>().right() | |
else -> response.readText().left() | |
} | |
} | |
/** | |
* https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens | |
*/ | |
suspend fun authorize( | |
authCode: String, | |
redirectUri: String, | |
): Either<ErrorResponse, TokenResponse> { | |
val response: HttpResponse = client.post( | |
path = "auth/token", | |
body = FormDataContent( | |
Parameters.build { | |
append("client_id", config.clientId) | |
append("client_secret", generateClientSecret()) | |
append("code", authCode) | |
append("grant_type", GrantType.authorization_code.name) | |
append("redirect_uri", redirectUri) | |
} | |
), | |
) | |
return when (response.status.value) { | |
200 -> response.receive<TokenResponse>().right() | |
400 -> response.receive<ErrorResponse>().left() | |
else -> { | |
logger.warn("Unexpected response ${response.status.value} - ${response.readText()}") | |
ErrorResponse(Error.UNEXPECTED, response.readText()).left() | |
} | |
} | |
} | |
/** | |
* https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens | |
*/ | |
suspend fun validate(token: String): Either<ErrorResponse, TokenResponse> { | |
val response: HttpResponse = client.post( | |
path = "auth/token", | |
body = FormDataContent( | |
Parameters.build { | |
append("client_id", config.clientId) | |
append("client_secret", generateClientSecret()) | |
append("grant_type", GrantType.refresh_token.name) | |
append("refresh_token", token) | |
} | |
), | |
) | |
return when (response.status.value) { | |
200 -> response.receive<TokenResponse>().right() | |
400 -> response.receive<ErrorResponse>().left() | |
else -> { | |
logger.warn("Unexpected response ${response.status.value} - ${response.readText()}") | |
ErrorResponse(Error.UNEXPECTED, response.readText()).left() | |
} | |
} | |
} | |
private fun generateClientSecret(): String { | |
val now = Instant.now() | |
return Jwts.builder() | |
.setHeader("kid" to config.keyId) | |
.setIssuer(config.teamId) | |
.setIssuedAt(Date(now.toEpochMilli())) | |
.setExpiration(Date(now.plusSeconds(300).toEpochMilli())) | |
.setAudience(config.appleIdServiceUrl) | |
.setSubject(config.clientId) | |
.signWith(config.privateKey, SignatureAlgorithm.ES256) | |
.compact() | |
} | |
private fun JwtBuilder.setHeader(header: Pair<String, String>): JwtBuilder { | |
this.setHeader(mapOf(header)) | |
return this | |
} | |
} | |
// | |
// Model | |
// | |
enum class GrantType { | |
authorization_code, | |
refresh_token, | |
} | |
/** | |
* https://developer.apple.com/documentation/sign_in_with_apple/tokenresponse | |
*/ | |
data class TokenResponse( | |
val access_token: String, | |
val expires_in: Long, | |
val id_token: String, | |
val refresh_token: String, | |
val token_type: String, | |
) | |
/** | |
* https://developer.apple.com/documentation/sign_in_with_apple/errorresponse | |
*/ | |
data class ErrorResponse( | |
val error: Error, | |
val error_description: String, | |
) | |
/** | |
* https://developer.apple.com/documentation/sign_in_with_apple/errorresponse | |
*/ | |
enum class Error(val cause: String) { | |
invalid_request("The request is malformed."), | |
invalid_client("The client authentication failed."), | |
invalid_grant("The authorization grant or refresh token is invalid."), | |
unauthorized_client("The client is not authorized to use this authorization grant type."), | |
unsupported_grant_type("The authenticated client is not authorized to use the grant type."), | |
invalid_scope("The requested scope is invalid."), | |
UNEXPECTED(""), | |
} | |
/** | |
* https://developer.apple.com/documentation/sign_in_with_apple/jwkset | |
*/ | |
data class JWKSet(val keys: Collection<JWKKey>) | |
/** | |
* https://developer.apple.com/documentation/sign_in_with_apple/jwkset/keys | |
*/ | |
data class JWKKey( | |
val alg: String, | |
val e: String, | |
val kid: String, | |
val kty: String, | |
val n: String, | |
val use: String, | |
) | |
// | |
// Config | |
// | |
data class Config( | |
val teamId: String, | |
val keyId: String, | |
val clientId: String, | |
val privateKey: PrivateKey, | |
val appleIdServiceUrl:String = "https://appleid.apple.com", | |
) | |
data class FileConfig( | |
val teamId: String, | |
val keyId: String, | |
val clientId: String, | |
val privateKey: String, | |
) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment