scalatwitteroauthscala-3sttp

OAuth 1.0 and X API v2 with Scala and sttp


I try to create an App to send automagically chess tournament announcements to X. Everything works as expected except the OAuth-1.0 authorization. I followed Kevin Williams. HMAC-SHA1 by Aravind_G

Here is the code. I omitted the JSON part which works fine. Please believe me that i provide the raw credentials in a correct way :-)

package de.qno.tournamentadmin

import org.joda.time.DateTime
import sttp.client4.*
import sttp.model.*

import java.net.URLEncoder
import javax.crypto.spec.SecretKeySpec
import scala.util.Random

[…]
/**
 * With thx to Kevin Williams
 * https://medium.com/@kevinwilliams.dev/posting-to-x-twitter-with-oauth-1-0-8d4de172cfa6
 */
object Auth:
  def urlEncode(text: String): String =
    URLEncoder.encode(text, java.nio.charset.Charset.defaultCharset())

  def generateNonce: String =
    Random.alphanumeric.take(15).mkString

  def sortData(data: Map[String, String]): Seq[(String, String)] =
    data.toSeq.sortBy(_._1) // sort alphabetically
  
  def reformatData(data: Seq[(String, String)]): Seq[String] =   
    data.map(x => s"${x._1}=${x._2}")
      
  def reformatSignedData(data: Seq[(String, String)]): Seq[String] =
    data.map(x => s"${x._1}=\"${urlEncode(x._2)}\"")  

  /**
   * With thx to Aravind_G
   * https://community.gatling.io/t/hmac-sha1-signature-generation-using-scala/5844
   * @param text
   * @return
   */
  def sha1sign (text: String):  String =
    val shaHash = javax.crypto.Mac.getInstance("HmacSHA1")
    val secretKey = s"${urlEncode(TournamentAdmin.xApiKeySecret)}&${urlEncode(TournamentAdmin.xAccessTokenSecret)}"
    val signingKey = new SecretKeySpec(secretKey.getBytes, "HmacSHA1")
    shaHash.init(signingKey)
    val signatureHash = shaHash.doFinal(text.getBytes("UTF-8"))
    val baseEncoder = java.util.Base64.getEncoder
    val signatureString = (for byte <- signatureHash yield f"$byte%02X").mkString
    baseEncoder.encodeToString(signatureString.getBytes)
  
  def signedHeader: Header =
    val collectAuthHeaderMap = Map(
      "oauth_consumer_key" -> TournamentAdmin.xApiKey,
      "oauth_signature_method" -> "HMAC-SHA1",
      "oauth_timestamp" -> DateTime().getMillis.toString,
      "oauth_nonce" -> generateNonce,
      "oauth_token" -> TournamentAdmin.xAccesToken,
      "oauth_version" -> "1.0"
    )

    val sortedData = sortData(collectAuthHeaderMap)
    val reformattedData = reformatData(sortedData)
    val oneStringData = "POST" + "&" + urlEncode(Twitter.createPostEndpoint) + "&" + urlEncode(reformattedData.mkString("&"))
    val signature = sha1sign(oneStringData)
    val signatureDataTupel: (String, String) = ("oauth_signature", signature) // add signature to data
    val sortedSignedHeader = sortData(collectAuthHeaderMap + signatureDataTupel)
    val reformattedSignedHeader = reformatSignedData(sortedSignedHeader)
    val oneStringSignedHeader = "OAuth " + reformattedSignedHeader.mkString(", ")
    Header("Authorization", oneStringSignedHeader)

object Twitter:
  val createPostEndpoint = "https://api.x.com/2/tweets"

  def createPost(text: String): XCreatePostResponse =
    val textObj = XCreatePostBody(text)
    val h = Auth.signedHeader
    println(h)
    basicRequest
      .header(h)
      .contentType("application/json")
      .body(write(textObj))
      .post(uri"$createPostEndpoint")
      .response(asJson[XCreatePostResponse].getRight)
      .send(DefaultSyncBackend())
      .body

  @main
  def showHeader: Unit =
    createPost("Test")

In response of the main method, i get a 401 with a JSON response:

{
  "title": "Unauthorized",
  "type": "about:blank",
  "status": 401,
  "detail": "Unauthorized"
}

The created header:

