Last active
May 23, 2025 11:35
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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