Twitter OAuth 1.0a con Ktor

He estado trabajando en un nuevo proyecto que requiere muchos proveedores diferentes de inicio de sesión social. Esto significaba que necesitaba aprender qué era OAuth y cómo crear las API para comunicarme con proveedores de OAuth como Facebook, Google, Github y… Twitter. Fue muy fácil trabajar con los primeros tres proveedores, Twitter no lo fue. Pasé unas cinco horas leyendo la documentación de Twitter, revisando publicaciones en blogs y bibliotecas y escribiendo código. Así es como lo hice funcionar.

Paso 1 - Configurar OAuth URLs y Parámetros

Usé el paquete Ktor’s OAuth, se debe cambiar urlProvider, consumerKey y consumerSecret. Los otros campos deben permanecer igual.

oauth("auth-oauth-twitter") {
	urlProvider = { "${config.appConfig.backEndBaseUrl}/auth/callback/twitter" }
	providerLookup = {
	    OAuthServerSettings.OAuth1aServerSettings(
	        name = "twitter",
	        requestTokenUrl = "https://api.twitter.com/oauth/request_token",
	        authorizeUrl = "https://api.twitter.com/oauth/authorize",
	        accessTokenUrl = "https://api.twitter.com/oauth/access_token",
	        consumerKey = "CONSUMER_KEY_FROM_TWITTER_DEV_PORTAL",
	        consumerSecret = "CONSUMER_SECRET_FROM_TWITTER_DEV_PORTAL",
	    )
	}
	client = httpClient
}

Paso 2 - Crear activadores y parámetros Callback

Se debe configurar un controlador para activar el flujo de OAuth, el paquete de Ktor lo hace por usted. La parte más interesante es el Callback de la URL. Twitter llama al Callback de la URL con un “token” y un “tokenSecret”. Estos tokens son necesarios para aprobar las solicitudes que se realizan a la API de Twitter. En mi caso, todo lo que quería era el perfil del usuario registrado, pero en su caso, es posible que desee twittear en nombre del usuario.

authenticate("auth-oauth-twitter") {
    get("/auth/login/twitter") {
        // Redirects to 'authorizeUrl' automatically
    }

    get("/auth/callback/twitter") {
        val response = twitterTransport.getUser(principal.tokenSecret, principal.token)
            ?: throw BadRequestException(ErrorType.FAILED_TO_FETCH_TWITTER_USER, "Failed to fetch twitter user")

        val (email, name) = response
        logger.info("{} logged in with Twitter.", response.email)

        call.respondRedirect("/somewhere")
    }
}

Hasta ahora, el código es bastante simple, Twitter llama a mi parámetro Callback, uso su tokenSecret y token para obtener el perfil del usuario que ha iniciado sesión. Sin embargo, ¿qué sucede dentro de TwitterTransport.getUser?

Paso 3 - Crear un Nonce

fun createNonce(): String {
    val charPool : List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
    val randomString = ThreadLocalRandom.current()
        .ints(32, 0, charPool.size)
        .asSequence()
        .map { charPool[it] }
        .joinToString("")

    return randomString
}

Todo lo que hace es crear una string alfanumérico aleatorio de 32 caracteres.

Paso 4 - Obtener la marca de tiempo en segundos

val timestamp = Instant.now().epochSecond

Step 5 - Recopilar los parámetros