Authorization: OAuth oauth_consumer_key="xxx", oauth_nonce="nae0Pk46M7ox3yP", oauth_signature="NjM5NDZERkIxRUY1NEY3OUE4Qzc2MjY5ODI0NEYyM0NFNjE0RjZDMg%3D%3D", oauth_signature_method="HMAC-SHA1", oauth_timestamp="1726259880224", oauth_token="xxx", oauth_version="1.0"

I think i encoded sth that should not be encoded or otherwise. But i don't know where i went wrong. Who can show me the error of my ways? Perhaps someone knows a tutorial that shows the plain headers created?

TIA, QNo

Edit: In a first version, i omitted the essential steps. I hope i completed everything.

Edit: In a second edit, i fixed some self-detected bugs. Now i have an Authorization header in a correct format. So the problem has to be the sha1sign method(?)


Solution

  • Solved. I still don’t know how, but it works now after some refactoring.

    package de.qno.tournamentadmin
    
    import org.joda.time.DateTime
    import sttp.client4.*
    import sttp.model.*
    
    import java.net.URLEncoder
    import javax.crypto.spec.SecretKeySpec
    import scala.util.Random
    
    […]
    /**
     * With thx to Kevin Williams
     * https://medium.com/@kevinwilliams.dev/posting-to-x-twitter-with-oauth-1-0-8d4de172cfa6
     */
    object Auth:
      def urlEncode(text: String): String =
        URLEncoder.encode(text, java.nio.charset.Charset.defaultCharset())
    
      def generateNonce: String =
        Random.alphanumeric.take(15).mkString
    
      def canonicalize(data: Map[String, String]) : String =
        data.map(x => urlEncode(x._1) -> urlEncode(x._2)) // percent encode keys and values
          .toSeq.sortBy(_._1) // sort alphabetically
          .map(x => s"${x._1}=${x._2}") // unite key and value with =
          .mkString("&") // unite all with &
    
      def canonicalizeWithQuotes(data: Map[String, String]) : String =
        data.map(x => urlEncode(x._1) -> urlEncode(x._2)) // percent encode keys and values
          .toSeq.sortBy(_._1) // sort alphabetically
          .map(x => s"${x._1}=\"${x._2}\"") // unite key and value with =
          .mkString("&") // unite all with &
    
      /**
       * With thx to Aravind_G
       * https://community.gatling.io/t/hmac-sha1-signature-generation-using-scala/5844
       * @param text
       * @return
       */
      def sha1sign (text: String, secretKey: String):  String =
        val shaHash = javax.crypto.Mac.getInstance("HmacSHA1")
        shaHash.init(new SecretKeySpec(secretKey.getBytes("UTF-8"), "HmacSHA1"))
        val signatureHash = shaHash.doFinal(text.getBytes("UTF-8"))
        java.util.Base64.getEncoder.encodeToString(signatureHash)
        
      def signedHeader(key: String): Header =
        val collectAuthHeaderMap = Map(
          "oauth_consumer_key" -> TournamentAdmin.xApiKey,
          "oauth_signature_method" -> "HMAC-SHA1",
          "oauth_timestamp" -> (DateTime().getMillis / 1000).toString,
          "oauth_nonce" -> generateNonce,
          "oauth_token" -> TournamentAdmin.xAccesToken,
          "oauth_version" -> "1.0"
        )
    
        val request = "POST" + "&" + urlEncode(Twitter.createPostEndpoint) + "&" + urlEncode(canonicalize(collectAuthHeaderMap))
        val signature = sha1sign(request, key)
        val signatureDataTupel: (String, String) = ("oauth_signature", signature) // add signature to data
        val oneStringSignedHeader = "OAuth " + canonicalizeWithQuotes(collectAuthHeaderMap + signatureDataTupel).replace("&", ", ")
        Header("Authorization", oneStringSignedHeader)
    
    object Twitter:
      val createPostEndpoint = "https://api.x.com/2/tweets"
    
      def createPost(text: String, key: String): XCreatePostResponse =
        val textObj = XCreatePostBody(text)
        val h = Auth.signedHeader(key)
        println(h)
        basicRequest
          .header(h)
          .contentType("application/json")
          .body(write(textObj))
          .post(uri"$createPostEndpoint")
          .response(asJson[XCreatePostResponse].getRight)
          .send(DefaultSyncBackend())
          .body
    
      @main
      def showHeader: Unit =
        createPost("Test", TournamentAdmin.makeTwitterKey)