blob: 292a959deec6c188b2b040c5a9f6e1c4ef13245d [file] [log] [blame]
/*
* Copyright (c) 2016 Manumitting Technologies Inc and others.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v10.html
*
* Contributors:
* Manumitting Technologies Inc - initial API and implementation
*/
package org.eclipse.userstorage.oauth;
import org.eclipse.userstorage.IStorageService;
import org.eclipse.userstorage.internal.Activator;
import org.eclipse.userstorage.internal.Session;
import org.eclipse.userstorage.internal.oauth.AuthToken;
import org.eclipse.userstorage.internal.oauth.OAuthCredentialsPersistence;
import org.eclipse.userstorage.internal.oauth.UIFacade;
import org.eclipse.userstorage.internal.oauth.ui.SWTInternalBrowserFacade;
import org.eclipse.userstorage.internal.util.AES;
import org.eclipse.userstorage.internal.util.IOUtil;
import org.eclipse.userstorage.internal.util.JSONUtil;
import org.eclipse.userstorage.internal.util.ProxyUtil;
import org.eclipse.userstorage.internal.util.StringUtil;
import org.eclipse.userstorage.spi.Credentials;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.core.runtime.Status;
import org.eclipse.jface.window.IShellProvider;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.NameValuePair;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.client.fluent.Request;
import org.apache.http.client.fluent.Response;
import org.apache.http.client.utils.DateUtils;
import org.apache.http.client.utils.URIBuilder;
import org.apache.http.entity.ContentType;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.util.EntityUtils;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.security.GeneralSecurityException;
import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* A crendentials provider that authenticates and authorizes using the
* Eclipse OAuth provider.
*
* <p>Normal flow:</p>
* <ul>
* <li> REQUEST AUTHORIZATION: GET <tt>https://accounts.eclipse.org/oauth2/authorize?response_type=code&amp;client_id=_XXX_&amp;client_secret=_XXX_&amp;scope=_XXX_%20_XXX_%20_XXX_&amp;redirect_uri=_XXX_&amp;state=_XXX_</tt> </li>
* <li> This may redirect a bit: <tt>302 Found</tt> <tt>https://accounts.eclipse.org/user/login?destination=oauth2/authorize</tt> </li>
* <li> LOGIN SUCCESS <tt><em>http://localhost/</em>?code=a74a9f4ec60fa71121ddeb0219d8d9bcee9bf139&amp;state=_XXX_</tt> </li>
* <li> REQUEST AUTHORIZATION: POST <tt>https://accounts.eclipse.org/oauth2/token</tt> with body <tt>grant_type=authorization_code&amp;client_id=_XXX_&amp;client_secret=_XXX_&amp;redirect_uri=_XXX_&amp;code=_XXX_</tt></li>
* <li> Should respond with <tt>200 OK</tt> and body is a JSON auth token. Or may respond with a 4XX if the code is invalid.</li>
* </ul>
* <p>Error flow:</p>
* <ul>
* <li> <tt>https://accounts.eclipse.org/oauth2/authorize?response_type=code&amp;client_id=_XXX_&amp;client_secret=_XXX_&amp;scope=_XXX_%20_XXX_%20_XXX_&amp;redirect_uri=_XXX_&amp;state=_XXX_</tt>
* <li> <tt><em>http://localhost/</em>?error=access_denied&amp;error_description=The+user+denied+access+to+your+application&amp;state=_XXX_</tt></li>
* </ul>
* <p>Refresh flow:</p>
* <ul>
* <li> REQUEST AUTHORIZATION: POST <tt>https://accounts.eclipse.org/oauth2/token</tt> with body <tt>grant_type=refresh_token&amp;client_id=_XXX_&amp;client_secret=_XXX_&amp;redirect_uri=_XXX_&amp;refresh_token=_XXX_</tt></li>
* <li> Should respond with 200 OK and body is a JSON auth token. Or may respond with a 4XX if the refresh token has expired or has been repudiated.</li>
* </ul>
*/
public final class EclipseOAuthCredentialsProvider extends OAuthCredentialsProvider
{
private static final String AUTHORIZATION = "Authorization";
private static final boolean DEBUG = Boolean.getBoolean("org.eclipse.userstorage.session.debug");
private static final String PLUGIN_ID = "org.eclipse.userstorage.oauth";
private static void debug(String message)
{
if (DEBUG)
{
System.out.println("<EclipseOAuthCredentialsProvider> " + message);
}
}
private static void log(int statusCode, String message, Throwable exception)
{
Activator.log(new Status(statusCode, PLUGIN_ID, message, exception));
}
private OAuthCredentialsPersistence persister;
UIFacade uiFacade; // package protected for testing purposes
/**
* @param clientId the OAuth identifier assigned to the application
* @param clientSecret the OAuth secret assigned to the application
* @param scopes the OAuth scopes requested by this application
* @param expectedCallback the expected callback URL to indicate success/failure
*/
public EclipseOAuthCredentialsProvider(String clientId, String clientSecret, String[] scopes, URI expectedCallback) throws URISyntaxException
{
this(null, clientId, clientSecret, scopes, expectedCallback);
}
/**
* @param authService if null then use the service's serviceURI
* @param clientId the OAuth identifier assigned to the application
* @param clientSecret the OAuth secret assigned to the application
* @param scopes the OAuth scopes requested by this application
* @param expectedCallback the expected callback URL to indicate success/failure
*/
public EclipseOAuthCredentialsProvider(URI authService, String clientId, String clientSecret, String[] scopes, URI expectedCallback)
{
super(authService, clientId, clientSecret, scopes, expectedCallback);
persister = OAuthCredentialsPersistence.standard();
uiFacade = new SWTInternalBrowserFacade();
}
public void setShell(IShellProvider provider)
{
if (uiFacade instanceof SWTInternalBrowserFacade)
{
((SWTInternalBrowserFacade)uiFacade).setShell(provider);
}
}
@Override
public Credentials provideCredentials(IStorageService service, boolean reauthentication)
{
try
{
if (!reauthentication)
{
// Try the refresh token
AuthToken authToken = refreshAuthToken(service);
if (authToken != null)
{
debug("Returning cached credentials");
return asCredentials(service, authToken);
}
}
/* Start the full process */
debug("Starting OAuth authorization process");
String providerName = service.getServiceLabel();
URI authURI = getAuthorizationURI(service);
URI callbackURI = getRegisteredCallback();
URI authCodeURI = uiFacade.obtainAuthCode(providerName, authURI, callbackURI);
if (authCodeURI == null)
{
debug("User Cancelled login process");
return null;
}
List<NameValuePair> params = new URIBuilder(authCodeURI).getQueryParams();
if (findValue(params, "error") != null)
{
debug("Remote reported OAuth Error: " + authCodeURI.getRawQuery());
uiFacade.showError("OAuth Error", "An error occurred retrieving during the authorization process.",
new Status(IStatus.ERROR, PLUGIN_ID, "Error returned: " + authCodeURI.getRawQuery()));
return null;
}
debug("Login success: retrieving access token...");
AuthToken authToken = requestAuthToken(service, authCodeURI);
if (authToken == null)
{
debug("Failed to turn Access Code -> Auth Token");
uiFacade.showError("OAuth Error", "Unable to retrieve Authorization Code",
new Status(IStatus.ERROR, PLUGIN_ID, "Unable to turn authorization code to an authoriation token"));
return null;
}
return asCredentials(service, authToken);
}
catch (IOException ex)
{
log(IStatus.WARNING, "Unable to fetch credential", ex);
return null;
}
catch (URISyntaxException ex)
{
log(IStatus.WARNING, "Invalid parameters", ex);
return null;
}
}
private String findValue(List<NameValuePair> queryParameters, String key)
{
for (NameValuePair pair : queryParameters)
{
if (key.equals(pair.getName()))
{
return pair.getValue();
}
}
return null;
}
@Override
public Request configureRequest(Request request, URI uri, Credentials credentials)
{
AuthToken token = AuthToken.deserialize(credentials.getPassword());
return request.addHeader(AUTHORIZATION, token.getTokenType() + " " + token.getAccessToken());
}
@Override
public boolean isValid(Credentials credentials)
{
if (!super.isValid(credentials))
{
return false;
}
try
{
AuthToken token = AuthToken.deserialize(credentials.getPassword());
return token != null && !token.isExpired();
}
catch (IllegalArgumentException e)
{
return false;
}
}
@Override
public Credentials getCredentials(IStorageService service)
{
AuthToken authToken = retrieveStoredAuthToken(service);
if (authToken == null || authToken.isExpired())
{
return null;
}
return asCredentials(service, authToken);
}
@Override
public boolean hasCredentials(IStorageService service)
{
AuthToken authToken = retrieveStoredAuthToken(service);
return authToken != null && !authToken.isExpired();
}
private AuthToken requestAuthToken(IStorageService service, URI uri) throws URISyntaxException, IOException
{
Pattern codePattern = Pattern.compile(".*[?&]code=([^&]+).*");
Matcher m = codePattern.matcher(uri.toASCIIString());
if (!m.matches())
{
debug("OAuth Access Code URI doesn't have an access code!");
return null;
}
String accessCode = m.group(1);
//@formatter:off
URI authorizationURI = new URIBuilder(getAuthorizationServiceBaseURI(service))
.setPath("/oauth2/token")
.build();
Request request = Request.Post(authorizationURI)
.bodyForm(
new BasicNameValuePair("grant_type", "authorization_code"),
new BasicNameValuePair("client_id", getClientId()),
new BasicNameValuePair("client_secret", getClientSecret()),
new BasicNameValuePair("redirect_uri", getRegisteredCallback().toASCIIString()),
new BasicNameValuePair("code", accessCode));
//@formatter:on
Response result = ProxyUtil.proxyAuthentication(executor, authorizationURI).execute(request);
HttpResponse response = result.returnResponse();
if (response.getStatusLine().getStatusCode() != HttpStatus.SC_OK)
{
log(IStatus.ERROR, "Authorization/authentication failed: " + response, null);
return null;
}
String jsonRep = EntityUtils.toString(response.getEntity());
Date date = DateUtils.parseDate(response.getFirstHeader("Date").getValue());
AuthToken authToken = new AuthToken(jsonRep, date);
String email = retrieveUserDetails(service, authToken);
persistAuthToken(service, authToken, email);
return authToken;
}
private String retrieveUserDetails(IStorageService service, AuthToken token)
{
URI refreshURI;
if (!contains(getScopes(), "profile"))
{
return null;
}
try
{
refreshURI = new URIBuilder(service.getServiceURI()).setPath("/account/profile").build();
//@formatter:off
Request request = Request.Get(refreshURI)
.addHeader(Session.ACCEPT, ContentType.APPLICATION_JSON.getMimeType())
.addHeader(Session.CONTENT_TYPE, ContentType.APPLICATION_JSON.getMimeType())
.addHeader(Session.USER_AGENT, Session.USER_AGENT_ID)
.addHeader(AUTHORIZATION, token.getTokenType() + " " + token.getAccessToken());
//@formatter:on
Response result = ProxyUtil.proxyAuthentication(executor, refreshURI).execute(request);
HttpResponse response = result.returnResponse();
if (response.getStatusLine().getStatusCode() != HttpStatus.SC_OK)
{
return null;
}
String jsonRep = EntityUtils.toString(response.getEntity());
Object o = JSONUtil.parse(IOUtil.streamUTF(jsonRep), null);
if (!(o instanceof List<?> && ((List<?>)o).get(0) instanceof Map<?, ?>))
{
throw new IllegalArgumentException("not a valid profile object");
}
return (String)((Map<?, ?>)((List<?>)o).get(0)).get("mail");
}
catch (URISyntaxException ex)
{
log(IStatus.WARNING, "Unable to construct remote URI", ex);
}
catch (ClientProtocolException ex)
{
log(IStatus.WARNING, "Unable to request user profile information", ex);
}
catch (IOException ex)
{
log(IStatus.WARNING, "Unable to parse user profile information", ex);
}
return null;
}
private boolean contains(String[] scopes, String scope)
{
for (String s : scopes)
{
if (s.equals(scope))
{
return true;
}
}
return false;
}
private AuthToken refreshAuthToken(IStorageService service)
{
// check to see if we have previously-saved credentials.
AuthToken authToken = retrieveStoredAuthToken(service);
if (authToken == null)
{
return null;
}
if (authToken.getRefreshToken() == null)
{
discardStoredAuthToken(service);
return null;
}
try
{
//@formatter:off
URI refreshURI = new URIBuilder(getAuthorizationServiceBaseURI(service))
.setPath("/oauth2/token")
.build();
Request request = Request.Post(refreshURI)
//.addHeader("Content-Type", ContentType.APPLICATION_FORM_URLENCODED.getMimeType())
.bodyForm(
new BasicNameValuePair("grant_type", "refresh_token"),
new BasicNameValuePair("refresh_token", authToken.getRefreshToken()),
new BasicNameValuePair("client_id", getClientId()),
new BasicNameValuePair("client_secret", getClientSecret()));
//@formatter:on
Response result = ProxyUtil.proxyAuthentication(executor, refreshURI).execute(request);
HttpResponse response = result.returnResponse();
if (response.getStatusLine().getStatusCode() != HttpStatus.SC_OK)
{
log(IStatus.WARNING, "Authorization/authentication failed when refreshing auth token: " + response, null);
discardStoredAuthToken(service);
return null;
}
String jsonRep = EntityUtils.toString(response.getEntity());
Date date = DateUtils.parseDate(response.getFirstHeader("Date").getValue());
authToken = new AuthToken(jsonRep, date);
String email = retrieveUserDetails(service, authToken);
persistAuthToken(service, authToken, email);
return authToken;
}
catch (IOException ex)
{
log(IStatus.ERROR, "Unparseable response", ex);
return null;
}
catch (URISyntaxException ex)
{
log(IStatus.ERROR, "Invalid URI", ex);
return null;
}
}
private AuthToken retrieveStoredAuthToken(IStorageService service)
{
try
{
String encryptedForm = persister.getAccountToken(getClientId(), getAuthorizationServiceBaseURI(service).toString());
if (encryptedForm == null)
{
return null;
}
String serializedForm = AES.decrypt(encryptedForm, getClientSecretAsChars());
return AuthToken.deserialize(serializedForm);
}
catch (IllegalArgumentException e)
{
log(IStatus.WARNING, "Unable to deserialize stored token", e);
return null;
}
catch (GeneralSecurityException e)
{
log(IStatus.WARNING, "Unable to decrypt stored token", e);
return null;
}
}
private void discardStoredAuthToken(IStorageService service)
{
persister.removeAccountToken(getClientId(), getAuthorizationServiceBaseURI(service).toString());
}
private void persistAuthToken(IStorageService service, AuthToken authToken, String email)
{
try
{
String serializedForm = authToken.serialize();
String encryptedForm = AES.encrypt(serializedForm, getClientSecretAsChars());
persister.putAccountToken(getClientId(), getAuthorizationServiceBaseURI(service).toString(), encryptedForm, email);
}
catch (GeneralSecurityException e)
{
log(IStatus.ERROR, "Unable to encrypt auth token for storage", e);
}
catch (IOException e)
{
log(IStatus.ERROR, "Unable to serialize auth token for storage", e);
}
}
private Credentials asCredentials(IStorageService service, AuthToken authToken)
{
try
{
return new Credentials(service.getServiceURI() + "|" + getClientId(), authToken.serialize());
}
catch (IOException e)
{
log(IStatus.WARNING, "Unable to serialize auth token", e);
return null;
}
}
/**
* Generate the URI that starts the User Authorization flow
*/
protected URI getAuthorizationURI(IStorageService service) throws URISyntaxException
{
//@formatter:off
return new URIBuilder(getAuthorizationServiceBaseURI(service))
.setPath("/oauth2/authorize")
.addParameter("response_type", "code")
.addParameter("client_id", getClientId())
.addParameter("client_secret", getClientSecret())
.addParameter("scope", StringUtil.join(" ", getScopes()))
.addParameter("redirect_uri", getRegisteredCallback().toASCIIString())
.addParameter("state", stateCode)
.build();
//@formatter:on
}
}