include_email es parte del conjunto de parámetros porque es un parámetro en la URL (https://api.twitter.com/1.1/account/verify_credentials.json?include_email=true) que estoy solicitando. Si está accediendo a una URL diferente, recuerde incluir los parámetros de consulta de esa URL en el conjunto de parámetros a continuación.

val params = listOf(
    "include_email" to "true",
    "oauth_consumer_key" to consumerKey,
    "oauth_nonce" to nonce,
    "oauth_signature_method" to "HMAC-SHA1",
    "oauth_timestamp" to timestamp.toString(),
    "oauth_token" to oAuthToken,
    "oauth_version" to "1.0"
)

Paso 6 - Crear un string de parámetros

Mi código usa com.google.common.net.PercentEscaper. El algoritmo para crear este string de parámetros se describe aquí bajo el encabezado de recopilación de parámetros.

private val escaper = PercentEscaper("_-.", false)

fun createParamString(params: List<Pair<String, String>>): String {
    return params.map { (key, value) ->
        escaper.escape(key) to escaper.escape(value)
    }.sortedBy {
        it.first
    }.map { (key, value) ->
        "$key=$value"
    }.joinToString("&")
}

Paso 7 - Crear una firma a base de caracteres

fun createSignatureBaseString(method: String, url: String, parameterString: String): String {
    val buffer = StringBuilder()
    buffer.append(method.uppercase())
    buffer.append("&")
    buffer.append(escaper.escape(url))
    buffer.append("&")
    buffer.append(escaper.escape(parameterString))

    return buffer.toString()
}

Paso 8 - Crear una clave de firmado

fun createSigningKey(consumerSecret: String, oauthTokenSecret: String): String {
    val buffer = StringBuilder()
    buffer.append(escaper.escape(consumerSecret))
    buffer.append("&")
    buffer.append(escaper.escape(oauthTokenSecret))
    return buffer.toString()
}

Paso 9 - Crear una firma

Usé HmacUtils en org.apache.commons.codec.digest para que haciera esto por mí y luego encripté la matriz de bytes resultante con base64

fun hmacSign(signingKey: String, signatureBaseString: String): String {
    val hmacSha1 = HmacUtils.hmacSha1(signingKey, signatureBaseString)
    return Base64.getEncoder().encodeToString(hmacSha1)
}

Paso 10 - Crear el encabezado de autorización

Esto implicó recolectar todos los diversos fragmentos y piezas, separando los strings y luego uniendolos con una “,”

fun createAuthHeader(consumerKey: String, timestamp: Long, nonce: String, signature: String, token: String): String {
    val pairs = listOf(
        "oauth_consumer_key" to consumerKey,
        "oauth_nonce" to nonce,
        "oauth_signature" to signature,
        "oauth_signature_method" to "HMAC-SHA1",
        "oauth_timestamp" to timestamp.toString(),
        "oauth_token" to token,
        "oauth_version" to "1.0",
    )

    val collected = pairs.map { (key, value) ->
        "${escaper.escape(key)}=\"${escaper.escape(value)}\""
    }.joinToString(", ")

    return "OAuth $collected"
}

¡Ahora tiene el encabezado de autorización y puede enviar su solicitud HTTP a la API de Twitter!

OAuthEncoder.kt

class OAuthEncoder {
    private val escaper = PercentEscaper("_-.", false)

    fun createNonce(): String {
        val charPool : List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
        val randomString = ThreadLocalRandom.current()
            .ints(32, 0, charPool.size)
            .asSequence()
            .map { charPool[it] }
            .joinToString("")

        return randomString
    }

    fun createAuthHeader(consumerKey: String, timestamp: Long, nonce: String, signature: String, token: String): String {
        val pairs = listOf(
            "oauth_consumer_key" to consumerKey,
            "oauth_nonce" to nonce,
            "oauth_signature" to signature,
            "oauth_signature_method" to "HMAC-SHA1",
            "oauth_timestamp" to timestamp.toString(),
            "oauth_token" to token,
            "oauth_version" to "1.0",
        )

        val collected = pairs.map { (key, value) ->
            "${escaper.escape(key)}=\"${escaper.escape(value)}\""
        }.joinToString(", ")

        return "OAuth $collected"
    }

    fun hmacSign(signingKey: String, signatureBaseString: String): String {
        val hmacSha1 = HmacUtils.hmacSha1(signingKey, signatureBaseString)
        return Base64.getEncoder().encodeToString(hmacSha1)
    }

    fun createSigningKey(consumerSecret: String, oauthTokenSecret: String): String {
        val buffer = StringBuilder()
        buffer.append(escaper.escape(consumerSecret))
        buffer.append("&")
        buffer.append(escaper.escape(oauthTokenSecret))
        return buffer.toString()
    }

    fun createSignatureBaseString(method: String, url: String, parameterString: String): String {
        val buffer = StringBuilder()
        buffer.append(method.uppercase())
        buffer.append("&")
        buffer.append(escaper.escape(url))
        buffer.append("&")
        buffer.append(escaper.escape(parameterString))

        return buffer.toString()
    }

    fun createParamString(params: List<Pair<String, String>>): String {
        return params.map { (key, value) ->
            escaper.escape(key) to escaper.escape(value)
        }.sortedBy {
            it.first
        }.map { (key, value) ->
            "$key=$value"
        }.joinToString("&")
    }

    fun createSignature(
        consumerSecret: String,
        oAuthTokenSecret: String,
        method: String,
        url: String,
        params: List<Pair<String, String>>
    ): String {
        val paramString = createParamString(params)
        val signatureBaseString = createSignatureBaseString(method, url, paramString)
        val signingKey = createSigningKey(consumerSecret, oAuthTokenSecret)
        return hmacSign(signingKey, signatureBaseString)
    }
}

TwitterTransport.kt

interface ITwitterTransport {
    data class User(val email: String, val name: String)

    fun getUser(oAuthTokenSecret: String, oAuthToken: String): User?
}

class TwitterTransport : ITwitterTransport {
    private val logger = LoggerFactory.getLogger(this::class.java)
    private val client = HttpClient.newBuilder().build()

    private val consumerKey = "CONSUMER_KEY"
    private val consumerSecret = "CONSUMER_SECRET"
    private val encoder = OAuthEncoder()

    override fun getUser(oAuthTokenSecret: String, oAuthToken: String): ITwitterTransport.User {
        val url = "https://api.twitter.com/1.1/account/verify_credentials.json"
        val nonce = encoder.createNonce()
        val timestamp = Instant.now().epochSecond

        val params = listOf(
            "include_email" to "true",
            "oauth_consumer_key" to consumerKey,
            "oauth_nonce" to nonce,
            "oauth_signature_method" to "HMAC-SHA1",
            "oauth_timestamp" to timestamp.toString(),
            "oauth_token" to oAuthToken,
            "oauth_version" to "1.0"
        )

        logger.info("oAuthToken = {}", oAuthToken)

        val signature = encoder.createSignature(
            consumerSecret = consumerSecret,
            oAuthTokenSecret = oAuthTokenSecret,
            method = "get",
            url = url,
            params = params
        )

        val authHeader = encoder.createAuthHeader(
            consumerKey = consumerKey,
            timestamp = timestamp,
            nonce = nonce,
            signature = signature,
            token = oAuthToken
        )

        val request = HttpRequest.newBuilder()
            .uri(URI.create("$url?include_email=true"))
            .header("Authorization", authHeader)
            .GET()
            .build()

        val response = client.send(request, HttpResponse.BodyHandlers.ofString())
        val body = parse<JsonNode>(response.body())
        logger.info("${response.statusCode()} ${response.body()}")

        val email = body["email"].asText()
        val name = body["name"].asText()
        return ITwitterTransport.User(email, name)
    }
}

Referencias

[1] - Autorizando una solicitud, https://developer.twitter.com/en/docs/authentication/oauth-1-0a/authorizing-a-request

[2] - Creando una firma, https://developer.twitter.com/en/docs/authentication/oauth-1-0a/creating-a-signature

Join The Mailing List