Twitter OAuth 1.0a with Ktor

I’ve been working on a new project that requires lots of different social sign in providers. This meant I needed to learn what OAuth is and how to create the APIs to communicate with OAuth providers like Facebook, Google, Github and… Twitter. The first three providers were very easy to work with, Twitter was not. I spent about five hours reading Twitter’s documentation, going through blog post and libraries and writing code. This is how I got it working.

Step 1 - Configure OAuth URLs and Parameters

I used Ktor’s OAuth package, you’ll need to change urlProvider, consumerKey and consumerSecret. The other fields should stay the same.

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
}

Step 2 - Create Trigger and Callback endpoints

You need to set up a handler to trigger the OAuth flow, Ktor’s package does this for you. The more interesting part is the callback URL. Twitter calls the callback URL with a token and a tokenSecret. You need these tokens to sign requests you make to Twitter’s API. In my case all I wanted was the signed in user’s profile, but in your case you might want to tweet on behalf of the user.

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")
    }
}

So far the code is pretty simple, Twitter calls into my callback endpoint, I use their tokenSecret and token to get the logged in user’s profile. What happens inside TwitterTransport.getUser though?

Step 3 - Create a 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
}

All this does is create a 32 character random alphanumeric string.

Step 4 - Get the timestamp in seconds

val timestamp = Instant.now().epochSecond

Step 5 - Collect the parameters

include_email is part of the parameter set because it is a parameter on the URL (https://api.twitter.com/1.1/account/verify_credentials.json?include_email=true) I am requesting. If you’re hitting a different URL remember to include the query parameters of that URL into the parameter set below.

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"
)

Step 6 - Create a paramater string

My code uses com.google.common.net.PercentEscaper. The algorithm for creating this parameter string is described here under the collecting parameters header.

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("&")
}

Step 7 - Create a signature base string

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()
}

Step 8 - Create a signing key

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()
}

Step 9 - Create the signature

I used HmacUtils in org.apache.commons.codec.digest to do this for me and then Base64ed the resulting byte array

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

Step 10 - Create the Authorization header

This involved collecting all the various bits and pieces, escaping the strings and then joining them together with a “, "

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"
}

Now you have the authorization header and can send your HTTP request to Twitter’s API!

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)
    }
}

References

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

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