Skip to content

Instantly share code, notes, and snippets.

@vihangpatil
Created November 18, 2021 08:36
Show Gist options
  • Save vihangpatil/70acb236af7158dc56e3e144f8650ee6 to your computer and use it in GitHub Desktop.
Save vihangpatil/70acb236af7158dc56e3e144f8650ee6 to your computer and use it in GitHub Desktop.
Kotlin Backend Client to verify AppleId Auth Code
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