diff --git a/README.rst b/README.rst index 892109e..1000c09 100644 --- a/README.rst +++ b/README.rst @@ -37,11 +37,7 @@ To install this plug-in, either download a binary version available from the `re The JAR file needs to be deployed to each run-time node and the admin node. For simple test deployments where the admin node is a run-time node, the JAR file only needs to be copied to one location. -The following dependent JAR file must be in the same directory, the plugin group directory: - -- `scribejava-core-7.1.1.jar `_ - -After running ``mvn package``, this will be placed into the ``target/libs`` directory and can be copied from there to the plugin group directory. +This plug-in has no third-party runtime dependencies; it only requires the Curity Identity Server SDK, which is provided by the server at runtime. After running ``mvn package``, the plug-in JAR is placed into the ``target/libs`` directory and can be copied from there to the plugin group directory. For a more detailed explanation of installing plug-ins, refer to the `Curity developer guide `_. diff --git a/pom.xml b/pom.xml index e2bc069..7401adf 100644 --- a/pom.xml +++ b/pom.xml @@ -68,25 +68,6 @@ 1.7.22 provided - - com.github.scribejava - scribejava-core - 7.1.1 - - - com.fasterxml.jackson.core - jackson-annotations - - - com.fasterxml.jackson.core - jackson-databind - - - com.fasterxml.jackson.core - jackson-core - - - diff --git a/src/main/java/io/curity/identityserver/plugin/twitter/authentication/CallbackRequestHandler.java b/src/main/java/io/curity/identityserver/plugin/twitter/authentication/CallbackRequestHandler.java index cb2d533..78c840c 100644 --- a/src/main/java/io/curity/identityserver/plugin/twitter/authentication/CallbackRequestHandler.java +++ b/src/main/java/io/curity/identityserver/plugin/twitter/authentication/CallbackRequestHandler.java @@ -16,10 +16,6 @@ package io.curity.identityserver.plugin.twitter.authentication; -import com.github.scribejava.core.builder.ServiceBuilder; -import com.github.scribejava.core.model.OAuth1AccessToken; -import com.github.scribejava.core.model.OAuth1RequestToken; -import com.github.scribejava.core.oauth.OAuth10aService; import io.curity.identityserver.plugin.twitter.config.TwitterAuthenticatorPluginConfig; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -30,16 +26,12 @@ import se.curity.identityserver.sdk.attribute.SubjectAttributes; import se.curity.identityserver.sdk.authentication.AuthenticationResult; import se.curity.identityserver.sdk.authentication.AuthenticatorRequestHandler; -import se.curity.identityserver.sdk.errors.ErrorCode; import se.curity.identityserver.sdk.service.ExceptionFactory; import se.curity.identityserver.sdk.service.SessionManager; import se.curity.identityserver.sdk.service.authentication.AuthenticatorInformationProvider; import se.curity.identityserver.sdk.web.Request; import se.curity.identityserver.sdk.web.Response; -import java.net.MalformedURLException; -import java.net.URI; -import java.net.URL; import java.util.Objects; import java.util.Optional; @@ -47,8 +39,6 @@ import static io.curity.identityserver.plugin.twitter.authentication.Constants.OAUTH_TOKEN_SECRET; import static io.curity.identityserver.plugin.twitter.authentication.Constants.SCREEN_NAME; import static io.curity.identityserver.plugin.twitter.authentication.Constants.USER_ID; -import static io.curity.identityserver.plugin.twitter.authentication.HttpClientUtil.getHttpClient; -import static io.curity.identityserver.plugin.twitter.descriptor.TwitterAuthenticatorPluginDescriptor.CALLBACK; public final class CallbackRequestHandler implements AuthenticatorRequestHandler @@ -56,8 +46,9 @@ public final class CallbackRequestHandler private static final Logger _logger = LoggerFactory.getLogger(CallbackRequestHandler.class); private final ExceptionFactory _exceptionFactory; - private final OAuth10aService _service; - private final OAuth1RequestToken _requestToken; + private final TwitterOAuthClient _client; + private final String _requestToken; + private final String _requestTokenSecret; private final AuthenticatorInformationProvider _authenticatorInformationProvider; public CallbackRequestHandler(TwitterAuthenticatorPluginConfig _config) @@ -66,13 +57,9 @@ public CallbackRequestHandler(TwitterAuthenticatorPluginConfig _config) _exceptionFactory = _config.getExceptionFactory(); _authenticatorInformationProvider = _config.getAuthenticatorInformationProvider(); - _service = new ServiceBuilder(_config.getApiKey()) - .apiSecret(_config.getApiSecretKey()) - .callback(createRedirectUri()) - .httpClient(getHttpClient(_config)) - .build(TwitterApi.instance()); - _requestToken = new OAuth1RequestToken(_config.getSessionManager().get(OAUTH_TOKEN).getValue().toString(), - sessionManager.get(OAUTH_TOKEN_SECRET).getValue().toString()); + _client = new TwitterOAuthClient(_config); + _requestToken = sessionManager.get(OAUTH_TOKEN).getValue().toString(); + _requestTokenSecret = sessionManager.get(OAUTH_TOKEN_SECRET).getValue().toString(); } @Override @@ -94,11 +81,11 @@ public Optional get(CallbackGetRequestModel requestModel, { handleError(requestModel); // Side-effect: Throws if error - OAuth1AccessToken accessToken; + TwitterOAuthClient.OAuthToken accessToken; try { - accessToken = _service.getAccessToken(_requestToken, requestModel.getOAuthVerifier()); + accessToken = _client.getAccessToken(_requestToken, _requestTokenSecret, requestModel.getOAuthVerifier()); } catch (Exception ex) { @@ -142,21 +129,6 @@ private void handleError(CallbackGetRequestModel requestModel) } } - private String createRedirectUri() - { - try - { - URI authUri = _authenticatorInformationProvider.getFullyQualifiedAuthenticationUri(); - - return new URL(authUri.toURL(), authUri.getPath() + "/" + CALLBACK).toString(); - } - catch (MalformedURLException e) - { - throw _exceptionFactory.internalServerException(ErrorCode.INVALID_REDIRECT_URI, - "Could not create redirect URI"); - } - } - @Override public Optional post(CallbackGetRequestModel requestModel, Response response) { diff --git a/src/main/java/io/curity/identityserver/plugin/twitter/authentication/HttpClientUtil.java b/src/main/java/io/curity/identityserver/plugin/twitter/authentication/HttpClientUtil.java deleted file mode 100644 index 8803f88..0000000 --- a/src/main/java/io/curity/identityserver/plugin/twitter/authentication/HttpClientUtil.java +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright 2020 Curity AB - * - * 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. - */ - -package io.curity.identityserver.plugin.twitter.authentication; - -import com.github.scribejava.core.httpclient.HttpClient; -import io.curity.identityserver.plugin.twitter.config.TwitterAuthenticatorPluginConfig; -import se.curity.identityserver.sdk.Nullable; - -import java.util.Optional; - -final class HttpClientUtil -{ - private HttpClientUtil() { } - - @Nullable - static HttpClient getHttpClient(TwitterAuthenticatorPluginConfig config) - { - Optional maybeHttpClient = config.getHttpClient(); - - return maybeHttpClient - .map(httpClient -> ScribeJavaHttpClientAdapter.from(config.getWebServiceClientFactory(), httpClient)) - .orElseGet(() -> ScribeJavaHttpClientAdapter.from(config.getWebServiceClientFactory())); - } -} diff --git a/src/main/java/io/curity/identityserver/plugin/twitter/authentication/ScribeJavaHttpClientAdapter.java b/src/main/java/io/curity/identityserver/plugin/twitter/authentication/ScribeJavaHttpClientAdapter.java deleted file mode 100644 index 3eeed44..0000000 --- a/src/main/java/io/curity/identityserver/plugin/twitter/authentication/ScribeJavaHttpClientAdapter.java +++ /dev/null @@ -1,251 +0,0 @@ -/* - * Copyright 2020 Curity AB - * - * 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. - */ - -package io.curity.identityserver.plugin.twitter.authentication; - -import com.github.scribejava.core.httpclient.HttpClient; -import com.github.scribejava.core.httpclient.multipart.MultipartPayload; -import com.github.scribejava.core.model.OAuthAsyncRequestCallback; -import com.github.scribejava.core.model.OAuthRequest; -import com.github.scribejava.core.model.Response; -import com.github.scribejava.core.model.Verb; -import se.curity.identityserver.sdk.Nullable; -import se.curity.identityserver.sdk.http.HttpRequest; -import se.curity.identityserver.sdk.http.HttpResponse; -import se.curity.identityserver.sdk.service.WebServiceClient; -import se.curity.identityserver.sdk.service.WebServiceClientFactory; - -import java.io.File; -import java.net.URI; -import java.nio.charset.StandardCharsets; -import java.util.AbstractMap; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.Future; -import java.util.stream.Collectors; - -public final class ScribeJavaHttpClientAdapter implements HttpClient -{ - private final WebServiceClient _webServiceClient; - - private static final Map _statusMessageByStatusCode; - - static - { - Map statusMessageByStatusCode = new HashMap<>(50); - - statusMessageByStatusCode.put(200, "OK"); - statusMessageByStatusCode.put(201, "Created"); - statusMessageByStatusCode.put(202, "Accepted"); - statusMessageByStatusCode.put(203, "Non-Authoritative Information"); - statusMessageByStatusCode.put(204, "No Content"); - statusMessageByStatusCode.put(205, "Reset Content"); - statusMessageByStatusCode.put(206, "Partial Content"); - statusMessageByStatusCode.put(300, "Multiple Choices"); - statusMessageByStatusCode.put(301, "Moved Permanently"); - statusMessageByStatusCode.put(302, "Found"); - statusMessageByStatusCode.put(303, "See Other"); - statusMessageByStatusCode.put(304, "Not Modified"); - statusMessageByStatusCode.put(305, "Use Proxy"); - statusMessageByStatusCode.put(306, "Switch Proxy"); - statusMessageByStatusCode.put(307, "Temporary Redirect"); - statusMessageByStatusCode.put(308, "Permanent Redirect"); - statusMessageByStatusCode.put(400, "Bad Request"); - statusMessageByStatusCode.put(401, "Unauthorized"); - statusMessageByStatusCode.put(402, "Payment Required"); - statusMessageByStatusCode.put(403, "Forbidden"); - statusMessageByStatusCode.put(404, "Not Found"); - statusMessageByStatusCode.put(405, "Method Not Allowed"); - statusMessageByStatusCode.put(406, "Not Acceptable"); - statusMessageByStatusCode.put(407, "Proxy Authentication Required"); - statusMessageByStatusCode.put(408, "Request Timeout"); - statusMessageByStatusCode.put(409, "Conflict"); - statusMessageByStatusCode.put(410, "Gone"); - statusMessageByStatusCode.put(411, "Length Required"); - statusMessageByStatusCode.put(412, "Precondition Failed"); - statusMessageByStatusCode.put(413, "Payload Too Large"); - statusMessageByStatusCode.put(414, "URI Too Long"); - statusMessageByStatusCode.put(415, "Unsupported Media Type"); - statusMessageByStatusCode.put(416, "Range Not Satisfiable"); - statusMessageByStatusCode.put(417, "Expectation Failed"); - statusMessageByStatusCode.put(420, "Enhance Your Calm"); - statusMessageByStatusCode.put(421, "Misdirected Request"); - statusMessageByStatusCode.put(425, "Too Early"); - statusMessageByStatusCode.put(426, "Upgrade Required"); - statusMessageByStatusCode.put(428, "Precondition Required"); - statusMessageByStatusCode.put(429, "Too Many Requests"); - statusMessageByStatusCode.put(431, "Request Header Fields Too Large"); - statusMessageByStatusCode.put(451, "Unavailable For Legal Reasons"); - statusMessageByStatusCode.put(500, "Internal Server Error"); - statusMessageByStatusCode.put(501, "Not Implemented"); - statusMessageByStatusCode.put(502, "Bad Gateway"); - statusMessageByStatusCode.put(503, "Service Unavailable"); - statusMessageByStatusCode.put(504, "Gateway Timeout"); - statusMessageByStatusCode.put(505, "HTTP Version Not Supported"); - statusMessageByStatusCode.put(506, "Variant Also Negotiates"); - statusMessageByStatusCode.put(510, "Not Extended"); - statusMessageByStatusCode.put(511, "Network Authentication Required"); - - _statusMessageByStatusCode = Collections.unmodifiableMap(statusMessageByStatusCode); - } - - private ScribeJavaHttpClientAdapter(WebServiceClient webServiceClient) - { - _webServiceClient = webServiceClient; - } - - public static HttpClient from( - WebServiceClientFactory webServiceClientFactory, - se.curity.identityserver.sdk.service.HttpClient httpClient) - { - WebServiceClient webServiceClient = webServiceClientFactory.create(httpClient).withHost(TwitterApi.BASE_URL); - - return new ScribeJavaHttpClientAdapter(webServiceClient); - } - - public static HttpClient from(WebServiceClientFactory webServiceClientFactory) - { - WebServiceClient webServiceClient = webServiceClientFactory.create(URI.create(TwitterApi.BASE_URL)); - - return new ScribeJavaHttpClientAdapter(webServiceClient); - } - - @Override - public Response execute(@Nullable String userAgent, - Map headers, - Verb httpVerb, - String completeUrl, - byte[] bodyContents) - { - int startPos = completeUrl.indexOf("/", "https://".length() + 1); - String path = completeUrl.substring(startPos); - - HttpRequest.Builder builder = _webServiceClient.withPath(path).request(); - - headers.forEach(builder::header); - - builder.accept("application/json"); - - if (userAgent != null) - { - builder.header("UserAgent", userAgent); - } - - builder.body(HttpRequest.fromByteArray(bodyContents)); - - HttpRequest request = builder.method(httpVerb.name()); - - HttpResponse response = request.response(); - - Map adaptedHeaders = response.headers() - .map() - .entrySet() - .stream() - .map(it -> new AbstractMap.SimpleEntry<>(it.getKey(), String.join(",", it.getValue()))) - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); - - String body = response.body(HttpResponse.asString()); - - String message = _statusMessageByStatusCode.get(response.statusCode()); - - return new Response(response.statusCode(), message, adaptedHeaders, body); - } - - @Override - public Response execute(String userAgent, - Map headers, - Verb httpVerb, - String completeUrl, - String bodyContents) - { - return execute(userAgent, headers, httpVerb, completeUrl, bodyContents.getBytes(StandardCharsets.UTF_8)); - } - - @Override - public Response execute(String userAgent, - Map headers, - Verb httpVerb, - String completeUrl, - File bodyContents) - { - throw new UnsupportedOperationException("file upload is not support"); - } - - @Override - public Response execute(String userAgent, - Map headers, - Verb httpVerb, - String completeUrl, - MultipartPayload bodyContents) - { - throw new UnsupportedOperationException("Multi-part body content is not support"); - } - - @Override - public void close() - { - // Void - } - - @Override - public Future executeAsync(String userAgent, - Map headers, - Verb httpVerb, - String completeUrl, - byte[] bodyContents, - OAuthAsyncRequestCallback callback, - OAuthRequest.ResponseConverter converter) - { - throw new UnsupportedOperationException("async execution is not support"); - } - - @Override - public Future executeAsync(String userAgent, - Map headers, - Verb httpVerb, - String completeUrl, - MultipartPayload bodyContents, - OAuthAsyncRequestCallback callback, - OAuthRequest.ResponseConverter converter) - { - throw new UnsupportedOperationException("async execution is not support"); - } - - @Override - public Future executeAsync(String userAgent, - Map headers, - Verb httpVerb, - String completeUrl, - String bodyContents, - OAuthAsyncRequestCallback callback, - OAuthRequest.ResponseConverter converter) - { - throw new UnsupportedOperationException("async execution is not support"); - } - - @Override - public Future executeAsync(String userAgent, - Map headers, - Verb httpVerb, - String completeUrl, - File bodyContents, - OAuthAsyncRequestCallback callback, - OAuthRequest.ResponseConverter converter) - { - throw new UnsupportedOperationException("async execution is not support"); - } -} diff --git a/src/main/java/io/curity/identityserver/plugin/twitter/authentication/TwitterApi.java b/src/main/java/io/curity/identityserver/plugin/twitter/authentication/TwitterApi.java deleted file mode 100644 index 0b330cd..0000000 --- a/src/main/java/io/curity/identityserver/plugin/twitter/authentication/TwitterApi.java +++ /dev/null @@ -1,42 +0,0 @@ -package io.curity.identityserver.plugin.twitter.authentication; - -import com.github.scribejava.core.builder.api.DefaultApi10a; - -// Based on https://raw.githubusercontent.com/scribejava/scribejava/master/scribejava-apis/src/main/java/com/github/scribejava/apis/TwitterApi.java -// Licensed under MIT -final class TwitterApi extends DefaultApi10a -{ - static final String BASE_URL = "https://api.twitter.com"; - - private static final String AUTHORIZE_URL = BASE_URL + "/oauth/authorize"; - private static final String REQUEST_TOKEN_RESOURCE = BASE_URL + "/oauth/request_token"; - private static final String ACCESS_TOKEN_RESOURCE = BASE_URL + "/oauth/access_token"; - - public static TwitterApi instance() - { - return InstanceHolder.INSTANCE; - } - - @Override - public String getAccessTokenEndpoint() - { - return ACCESS_TOKEN_RESOURCE; - } - - @Override - public String getRequestTokenEndpoint() - { - return REQUEST_TOKEN_RESOURCE; - } - - @Override - public String getAuthorizationBaseUrl() - { - return AUTHORIZE_URL; - } - - private static class InstanceHolder - { - private static final TwitterApi INSTANCE = new TwitterApi(); - } -} diff --git a/src/main/java/io/curity/identityserver/plugin/twitter/authentication/TwitterAuthenticatorRequestHandler.java b/src/main/java/io/curity/identityserver/plugin/twitter/authentication/TwitterAuthenticatorRequestHandler.java index 91db2ee..e774a98 100644 --- a/src/main/java/io/curity/identityserver/plugin/twitter/authentication/TwitterAuthenticatorRequestHandler.java +++ b/src/main/java/io/curity/identityserver/plugin/twitter/authentication/TwitterAuthenticatorRequestHandler.java @@ -16,14 +16,9 @@ package io.curity.identityserver.plugin.twitter.authentication; -import com.github.scribejava.core.builder.ServiceBuilder; -import com.github.scribejava.core.httpclient.HttpClient; -import com.github.scribejava.core.model.OAuth1RequestToken; -import com.github.scribejava.core.oauth.OAuth10aService; import io.curity.identityserver.plugin.twitter.config.TwitterAuthenticatorPluginConfig; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import se.curity.identityserver.sdk.Nullable; import se.curity.identityserver.sdk.attribute.Attribute; import se.curity.identityserver.sdk.authentication.AuthenticationResult; import se.curity.identityserver.sdk.authentication.AuthenticatorRequestHandler; @@ -42,7 +37,6 @@ import static io.curity.identityserver.plugin.twitter.authentication.Constants.OAUTH_TOKEN; import static io.curity.identityserver.plugin.twitter.authentication.Constants.OAUTH_TOKEN_SECRET; -import static io.curity.identityserver.plugin.twitter.authentication.HttpClientUtil.getHttpClient; import static io.curity.identityserver.plugin.twitter.descriptor.TwitterAuthenticatorPluginDescriptor.CALLBACK; public final class TwitterAuthenticatorRequestHandler implements AuthenticatorRequestHandler @@ -65,24 +59,20 @@ public Optional get(Request request, Response response) { _logger.debug("GET request received for authentication"); - OAuth10aService service = new ServiceBuilder(_config.getApiKey()) - .apiSecret(_config.getApiSecretKey()) - .callback(createRedirectUri()) - .httpClient(getHttpClient(_config)) - .build(TwitterApi.instance()); + TwitterOAuthClient client = new TwitterOAuthClient(_config); - OAuth1RequestToken requestToken; + TwitterOAuthClient.OAuthToken requestToken; try { - requestToken = service.getRequestToken(); + requestToken = client.getRequestToken(createRedirectUri()); } catch (Exception e) { throw _exceptionFactory.externalServiceException("Error authenticating with Twitter: " + e.getMessage()); } - String authorizationEndpoint = service.getAuthorizationUrl(requestToken); + String authorizationEndpoint = client.getAuthorizationUrl(requestToken.getToken()); SessionManager sessionManager = _config.getSessionManager(); diff --git a/src/main/java/io/curity/identityserver/plugin/twitter/authentication/TwitterOAuthClient.java b/src/main/java/io/curity/identityserver/plugin/twitter/authentication/TwitterOAuthClient.java new file mode 100644 index 0000000..d160767 --- /dev/null +++ b/src/main/java/io/curity/identityserver/plugin/twitter/authentication/TwitterOAuthClient.java @@ -0,0 +1,333 @@ +/* + * Copyright 2020 Curity AB + * + * 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. + */ + +package io.curity.identityserver.plugin.twitter.authentication; + +import io.curity.identityserver.plugin.twitter.config.TwitterAuthenticatorPluginConfig; +import se.curity.identityserver.sdk.http.HttpRequest; +import se.curity.identityserver.sdk.http.HttpResponse; +import se.curity.identityserver.sdk.service.Json; +import se.curity.identityserver.sdk.service.WebServiceClient; +import se.curity.identityserver.sdk.service.WebServiceClientFactory; + +import java.io.UnsupportedEncodingException; +import java.net.URI; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.stream.Collectors; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * Minimal implementation of Twitter's OAuth 1.0a (3-legged) client, replacing the previous dependency on + * scribejava-core. Requests are signed with HMAC-SHA1 and sent through the SDK-provided {@link WebServiceClient}. + */ +final class TwitterOAuthClient +{ + static final String BASE_URL = "https://api.twitter.com"; + + private static final String REQUEST_TOKEN_PATH = "/oauth/request_token"; + private static final String ACCESS_TOKEN_PATH = "/oauth/access_token"; + private static final String AUTHORIZE_URL = BASE_URL + "/oauth/authorize"; + + private static final String SIGNATURE_METHOD = "HMAC-SHA1"; + private static final String HMAC_ALGORITHM = "HmacSHA1"; + private static final String OAUTH_VERSION = "1.0"; + + private final String _consumerKey; + private final String _consumerSecret; + private final WebServiceClient _webServiceClient; + private final Json _json; + private final SecureRandom _random = new SecureRandom(); + + TwitterOAuthClient(TwitterAuthenticatorPluginConfig config) + { + _consumerKey = config.getApiKey(); + _consumerSecret = config.getApiSecretKey(); + _json = config.getJson(); + _webServiceClient = createWebServiceClient(config); + } + + private static WebServiceClient createWebServiceClient(TwitterAuthenticatorPluginConfig config) + { + WebServiceClientFactory factory = config.getWebServiceClientFactory(); + + return config.getHttpClient() + .map(httpClient -> factory.create(httpClient).withHost(URI.create(BASE_URL).getHost())) + .orElseGet(() -> factory.create(URI.create(BASE_URL))); + } + + /** + * Obtains a temporary request token from Twitter (first leg of the OAuth 1.0a flow). + * + * @param callbackUrl the URL Twitter should redirect the resource owner back to after authorization + * @return the request token and its secret + */ + OAuthToken getRequestToken(String callbackUrl) + { + Map oauthParameters = baseOAuthParameters(); + oauthParameters.put("oauth_callback", callbackUrl); + + return sendSignedPost(REQUEST_TOKEN_PATH, oauthParameters, ""); + } + + /** + * Builds the URL the resource owner should be redirected to in order to authorize the application + * (second leg of the OAuth 1.0a flow). + * + * @param requestToken the token returned by {@link #getRequestToken(String)} + * @return the authorization URL + */ + String getAuthorizationUrl(String requestToken) + { + return AUTHORIZE_URL + "?oauth_token=" + encode(requestToken); + } + + /** + * Exchanges an authorized request token for an access token (third leg of the OAuth 1.0a flow). + * + * @param requestToken the request token previously obtained + * @param requestTokenSecret the secret associated with the request token + * @param verifier the {@code oauth_verifier} returned to the callback by Twitter + * @return the access token, its secret and any additional parameters returned by Twitter (e.g. user_id) + */ + OAuthToken getAccessToken(String requestToken, String requestTokenSecret, String verifier) + { + Map oauthParameters = baseOAuthParameters(); + oauthParameters.put("oauth_token", requestToken); + oauthParameters.put("oauth_verifier", verifier); + + return sendSignedPost(ACCESS_TOKEN_PATH, oauthParameters, requestTokenSecret); + } + + private OAuthToken sendSignedPost(String path, Map oauthParameters, String tokenSecret) + { + String signature = sign("POST", BASE_URL + path, oauthParameters, tokenSecret); + oauthParameters.put("oauth_signature", signature); + + HttpResponse response = _webServiceClient.withPath(path) + .request() + .header("Authorization", buildAuthorizationHeader(oauthParameters)) + .body(HttpRequest.noBody()) + .method("POST") + .response(); + + int statusCode = response.statusCode(); + String body = response.body(HttpResponse.asString()); + + if (statusCode < 200 || statusCode >= 300) + { + throw new TwitterOAuthException(describeError(statusCode, body)); + } + + Map parameters = parseFormEncoded(body); + + return new OAuthToken(parameters.get("oauth_token"), parameters.get("oauth_token_secret"), parameters); + } + + private Map baseOAuthParameters() + { + Map parameters = new LinkedHashMap<>(); + + parameters.put("oauth_consumer_key", _consumerKey); + parameters.put("oauth_nonce", nonce()); + parameters.put("oauth_signature_method", SIGNATURE_METHOD); + parameters.put("oauth_timestamp", Long.toString(System.currentTimeMillis() / 1000L)); + parameters.put("oauth_version", OAUTH_VERSION); + + return parameters; + } + + private String sign(String method, String baseUrl, Map parameters, String tokenSecret) + { + // See https://docs.x.com/fundamentals/authentication/oauth-1-0a/creating-a-signature + String parameterString = parameters.entrySet().stream() + .map(entry -> encode(entry.getKey()) + "=" + encode(entry.getValue())) + .sorted() + .collect(Collectors.joining("&")); + + String signatureBaseString = method.toUpperCase() + '&' + encode(baseUrl) + '&' + encode(parameterString); + String signingKey = encode(_consumerSecret) + '&' + encode(tokenSecret == null ? "" : tokenSecret); + + try + { + Mac mac = Mac.getInstance(HMAC_ALGORITHM); + mac.init(new SecretKeySpec(signingKey.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM)); + byte[] signature = mac.doFinal(signatureBaseString.getBytes(StandardCharsets.UTF_8)); + + return Base64.getEncoder().encodeToString(signature); + } + catch (NoSuchAlgorithmException | InvalidKeyException e) + { + throw new IllegalStateException("Unable to compute the OAuth signature", e); + } + } + + private static String buildAuthorizationHeader(Map oauthParameters) + { + return "OAuth " + oauthParameters.entrySet().stream() + .sorted(Comparator.comparing(Map.Entry::getKey)) + .map(entry -> encode(entry.getKey()) + "=\"" + encode(entry.getValue()) + '"') + .collect(Collectors.joining(", ")); + } + + private String describeError(int statusCode, String body) + { + // Twitter error responses are usually JSON, e.g. {"errors":[{"code":32,"message":"..."}]}. + if (body != null && !body.isEmpty()) + { + try + { + Object errors = _json.fromJson(body).get("errors"); + + if (errors instanceof List && !((List) errors).isEmpty()) + { + Object firstError = ((List) errors).get(0); + + if (firstError instanceof Map) + { + Object message = ((Map) firstError).get("message"); + + if (message != null) + { + return "HTTP " + statusCode + " - " + message; + } + } + } + } + catch (Json.JsonException e) + { + // Not a JSON body (e.g. a form-encoded or plain-text error); fall back to the raw body below. + } + + return "HTTP " + statusCode + " - " + body; + } + + return "HTTP " + statusCode; + } + + private static Map parseFormEncoded(String body) + { + Map parameters = new TreeMap<>(); + + if (body == null || body.isEmpty()) + { + return parameters; + } + + for (String pair : body.split("&")) + { + int separator = pair.indexOf('='); + + if (separator > 0) + { + parameters.put(urlDecode(pair.substring(0, separator)), urlDecode(pair.substring(separator + 1))); + } + } + + return parameters; + } + + private String nonce() + { + byte[] bytes = new byte[32]; + _random.nextBytes(bytes); + + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes).replaceAll("[^A-Za-z0-9]", ""); + } + + private static String encode(String value) + { + try + { + // RFC 3986 percent-encoding, as required by the OAuth 1.0a signature specification. + return URLEncoder.encode(value, StandardCharsets.UTF_8.name()) + .replace("+", "%20") + .replace("*", "%2A") + .replace("%7E", "~"); + } + catch (UnsupportedEncodingException e) + { + throw new IllegalStateException("UTF-8 is not supported", e); + } + } + + private static String urlDecode(String value) + { + try + { + return URLDecoder.decode(value, StandardCharsets.UTF_8.name()); + } + catch (UnsupportedEncodingException e) + { + throw new IllegalStateException("UTF-8 is not supported", e); + } + } + + /** + * An OAuth token together with its secret and any additional parameters returned by Twitter alongside it. + */ + static final class OAuthToken + { + private final String _token; + private final String _tokenSecret; + private final Map _parameters; + + private OAuthToken(String token, String tokenSecret, Map parameters) + { + _token = token; + _tokenSecret = tokenSecret; + _parameters = parameters; + } + + String getToken() + { + return _token; + } + + String getTokenSecret() + { + return _tokenSecret; + } + + String getParameter(String name) + { + return _parameters.get(name); + } + } + + /** + * Thrown when Twitter responds with an error during any leg of the OAuth 1.0a flow. + */ + static final class TwitterOAuthException extends RuntimeException + { + TwitterOAuthException(String message) + { + super(message); + } + } +} diff --git a/src/main/java/io/curity/identityserver/plugin/twitter/config/TwitterAuthenticatorPluginConfig.java b/src/main/java/io/curity/identityserver/plugin/twitter/config/TwitterAuthenticatorPluginConfig.java index e9f61f5..28eb1cb 100644 --- a/src/main/java/io/curity/identityserver/plugin/twitter/config/TwitterAuthenticatorPluginConfig.java +++ b/src/main/java/io/curity/identityserver/plugin/twitter/config/TwitterAuthenticatorPluginConfig.java @@ -20,6 +20,7 @@ import se.curity.identityserver.sdk.config.annotation.Description; import se.curity.identityserver.sdk.service.ExceptionFactory; import se.curity.identityserver.sdk.service.HttpClient; +import se.curity.identityserver.sdk.service.Json; import se.curity.identityserver.sdk.service.SessionManager; import se.curity.identityserver.sdk.service.WebServiceClientFactory; import se.curity.identityserver.sdk.service.authentication.AuthenticatorInformationProvider; @@ -45,4 +46,6 @@ public interface TwitterAuthenticatorPluginConfig extends Configuration AuthenticatorInformationProvider getAuthenticatorInformationProvider(); WebServiceClientFactory getWebServiceClientFactory(); + + Json getJson(); }