Skip to content

Instantly share code, notes, and snippets.

@JakeWharton
Last active November 27, 2023 10:04
Show Gist options
  • Star 73 You must be signed in to star a gist
  • Fork 11 You must be signed in to fork a gist
  • Save JakeWharton/f26f19732f0c5907e1ab to your computer and use it in GitHub Desktop.
Save JakeWharton/f26f19732f0c5907e1ab to your computer and use it in GitHub Desktop.
An OkHttp interceptor which does OAuth1 signing. Requires Guava and Java 8, although those dependencies wouldn't be too hard to break if you didn't have them.
/*
* Copyright (C) 2015 Jake Wharton
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import com.google.common.escape.Escaper;
import com.google.common.net.UrlEscapers;
import com.squareup.okhttp.HttpUrl;
import com.squareup.okhttp.Interceptor;
import com.squareup.okhttp.Request;
import com.squareup.okhttp.RequestBody;
import com.squareup.okhttp.Response;
import java.io.IOException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Clock;
import java.util.Map;
import java.util.Random;
import java.util.SortedMap;
import java.util.TreeMap;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import okio.Buffer;
import okio.ByteString;
public final class Oauth1SigningInterceptor implements Interceptor {
private static final Escaper ESCAPER = UrlEscapers.urlFormParameterEscaper();
private static final String OAUTH_CONSUMER_KEY = "oauth_consumer_key";
private static final String OAUTH_NONCE = "oauth_nonce";
private static final String OAUTH_SIGNATURE = "oauth_signature";
private static final String OAUTH_SIGNATURE_METHOD = "oauth_signature_method";
private static final String OAUTH_SIGNATURE_METHOD_VALUE = "HMAC-SHA1";
private static final String OAUTH_TIMESTAMP = "oauth_timestamp";
private static final String OAUTH_ACCESS_TOKEN = "oauth_token";
private static final String OAUTH_VERSION = "oauth_version";
private static final String OAUTH_VERSION_VALUE = "1.0";
private final String consumerKey;
private final String consumerSecret;
private final String accessToken;
private final String accessSecret;
private final Random random;
private final Clock clock;
private Oauth1SigningInterceptor(String consumerKey, String consumerSecret, String accessToken,
String accessSecret, Random random, Clock clock) {
this.consumerKey = consumerKey;
this.consumerSecret = consumerSecret;
this.accessToken = accessToken;
this.accessSecret = accessSecret;
this.random = random;
this.clock = clock;
}
@Override public Response intercept(Chain chain) throws IOException {
return chain.proceed(signRequest(chain.request()));
}
public Request signRequest(Request request) throws IOException {
byte[] nonce = new byte[32];
random.nextBytes(nonce);
String oauthNonce = ByteString.of(nonce).base64().replaceAll("\\W", "");
String oauthTimestamp = String.valueOf(clock.millis());
String consumerKeyValue = ESCAPER.escape(consumerKey);
String accessTokenValue = ESCAPER.escape(accessToken);
SortedMap<String, String> parameters = new TreeMap<>();
parameters.put(OAUTH_CONSUMER_KEY, consumerKeyValue);
parameters.put(OAUTH_ACCESS_TOKEN, accessTokenValue);
parameters.put(OAUTH_NONCE, oauthNonce);
parameters.put(OAUTH_TIMESTAMP, oauthTimestamp);
parameters.put(OAUTH_SIGNATURE_METHOD, OAUTH_SIGNATURE_METHOD_VALUE);
parameters.put(OAUTH_VERSION, OAUTH_VERSION_VALUE);
HttpUrl url = request.httpUrl();
for (int i = 0; i < url.querySize(); i++) {
parameters.put(ESCAPER.escape(url.queryParameterName(i)),
ESCAPER.escape(url.queryParameterValue(i)));
}
RequestBody requestBody = request.body();
Buffer body = new Buffer();
requestBody.writeTo(body);
while (!body.exhausted()) {
long keyEnd = body.indexOf((byte) '=');
if (keyEnd == -1) throw new IllegalStateException("Key with no value: " + body.readUtf8());
String key = body.readUtf8(keyEnd);
body.skip(1); // Equals.
long valueEnd = body.indexOf((byte) '&');
String value = valueEnd == -1 ? body.readUtf8() : body.readUtf8(valueEnd);
if (valueEnd != -1) body.skip(1); // Ampersand.
parameters.put(key, value);
}
Buffer base = new Buffer();
String method = request.method();
base.writeUtf8(method);
base.writeByte('&');
base.writeUtf8(ESCAPER.escape(request.httpUrl().newBuilder().query(null).build().toString()));
base.writeByte('&');
boolean first = true;
for (Map.Entry<String, String> entry : parameters.entrySet()) {
if (!first) base.writeUtf8(ESCAPER.escape("&"));
first = false;
base.writeUtf8(ESCAPER.escape(entry.getKey()));
base.writeUtf8(ESCAPER.escape("="));
base.writeUtf8(ESCAPER.escape(entry.getValue()));
}
String signingKey =
ESCAPER.escape(consumerSecret) + "&" + ESCAPER.escape(accessSecret);
SecretKeySpec keySpec = new SecretKeySpec(signingKey.getBytes(), "HmacSHA1");
Mac mac;
try {
mac = Mac.getInstance("HmacSHA1");
mac.init(keySpec);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
throw new IllegalStateException(e);
}
byte[] result = mac.doFinal(base.readByteArray());
String signature = ByteString.of(result).base64();
String authorization = "OAuth "
+ OAUTH_CONSUMER_KEY + "=\"" + consumerKeyValue + "\", "
+ OAUTH_NONCE + "=\"" + oauthNonce + "\", "
+ OAUTH_SIGNATURE + "=\"" + ESCAPER.escape(signature) + "\", "
+ OAUTH_SIGNATURE_METHOD + "=\"" + OAUTH_SIGNATURE_METHOD_VALUE + "\", "
+ OAUTH_TIMESTAMP + "=\"" + oauthTimestamp + "\", "
+ OAUTH_ACCESS_TOKEN + "=\"" + accessTokenValue + "\", "
+ OAUTH_VERSION + "=\"" + OAUTH_VERSION_VALUE + "\"";
return request.newBuilder()
.addHeader("Authorization", authorization)
.build();
}
public static final class Builder {
private String consumerKey;
private String consumerSecret;
private String accessToken;
private String accessSecret;
private Random random = new SecureRandom();
private Clock clock = Clock.systemUTC();
public Builder consumerKey(String consumerKey) {
if (consumerKey == null) throw new NullPointerException("consumerKey = null");
this.consumerKey = consumerKey;
return this;
}
public Builder consumerSecret(String consumerSecret) {
if (consumerSecret == null) throw new NullPointerException("consumerSecret = null");
this.consumerSecret = consumerSecret;
return this;
}
public Builder accessToken(String accessToken) {
if (accessToken == null) throw new NullPointerException("accessToken == null");
this.accessToken = accessToken;
return this;
}
public Builder accessSecret(String accessSecret) {
if (accessSecret == null) throw new NullPointerException("accessSecret == null");
this.accessSecret = accessSecret;
return this;
}
public Builder random(Random random) {
if (random == null) throw new NullPointerException("random == null");
this.random = random;
return this;
}
public Builder clock(Clock clock) {
if (clock == null) throw new NullPointerException("clock == null");
this.clock = clock;
return this;
}
public Oauth1SigningInterceptor build() {
if (consumerKey == null) throw new IllegalStateException("consumerKey not set");
if (consumerSecret == null) throw new IllegalStateException("consumerSecret not set");
if (accessToken == null) throw new IllegalStateException("accessToken not set");
if (accessSecret == null) throw new IllegalStateException("accessSecret not set");
return new Oauth1SigningInterceptor(consumerKey, consumerSecret, accessToken, accessSecret, random,
clock);
}
}
}
/*
* Copyright (C) 2015 Jake Wharton
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import com.squareup.okhttp.FormEncodingBuilder;
import com.squareup.okhttp.Request;
import com.squareup.okhttp.RequestBody;
import java.io.IOException;
import java.time.Clock;
import java.time.Instant;
import java.util.Random;
import okio.ByteString;
import org.junit.Test;
import static com.google.common.truth.Truth.assertThat;
import static java.time.ZoneOffset.UTC;
public final class Oauth1SigningInterceptorTest {
@Test public void litmus() throws IOException {
// Data from https://dev.twitter.com/oauth/overview/authorizing-requests.
Random notRandom = new Random() {
@Override public void nextBytes(byte[] bytes) {
if (bytes.length != 32) throw new AssertionError();
ByteString hex = ByteString.decodeBase64("kYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4c+g");
byte[] nonce = hex.toByteArray();
System.arraycopy(nonce, 0, bytes, 0, nonce.length);
}
};
Clock clock = Clock.fixed(Instant.ofEpochMilli(1318622958), UTC);
Oauth1SigningInterceptor oauth1 = new Oauth1SigningInterceptor.Builder()
.consumerKey("xvz1evFS4wEEPTGEFPHBog")
.consumerSecret("kAcSOqF21Fu85e7zjz7ZN2U4ZRhfV3WpwPAoE3Z7kBw")
.accessToken("370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb")
.accessSecret("LswwdoUaIvS8ltyTt5jkRh4J50vUPVVHtR2YPi5kE")
.random(notRandom)
.clock(clock)
.build();
RequestBody body = new FormEncodingBuilder()
.add("status", "Hello Ladies + Gentlemen, a signed OAuth request!")
.build();
Request request = new Request.Builder()
.url("https://api.twitter.com/1/statuses/update.json?include_entities=true")
.post(body)
.build();
Request signed = oauth1.signRequest(request);
assertThat(signed.header("Authorization")).isEqualTo("OAuth "
+ "oauth_consumer_key=\"xvz1evFS4wEEPTGEFPHBog\", "
+ "oauth_nonce=\"kYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg\", "
+ "oauth_signature=\"tnnArxj06cWHq44gCs1OSKk%2FjLY%3D\", "
+ "oauth_signature_method=\"HMAC-SHA1\", "
+ "oauth_timestamp=\"1318622958\", "
+ "oauth_token=\"370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb\", "
+ "oauth_version=\"1.0\"");
}
}
@dgyuri
Copy link

dgyuri commented Jul 25, 2015

NPE when request's body is null (HTTP GET).

 RequestBody requestBody = request.body();
 Buffer body = new Buffer();
 requestBody.writeTo(body);

@dgyuri
Copy link

dgyuri commented Aug 4, 2015

I've got wrong signature when the request URL has the same query parameter multiple time. Oauth1SigningInterceptor puts query parameters in a map that cannot contain duplicate keys.

Example URL:
{base_url}/repositories/{user}/{repo}/issues?status=new&status=open

Retrofit:

@GET("/repositories/{user}/{repo}/issues")
Call<IssueFilterResult> issues(
        @Path("user") String user,
        @Path("slug") String repo,
        @Query("status") Iterable status);

Note: status is an iterable.

@serj-lotutovici
Copy link

If somebody is interested here is a fork of this gist targeting Java 7 (no Guava required).

@JakeWharton, thank you for this code, I just removed a monolith dependency from our code base that was used only for this.

@CosminMihuMDC
Copy link

Here is a fork that uses Percent Encoding method for URL. (https://tools.ietf.org/html/rfc5849#page-29)

1. Without Percent Encoding (https://gist.github.com/JakeWharton/f26f19732f0c5907e1ab)

URL Encoded:

POST https://example.com/search?keyword=cosm+mi&start=1&limit=10

base string with space encoded as '+':

POST&https%3A%2F%2Fexample.com%2Fsearch&keyword%3Dcosm%2Bmi%26limit%3D10%26oauth_consumer_key%3Ddebf8ca8-416f-4f2d-bf7d-ea045083a643%26oauth_nonce%3DYNxfWmf0IxCwTIY8n9wXbUvGcnJlScUqhrZ53hr1w3Y%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1494508387%26oauth_token%3D8700b3a7-b68a-4225-88ec-6c9117f7828c%26oauth_version%3D1.0%26start%3D1

2. Using Percent Encoding (https://gist.github.com/CosminMihuMDC/03b5396367f8dbe6b52cf89d6b88bcce)

URL Encoded:

POST https://example.com/search?keyword=cosm%20mi&start=1&limit=100

base string with space encoded as '%20':

POST&https%3A%2F%2Fexample.com%2Fsearch&keyword%3Dcosm%2520mi%26limit%3D10%26oauth_consumer_key%3Ddebf8ca8-416f-4f2d-bf7d-ea045083a643%26oauth_nonce%3DYNxfWmf0IxCwTIY8n9wXbUvGcnJlScUqhrZ53hr1w3Y%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1494508387%26oauth_token%3D8700b3a7-b68a-4225-88ec-6c9117f7828c%26oauth_version%3D1.0%26start%3D1

Thanks @JakeWharton, @serj-lotutovici.

@DaarSingh
Copy link

UrlEscapers.urlFormParameterEscaper causes crash when sending a post multipart image upload. I have added more detail on stackoverflow https://stackoverflow.com/questions/47253666/java-lang-illegalargumentexception-unexpected-low-surrogate-character-with . Any idea how to fix this ?

@Zoha131
Copy link

Zoha131 commented Jul 18, 2018

I am a very beginner developer. One thing I don't get yet that I have only cosumer_key and consumer_secret but the builder also requires accessToken and accessSecret. Then how can I use this interceptor? It will be very helpful if you can show me an example.

@thenewmr
Copy link

thenewmr commented Feb 19, 2019

I can't show you an example but the accessToken and accessSecret are user specific credentials that you will also need. So, typically your consumer key and consumer secret are your credentials for accessing an API and the accessToken and accessSecret are the user specific credentials which you have obtained for a user using the OAuth system.

For that stuff, take a look at the following links:
https://developers.google.com/api-client-library/java/google-oauth-java-client/reference/1.20.0/com/google/api/client/auth/oauth/package-summary

https://stackoverflow.com/questions/15194182/examples-for-oauth1-using-google-api-java-oauth/17137361#17137361

https://github.com/codepath/android_guides/wiki/Consuming-APIs-with-Retrofit

@polson
Copy link

polson commented Mar 13, 2019

If anyone is interested here is a fork using Kotlin that doesn't require an accessToken or accessSecret, and doesn't require Guava.

@redouane59
Copy link

Hey, Thanks for the job ! I only have one problem, when my query parameter contains a space, it seems that it is not signing correctly the request (i'm consuming Twitter API and it gaves me an authentication error only on this case). Do you have any idea about how to fix that ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment