Skip to content

Instantly share code, notes, and snippets.

@NightlyNexus
Last active May 23, 2025 11:35
Show Gist options
  • Save NightlyNexus/b29849fc87c0d025e43cb0717bf436a5 to your computer and use it in GitHub Desktop.
Save NightlyNexus/b29849fc87c0d025e43cb0717bf436a5 to your computer and use it in GitHub Desktop.
An OkHttp 3/4/5 interceptor that signs OAuth 1 requests. No other dependencies. Correctly signs all parameters and uses RFC 3986 encoding. https://oauth.net/core/1.0/#signing_process
import java.security.InvalidKeyException
import java.security.NoSuchAlgorithmException
import java.security.SecureRandom
import java.time.Clock
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import kotlin.text.Charsets.UTF_8
import okhttp3.FormBody
import okhttp3.HttpUrl
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.Response
import okio.Buffer
import okio.BufferedSource
import okio.ByteString
// https://oauth.net/core/1.0/#signing_process
// This implementation relies on okhttp3.HttpUrl's query encoding to conform to the required
// RFC 3986 encoding.
// https://datatracker.ietf.org/doc/html/rfc3986
class Oauth1SigningInterceptor(
private val consumerKey: String,
consumerSecret: String,
private val accessToken: String,
tokenSecret: String,
private val generator: Generator
) : Interceptor {
interface Generator {
fun nonce(): String
fun timestampSeconds(): String
class Standard : Generator {
private val random = SecureRandom()
private val clock = Clock.systemUTC()
private val regex = "\\W".toRegex()
// A good-enough implementation.
// Twitter's OAuth 1 documentation suggests this implementation.
// https://docs.x.com/resources/fundamentals/authentication/oauth-1-0a/authorizing-a-request
override fun nonce(): String {
val nonce = ByteArray(32)
random.nextBytes(nonce)
return ByteString.of(*nonce).base64().replace(regex, "")
}
override fun timestampSeconds(): String {
return (clock.millis() / 1000).toString()
}
}
}
private val formUrlEncodedMediaType = "application/x-www-form-urlencoded".toMediaType()
private val authorizationPrefix = "OAuth "
private val realmName = "realm"
private val realm = ""
private val consumerKeyName = "oauth_consumer_key"
private val tokenName = "oauth_token"
private val signatureMethodName = "oauth_signature_method"
private val signatureMethod = "HMAC-SHA1"
private val signatureName = "oauth_signature"
private val timestampName = "oauth_timestamp"
private val nonceName = "oauth_nonce"
private val versionName = "oauth_version"
private val version = "1.0"
private val keySpec: SecretKeySpec
init {
val signingKey = createEncoder()
.addQueryParameter(consumerSecret, null)
.addQueryParameter(tokenSecret, null)
.build().encodedQuery!!
keySpec = SecretKeySpec(signingKey.toByteArray(UTF_8), "HmacSHA1")
}
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val timestamp = generator.timestampSeconds()
val nonce = generator.nonce()
val signature = buildSignature(
request,
timestamp,
nonce
)
val authorization = buildAuthorizationHeaderValue(
signature,
timestamp,
nonce
)
return chain.proceed(
request.newBuilder()
.addHeader("Authorization", authorization)
.build()
)
}
private fun buildAuthorizationHeaderValue(
signature: String,
timestamp: String,
nonce: String
): String {
val authorizationParametersSize = 8
val authorizationParameters = ArrayList<Pair<String, String?>>(authorizationParametersSize)
parseParametersFromEncodedQuery(
createEncoder()
.addQueryParameter(realmName, realm)
.addQueryParameter(consumerKeyName, consumerKey)
.addQueryParameter(tokenName, accessToken)
.addQueryParameter(signatureMethodName, signatureMethod)
.addQueryParameter(signatureName, signature)
.addQueryParameter(timestampName, timestamp)
.addQueryParameter(nonceName, nonce)
.addQueryParameter(versionName, version)
.build().encodedQuery!!,
authorizationParameters
)
val authorizationBuilder = StringBuilder()
authorizationBuilder.append(authorizationPrefix)
for (i in authorizationParameters.indices) {
val parameter = authorizationParameters[i]
val parameterName = parameter.first
// None of the 8 added parameters above have null values.
val parameterValue = parameter.second!!
if (i != 0) {
authorizationBuilder.append(',')
}
authorizationBuilder.append(parameterName)
authorizationBuilder.append("=\"")
authorizationBuilder.append(parameterValue)
authorizationBuilder.append('"')
}
return authorizationBuilder.toString()
}
private fun buildSignature(
request: Request,
timestamp: String,
nonce: String
): String {
val requestMethod = request.method
val requestUrl = buildRequestUrlForSignature(
request.url
)
val requestParameters = buildRequestParametersForSignature(
request,
timestamp,
nonce
)
val signatureBaseString = createEncoder()
.addQueryParameter(requestMethod, null)
.addQueryParameter(requestUrl, null)
.addQueryParameter(requestParameters, null)
.build().encodedQuery!!
val result = hmacSha(signatureBaseString.toByteArray(UTF_8))
return ByteString.of(*result).base64()
}
private fun buildRequestUrlForSignature(
url: HttpUrl
): String {
return HttpUrl.Builder()
.scheme(url.scheme)
.host(url.host)
.encodedPath(url.encodedPath)
.apply {
if (url.isHttps) {
if (url.port != 443) {
port(url.port)
}
} else {
if (url.port != 80) {
port(url.port)
}
}
}
.build().toString()
}
private fun buildRequestParametersForSignature(
request: Request,
timestamp: String,
nonce: String
): String {
val encodedParameters = mutableListOf<Pair<String, String?>>()
val parametersEncoder = createEncoder()
// Exclude realm (and oauth_signature, of course).
parametersEncoder.addQueryParameter(consumerKeyName, consumerKey)
parametersEncoder.addQueryParameter(tokenName, accessToken)
parametersEncoder.addQueryParameter(signatureMethodName, signatureMethod)
parametersEncoder.addQueryParameter(timestampName, timestamp)
parametersEncoder.addQueryParameter(nonceName, nonce)
parametersEncoder.addQueryParameter(versionName, version)
parseParametersFromEncodedQuery(
parametersEncoder.build().encodedQuery!!,
encodedParameters
)
if (request.method == "POST") {
val requestBody = request.body!!
if (requestBody.contentType() == formUrlEncodedMediaType) {
parseParametersFromRequestBody(
requestBody,
encodedParameters
)
}
}
val requestUrlEncodedQuery = request.url.encodedQuery
if (requestUrlEncodedQuery != null) {
parseParametersFromEncodedQuery(
requestUrlEncodedQuery,
encodedParameters
)
}
encodedParameters.sortWith(ParameterComparator)
val requestParametersBuilder = StringBuilder()
for (i in encodedParameters.indices) {
val parameter = encodedParameters[i]
val parameterName = parameter.first
val parameterValue = parameter.second
if (i != 0) {
requestParametersBuilder.append('&')
}
requestParametersBuilder.append(parameterName)
if (parameterValue != null) {
requestParametersBuilder.append('=')
requestParametersBuilder.append(parameterValue)
}
}
return requestParametersBuilder.toString()
}
private fun parseParametersFromEncodedQuery(
encodedQuery: String,
encodedParameters: MutableList<Pair<String, String?>>
) {
var startIndex = 0
var name: String? = null
var i = 0
while (i != encodedQuery.length) {
when (val codePoint = encodedQuery.codePointAt(i)) {
'&'.code -> {
encodedParameters += if (name == null) {
encodedQuery.substring(startIndex, i) to null
} else {
name to encodedQuery.substring(startIndex, i)
}
name = null
startIndex = i + 1
i = startIndex
}
'='.code -> {
name = encodedQuery.substring(startIndex, i)
startIndex = i + 1
i = startIndex
}
else -> {
i += Character.charCount(codePoint)
}
}
}
encodedParameters += if (name == null) {
encodedQuery.substring(startIndex, i) to null
} else {
name to encodedQuery.substring(startIndex, i)
}
}
private fun parseParametersFromRequestBody(
requestBody: RequestBody,
encodedParameters: MutableList<Pair<String, String?>>
) {
if (requestBody is FormBody) {
parseParametersFromFormBody(
requestBody,
encodedParameters
)
} else {
val bodySource = Buffer()
requestBody.writeTo(bodySource)
parseParametersFromRequestBodySource(
bodySource,
encodedParameters
)
}
}
private fun parseParametersFromFormBody(
formBody: FormBody,
encodedParameters: MutableList<Pair<String, String?>>
) {
val parametersEncoder = createEncoder()
for (i in 0 until formBody.size) {
parametersEncoder.addQueryParameter(formBody.name(i), formBody.value(i))
}
parseParametersFromEncodedQuery(
parametersEncoder.build().encodedQuery!!,
encodedParameters
)
}
// Every parameter name in a form body has a parameter value, unlike query parameters, which makes
// this implementation simpler.
private fun parseParametersFromRequestBodySource(
source: BufferedSource,
encodedParameters: MutableList<Pair<String, String?>>
) {
val parametersDecoder = FormBody.Builder()
while (true) {
val nameEndIndex = source.indexOf('='.code.toByte())
check(nameEndIndex != -1L) { "Name with no value: " + source.readUtf8() }
val name = source.readUtf8(nameEndIndex)
source.skip(1)
val valueEndIndex = source.indexOf('&'.code.toByte())
if (valueEndIndex == -1L) {
val value = source.readUtf8()
parametersDecoder.addEncoded(name, value)
break
} else {
val value = source.readUtf8(valueEndIndex)
parametersDecoder.addEncoded(name, value)
source.skip(1)
}
}
parseParametersFromFormBody(
parametersDecoder.build(),
encodedParameters
)
}
// Add parameters with encoder.addQueryParameter(name, value)
// and then get the list of RFC-3986-encoded parameters with
// parseParametersFromEncodedQuery(encoder.build().encodedQuery!!, encodedParameters).
private fun createEncoder(): HttpUrl.Builder {
return HttpUrl.Builder()
.scheme("http")
.host("localhost")
}
private object ParameterComparator : Comparator<Pair<String, String?>> {
override fun compare(o1: Pair<String, String?>, o2: Pair<String, String?>): Int {
val keyComparison = o1.first.compareTo(o2.first)
if (keyComparison != 0) {
return keyComparison
}
val value1 = o1.second
val value2 = o2.second
if (value1 == null) {
if (value2 == null) {
return 0
}
return -1
}
if (value2 == null) {
return 1
}
return value1.compareTo(value2)
}
}
private fun hmacSha(input: ByteArray): ByteArray {
val mac: Mac
try {
mac = Mac.getInstance("HmacSHA1")
mac.init(keySpec)
} catch (e: NoSuchAlgorithmException) {
throw IllegalStateException(e)
} catch (e: InvalidKeyException) {
throw IllegalStateException(e)
}
return mac.doFinal(input)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment