com.microsoft.services.msa.LiveAuthClient.java Source code

Java tutorial

Introduction

Here is the source code for com.microsoft.services.msa.LiveAuthClient.java

Source

// ------------------------------------------------------------------------------
// Copyright (c) 2014 Microsoft Corporation
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
//  of this software and associated documentation files (the "Software"), to deal
//  in the Software without restriction, including without limitation the rights
//  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
//  copies of the Software, and to permit persons to whom the Software is
//  furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
//  all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
//  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
//  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
//  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
//  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
//  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
//  THE SOFTWARE.
// ------------------------------------------------------------------------------

package com.microsoft.services.msa;

import android.app.Activity;
import android.app.Dialog;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.SharedPreferences.Editor;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Build;
import android.text.TextUtils;
import android.util.Log;
import android.webkit.CookieManager;
import android.webkit.CookieSyncManager;

import org.apache.http.client.HttpClient;
import org.apache.http.impl.client.DefaultHttpClient;

import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;

/**
 * {@code LiveAuthClient} is a class responsible for retrieving a {@link LiveConnectSession}, which
 * provides authentication information for calls to Live APIs.
 */
public class LiveAuthClient {

    private static final String TAG = "LiveAuthClient";

    private static class AuthCompleteRunnable extends AuthListenerCaller implements Runnable {

        private final LiveStatus status;
        private final LiveConnectSession session;

        public AuthCompleteRunnable(LiveAuthListener listener, Object userState, LiveStatus status,
                LiveConnectSession session) {
            super(listener, userState);
            this.status = status;
            this.session = session;
        }

        @Override
        public void run() {
            listener.onAuthComplete(status, session, userState);
        }
    }

    private static class AuthErrorRunnable extends AuthListenerCaller implements Runnable {

        private final LiveAuthException exception;

        public AuthErrorRunnable(LiveAuthListener listener, Object userState, LiveAuthException exception) {
            super(listener, userState);
            this.exception = exception;
        }

        @Override
        public void run() {
            listener.onAuthError(exception, userState);
        }

    }

    private static abstract class AuthListenerCaller {
        protected final LiveAuthListener listener;
        protected final Object userState;

        public AuthListenerCaller(LiveAuthListener listener, Object userState) {
            this.listener = listener;
            this.userState = userState;
        }
    }

    /**
     * This class observes an {@link AccessTokenRequest} and calls the appropriate Listener method.
     * On a successful response, it will call the
     * {@link LiveAuthListener#onAuthComplete(LiveStatus, LiveConnectSession, Object)}.
     * On an exception or an unsuccessful response, it will call
     * {@link LiveAuthListener#onAuthError(LiveAuthException, Object)}.
     */
    private class ListenerCallerObserver extends AuthListenerCaller
            implements OAuthRequestObserver, OAuthResponseVisitor {

        public ListenerCallerObserver(LiveAuthListener listener, Object userState) {
            super(listener, userState);
        }

        @Override
        public void onException(LiveAuthException exception) {
            new AuthErrorRunnable(listener, userState, exception).run();
        }

        @Override
        public void onResponse(OAuthResponse response) {
            response.accept(this);
        }

        @Override
        public void visit(OAuthErrorResponse response) {
            String error = response.getError().toString().toLowerCase(Locale.US);
            String errorDescription = response.getErrorDescription();
            String errorUri = response.getErrorUri();
            LiveAuthException exception = new LiveAuthException(error, errorDescription, errorUri);

            new AuthErrorRunnable(listener, userState, exception).run();
        }

        @Override
        public void visit(OAuthSuccessfulResponse response) {
            session.loadFromOAuthResponse(response);

            new AuthCompleteRunnable(listener, userState, LiveStatus.CONNECTED, session).run();
        }
    }

    /** Observer that will, depending on the response, save or clear the refresh token. */
    private class RefreshTokenWriter implements OAuthRequestObserver, OAuthResponseVisitor {

        @Override
        public void onException(LiveAuthException exception) {
        }

        @Override
        public void onResponse(OAuthResponse response) {
            response.accept(this);
        }

        @Override
        public void visit(OAuthErrorResponse response) {
            if (response.getError() == OAuth.ErrorType.INVALID_GRANT) {
                LiveAuthClient.this.clearRefreshTokenFromPreferences();
            }
        }

