-
-
Save JakeWharton/f26f19732f0c5907e1ab to your computer and use it in GitHub Desktop.
/* | |
* 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\""); | |
} | |
} |
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.
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.
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.
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 ?
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.
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://github.com/codepath/android_guides/wiki/Consuming-APIs-with-Retrofit
If anyone is interested here is a fork using Kotlin that doesn't require an accessToken or accessSecret, and doesn't require Guava.
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 ?
NPE when request's body is null (HTTP GET).