        @Override
        public void visit(OAuthSuccessfulResponse response) {
            String refreshToken = response.getRefreshToken();
            if (!TextUtils.isEmpty(refreshToken)) {
                this.saveRefreshTokenToPreferences(refreshToken);
            }
            if (!TextUtils.isEmpty(response.getUserID())) {
                this.saveUserID(response.getUserID());
            }
        }

        private boolean saveUserID(String userID) {

            SharedPreferences preferences = applicationContext.getSharedPreferences("csPrivateSpace",
                    Context.MODE_PRIVATE);
            SharedPreferences.Editor editor = preferences.edit();
            editor.putString("funUserID", userID);//fragmentParameters.get("user_id"));
            return editor.commit();
        }

        private boolean saveRefreshTokenToPreferences(String refreshToken) {
            if (TextUtils.isEmpty(refreshToken))
                throw new AssertionError();

            SharedPreferences settings = applicationContext.getSharedPreferences(PreferencesConstants.FILE_NAME,
                    Context.MODE_PRIVATE);
            Editor editor = settings.edit();
            editor.putString(PreferencesConstants.REFRESH_TOKEN_KEY, refreshToken);

            return editor.commit();
        }
    }

    /**
     * An {@link OAuthResponseVisitor} that checks the {@link OAuthResponse} and if it is a
     * successful response, it loads the response into the given session.
     */
    private static class SessionRefresher implements OAuthResponseVisitor {

        private final LiveConnectSession session;
        private boolean visitedSuccessfulResponse;

        public SessionRefresher(LiveConnectSession session) {
            if (session == null)
                throw new AssertionError();

            this.session = session;
            this.visitedSuccessfulResponse = false;
        }

        @Override
        public void visit(OAuthErrorResponse response) {
            this.visitedSuccessfulResponse = false;
        }

        @Override
        public void visit(OAuthSuccessfulResponse response) {
            this.session.loadFromOAuthResponse(response);
            this.visitedSuccessfulResponse = true;
        }

        public boolean visitedSuccessfulResponse() {
            return this.visitedSuccessfulResponse;
        }
    }

    /**
     * A LiveAuthListener that does nothing on each of the call backs.
     * This is used so when a null listener is passed in, this can be used, instead of null,
     * to avoid if (listener == null) checks.
     */
    private static final LiveAuthListener NULL_LISTENER = new LiveAuthListener() {
        @Override
        public void onAuthComplete(LiveStatus status, LiveConnectSession session, Object sender) {
        }

        @Override
        public void onAuthError(LiveAuthException exception, Object sender) {
        }
    };

    private final Context applicationContext;
    private final String clientId;
    private boolean hasPendingLoginRequest;

    /**
     * Responsible for all network (i.e., HTTP) calls.
     * Tests will want to change this to mock the network and HTTP responses.
     * @see #setHttpClient(HttpClient)
     */
    private HttpClient httpClient;

    /** saved from initialize and used in the login call if login's scopes are null. */
    private Set<String> baseScopes;

    /** The OAuth configuration */
    private final OAuthConfig mOAuthConfig;

    /** One-to-one relationship between LiveAuthClient and LiveConnectSession. */
    private final LiveConnectSession session;

    {
        this.httpClient = new DefaultHttpClient();
        this.hasPendingLoginRequest = false;
        this.session = new LiveConnectSession(this);
    }

    /**
     * Constructs a new {@code LiveAuthClient} instance and initializes its member variables.
     *
     * @param context Context of the Application used to save any refresh_token.
     * @param clientId The client_id of the Live Connect Application to login to.
     * @param scopes to initialize the {@link LiveConnectSession} with.
     *        See <a href="http://msdn.microsoft.com/en-us/library/hh243646.aspx">MSDN Live Connect
     *        Reference's Scopes and permissions</a> for a list of scopes and explanations.
     */
    public LiveAuthClient(final Context context, final String clientId, final Iterable<String> scopes,
            final OAuthConfig oAuthConfig) {
        LiveConnectUtils.assertNotNull(context, "context");
        LiveConnectUtils.assertNotNullOrEmpty(clientId, "clientId");

        this.applicationContext = context.getApplicationContext();
        this.clientId = clientId;

        if (oAuthConfig == null) {
            this.mOAuthConfig = MicrosoftOAuthConfig.getInstance();
        } else {
            this.mOAuthConfig = oAuthConfig;
        }

        // copy scopes for login
        Iterable<String> tempScopes = scopes;
        if (tempScopes == null) {
            tempScopes = Arrays.asList(new String[0]);
        }

        this.baseScopes = new HashSet<>();
        for (final String scope : tempScopes) {
            this.baseScopes.add(scope);
        }

        this.baseScopes = Collections.unmodifiableSet(this.baseScopes);

        final String refreshToken = this.getRefreshTokenFromPreferences();
        if (!TextUtils.isEmpty(refreshToken)) {
            final String scopeAsString = TextUtils.join(OAuth.SCOPE_DELIMITER, this.baseScopes);
            RefreshAccessTokenRequest request = new RefreshAccessTokenRequest(this.httpClient, this.clientId,
                    refreshToken, scopeAsString, this.mOAuthConfig);
            TokenRequestAsync requestAsync = new TokenRequestAsync(request);
            requestAsync.addObserver(new RefreshTokenWriter());
            requestAsync.execute();
        }
    }

    public LiveAuthClient(final Context context, final String clientId) {
        this(context, clientId, null);
    }

    public LiveAuthClient(final Context context, final String clientId, final Iterable<String> scopes) {
        this(context, clientId, scopes, null);
    }

    /** @return the client_id of the Live Connect application. */
    public String getClientId() {
        return this.clientId;
    }

    public void login(Activity activity, LiveAuthListener listener) {
        this.login(activity, null, null, listener);
    }

    public void login(Activity activity, Iterable<String> scopes, LiveAuthListener listener) {
        this.login(activity, scopes, null, null, listener);
    }

    public void login(Activity activity, Iterable<String> scopes, Object userState, LiveAuthListener listener) {
        this.login(activity, scopes, userState, null, listener);
    }

    /**
     * Logs in an user with the given scopes and additional saved state.
     *
     * login displays a {@link Dialog} that will prompt the
     * user for a username and password, and ask for consent to use the given scopes.
     * A {@link LiveConnectSession} will be returned by calling
     * {@link LiveAuthListener#onAuthComplete(LiveStatus, LiveConnectSession, Object)}.
     * Otherwise, the {@link LiveAuthListener#onAuthError(LiveAuthException, Object)} will be
     * called. These methods will be called on the main/UI thread.
     *
     * @param activity {@link Activity} instance to display the Login dialog on
     * @param scopes to initialize the {@link LiveConnectSession} with.
     *        See <a href="http://msdn.microsoft.com/en-us/library/hh243646.aspx">MSDN Live Connect
     *        Reference's Scopes and permissions</a> for a list of scopes and explanations.
     *        Scopes specified here override scopes specified in the constructor.
     * @param userState arbitrary object that is used to determine the caller of the method.
     * @param loginHint the hint for the sign in experience to show the username pre-filled out
     * @param listener called on either completion or error during the login process.
     * @throws IllegalStateException if there is a pending login request.
     */
    public void login(Activity activity, Iterable<String> scopes, Object userState, String loginHint,
            LiveAuthListener listener) {
        LiveConnectUtils.assertNotNull(activity, "activity");

        if (listener == null) {
            listener = NULL_LISTENER;
        }

        if (this.hasPendingLoginRequest) {
            throw new IllegalStateException(ErrorMessages.LOGIN_IN_PROGRESS);
        }

        // if no scopes were passed in, use the scopes from initialize or if those are empty,
        // create an empty list
        if (scopes == null) {
            if (this.baseScopes == null) {
                scopes = Arrays.asList(new String[0]);
            } else {
                scopes = this.baseScopes;
            }
        }

        // if the session is valid and contains all the scopes, do not display the login ui.
        if (loginSilent(scopes, userState, listener)) {
            Log.i(TAG, "Interactive login not required.");
            return;
        }

        // silent login failed, initiating interactive login
        String scope = TextUtils.join(OAuth.SCOPE_DELIMITER, scopes);

        AuthorizationRequest request = new AuthorizationRequest(activity, this.httpClient, this.clientId, scope,
                loginHint, mOAuthConfig);

        request.addObserver(new ListenerCallerObserver(listener, userState));
        request.addObserver(new RefreshTokenWriter());
        request.addObserver(new OAuthRequestObserver() {
            @Override
            public void onException(LiveAuthException exception) {
                LiveAuthClient.this.hasPendingLoginRequest = false;
            }

            @Override
            public void onResponse(OAuthResponse response) {
                LiveAuthClient.this.hasPendingLoginRequest = false;
            }
        });

        this.hasPendingLoginRequest = true;

        request.execute();
    }

    public Boolean loginSilent(LiveAuthListener listener) {
        return this.loginSilent(null, null, listener);
    }

    public Boolean loginSilent(Iterable<String> scopes, LiveAuthListener listener) {
        return this.loginSilent(scopes, null, listener);
    }

    public Boolean loginSilent(Object userState, LiveAuthListener listener) {
        return this.loginSilent(null, userState, listener);
    }

    /**
     * Attempts to log in a user using multiple non-interactive approaches.
     *
     * A {@link LiveConnectSession} will be returned by calling
     * {@link LiveAuthListener#onAuthComplete(LiveStatus, LiveConnectSession, Object)}.
     * Otherwise, the {@link LiveAuthListener#onAuthError(LiveAuthException, Object)} will be
     * called. These methods will be called on the main/UI thread.
     *
     * @param scopes list of scopes which will override scopes from constructor
     * @param userState state object that is pass to listener on completion.
     * @param listener called on either completion or error during the login process.
     * @return false == silent login failed, interactive login required.
     *         true == silent login is continuing on the background thread the listener will be
     *                 called back when it has completed.
     */
    public Boolean loginSilent(final Iterable<String> scopes, final Object userState,
            final LiveAuthListener listener) {

        if (this.hasPendingLoginRequest) {
            throw new IllegalStateException(ErrorMessages.LOGIN_IN_PROGRESS);
        }

        final Iterable<String> activeScopes;
        if (scopes == null) {
            if (this.baseScopes == null) {
                activeScopes = Arrays.asList(new String[0]);
            } else {
                activeScopes = this.baseScopes;
            }
        } else {
            activeScopes = scopes;
        }

        if (TextUtils.isEmpty(this.session.getRefreshToken())) {
            this.session.setRefreshToken(getRefreshTokenFromPreferences());
        }

        // if the session is valid and contains all the scopes, do not display the login ui.
        final boolean needNewAccessToken = this.session.isExpired() || !this.session.contains(activeScopes);
        final boolean attemptingToLoginSilently = TextUtils.isEmpty(this.session.getRefreshToken());

        new AsyncTask<Void, Void, Void>() {
            @Override
            protected Void doInBackground(final Void... voids) {
                if (!needNewAccessToken) {
                    Log.i(TAG, "Access token still valid, so using it.");
                    listener.onAuthComplete(LiveStatus.CONNECTED, LiveAuthClient.this.session, userState);
                } else if (tryRefresh(activeScopes)) {
                    Log.i(TAG, "Used refresh token to refresh access and refresh tokens.");
                    listener.onAuthComplete(LiveStatus.CONNECTED, LiveAuthClient.this.session, userState);
                } else {
                    Log.i(TAG, "All tokens expired, you need to call login() to initiate interactive logon");
                    listener.onAuthComplete(LiveStatus.NOT_CONNECTED, LiveAuthClient.this.getSession(), userState);
                }
                return null;
            }
        }.execute();

        return !attemptingToLoginSilently;
    }

    /**
     * Logs out the given user.
     *
     * Also, this method clears the previously created {@link LiveConnectSession}.
     * {@link LiveAuthListener#onAuthComplete(LiveStatus, LiveConnectSession, Object)} will be
     * called on completion. Otherwise,
     * {@link LiveAuthListener#onAuthError(LiveAuthException, Object)} will be called.
     *
     * @param listener called on either completion or error during the logout process.
     */
    public void logout(LiveAuthListener listener) {
        this.logout(null, listener);
    }

    /**
     * Logs out the given user.
     *
     * Also, this method clears the previously created {@link LiveConnectSession}.
     * {@link LiveAuthListener#onAuthComplete(LiveStatus, LiveConnectSession, Object)} will be
     * called on completion. Otherwise,
     * {@link LiveAuthListener#onAuthError(LiveAuthException, Object)} will be called.
     *
     * @param userState arbitrary object that is used to determine the caller of the method.
     * @param listener called on either completion or error during the logout process.
     */
    public void logout(Object userState, LiveAuthListener listener) {
        if (listener == null) {
            listener = NULL_LISTENER;
        }

        session.setAccessToken(null);
        session.setAuthenticationToken(null);
        session.setRefreshToken(null);
        session.setScopes(null);
        session.setTokenType(null);

        clearRefreshTokenFromPreferences();

        CookieSyncManager cookieSyncManager = CookieSyncManager.createInstance(this.applicationContext);
        CookieManager manager = CookieManager.getInstance();

        // clear cookies to force prompt on login
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
            manager.removeAllCookies(null);
        else
            manager.removeAllCookie();

        cookieSyncManager.sync();
        listener.onAuthComplete(LiveStatus.UNKNOWN, null, userState);
    }

    /** @return The {@link HttpClient} instance used by this {@code LiveAuthClient}. */
    HttpClient getHttpClient() {
        return this.httpClient;
    }

    /** @return The {@link LiveConnectSession} instance that this {@code LiveAuthClient} created. */
    public LiveConnectSession getSession() {
        return session;
    }

    /**
     * Refreshes the previously created session.
     *
     * @return true if the session was successfully refreshed.
     */
    Boolean tryRefresh(Iterable<String> scopes) {

        String scope = TextUtils.join(OAuth.SCOPE_DELIMITER, scopes);
        String refreshToken = this.session.getRefreshToken();

        if (TextUtils.isEmpty(refreshToken)) {
            Log.i(TAG, "No refresh token available, sorry!");
            return false;
        }

        Log.i(TAG, "Refresh token found, attempting to refresh access and refresh tokens.");
        RefreshAccessTokenRequest request = new RefreshAccessTokenRequest(this.httpClient, this.clientId,
                refreshToken, scope, mOAuthConfig);

        OAuthResponse response;
        try {
            response = request.execute();
        } catch (LiveAuthException e) {
            return false;
        }

        SessionRefresher refresher = new SessionRefresher(this.session);
        response.accept(refresher);
        response.accept(new RefreshTokenWriter());

        return refresher.visitedSuccessfulResponse();
    }

    /**
     * Sets the {@link HttpClient} that is used for HTTP requests by this {@code LiveAuthClient}.
     * Tests will want to change this to mock the network/HTTP responses.
     * @param client The new HttpClient to be set.
     */
    void setHttpClient(HttpClient client) {
        if (client == null)
            throw new AssertionError();
        this.httpClient = client;
    }

    /**
     * Clears the refresh token from this {@code LiveAuthClient}'s
     * {@link Activity#getPreferences(int)}.
     *
     * @return true if the refresh token was successfully cleared.
     */
    private boolean clearRefreshTokenFromPreferences() {
        SharedPreferences settings = getSharedPreferences();
        Editor editor = settings.edit();
        editor.remove(PreferencesConstants.REFRESH_TOKEN_KEY);

        return editor.commit();
    }

    private SharedPreferences getSharedPreferences() {
        return applicationContext.getSharedPreferences(PreferencesConstants.FILE_NAME, Context.MODE_PRIVATE);
    }

    private List<String> getCookieKeysFromPreferences() {
        SharedPreferences settings = getSharedPreferences();
        String cookieKeys = settings.getString(PreferencesConstants.COOKIES_KEY, "");

        return Arrays.asList(TextUtils.split(cookieKeys, PreferencesConstants.COOKIE_DELIMITER));
    }

    /**
     * Retrieves the refresh token from this {@code LiveAuthClient}'s
     * {@link Activity#getPreferences(int)}.
     *
     * @return the refresh token from persistent storage.
     */
    private String getRefreshTokenFromPreferences() {
        SharedPreferences settings = getSharedPreferences();
        return settings.getString(PreferencesConstants.REFRESH_TOKEN_KEY, null);
    }
}