/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ package org.mozilla.gecko.fxa.authenticator; import android.accounts.AbstractAccountAuthenticator; import android.accounts.Account; import android.accounts.AccountAuthenticatorResponse; import android.accounts.AccountManager; import android.accounts.NetworkErrorException; import android.content.Context; import android.content.Intent; import android.os.Bundle; import org.mozilla.gecko.background.common.log.Logger; import org.mozilla.gecko.background.fxa.FxAccountClient; import org.mozilla.gecko.background.fxa.FxAccountClient20; import org.mozilla.gecko.background.fxa.FxAccountUtils; import org.mozilla.gecko.background.fxa.oauth.FxAccountAbstractClient.RequestDelegate; import org.mozilla.gecko.background.fxa.oauth.FxAccountAbstractClientException.FxAccountAbstractClientRemoteException; import org.mozilla.gecko.background.fxa.oauth.FxAccountOAuthClient10; import org.mozilla.gecko.background.fxa.oauth.FxAccountOAuthClient10.AuthorizationResponse; import org.mozilla.gecko.browserid.BrowserIDKeyPair; import org.mozilla.gecko.browserid.JSONWebTokenUtils; import org.mozilla.gecko.fxa.FxAccountConstants; import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine; import org.mozilla.gecko.fxa.login.FxAccountLoginStateMachine.LoginStateMachineDelegate; import org.mozilla.gecko.fxa.login.FxAccountLoginTransition.Transition; import org.mozilla.gecko.fxa.login.Married; import org.mozilla.gecko.fxa.login.State; import org.mozilla.gecko.fxa.login.State.StateLabel; import org.mozilla.gecko.fxa.login.StateFactory; import org.mozilla.gecko.fxa.receivers.FxAccountDeletedService; import org.mozilla.gecko.fxa.sync.FxAccountNotificationManager; import org.mozilla.gecko.fxa.sync.FxAccountSyncAdapter; import org.mozilla.gecko.util.ThreadUtils; import java.security.NoSuchAlgorithmException; import java.util.concurrent.Executor; import java.util.concurrent.Executors; public class FxAccountAuthenticator extends AbstractAccountAuthenticator { public static final String LOG_TAG = FxAccountAuthenticator.class.getSimpleName(); public static final int UNKNOWN_ERROR_CODE = 999; protected final Context context; protected final AccountManager accountManager; public FxAccountAuthenticator(Context context) { super(context); this.context = context; this.accountManager = AccountManager.get(context); } @Override public Bundle addAccount(AccountAuthenticatorResponse response, String accountType, String authTokenType, String[] requiredFeatures, Bundle options) throws NetworkErrorException { Logger.debug(LOG_TAG, "addAccount"); // The data associated to each Account should be invalidated when we change // the set of Firefox Accounts on the system. AndroidFxAccount.invalidateCaches(); final Bundle res = new Bundle(); if (!FxAccountConstants.ACCOUNT_TYPE.equals(accountType)) { res.putInt(AccountManager.KEY_ERROR_CODE, -1); res.putString(AccountManager.KEY_ERROR_MESSAGE, "Not adding unknown account type."); return res; } final Intent intent = new Intent(FxAccountConstants.ACTION_FXA_GET_STARTED); res.putParcelable(AccountManager.KEY_INTENT, intent); return res; } @Override public Bundle confirmCredentials(AccountAuthenticatorResponse response, Account account, Bundle options) throws NetworkErrorException { Logger.debug(LOG_TAG, "confirmCredentials"); return null; } @Override public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) { Logger.debug(LOG_TAG, "editProperties"); return null; } protected static class Responder { final AccountAuthenticatorResponse response; final AndroidFxAccount fxAccount; public Responder(AccountAuthenticatorResponse response, AndroidFxAccount fxAccount) { this.response = response; this.fxAccount = fxAccount; } public void fail(Exception e) { Logger.warn(LOG_TAG, "Responding with error!", e); fxAccount.releaseSharedAccountStateLock(); final Bundle result = new Bundle(); result.putInt(AccountManager.KEY_ERROR_CODE, UNKNOWN_ERROR_CODE); result.putString(AccountManager.KEY_ERROR_MESSAGE, e.toString()); response.onResult(result); } public void succeed(String authToken) { Logger.info(LOG_TAG, "Responding with success!"); fxAccount.releaseSharedAccountStateLock(); final Bundle result = new Bundle(); result.putString(AccountManager.KEY_ACCOUNT_NAME, fxAccount.account.name); result.putString(AccountManager.KEY_ACCOUNT_TYPE, fxAccount.account.type); result.putString(AccountManager.KEY_AUTHTOKEN, authToken); response.onResult(result); } } public abstract static class FxADefaultLoginStateMachineDelegate implements LoginStateMachineDelegate { protected final Context context; protected final AndroidFxAccount fxAccount; protected final Executor executor; protected final FxAccountClient client; public FxADefaultLoginStateMachineDelegate(Context context, AndroidFxAccount fxAccount) { this.context = context; this.fxAccount = fxAccount; this.executor = Executors.newSingleThreadExecutor(); this.client = new FxAccountClient20(fxAccount.getAccountServerURI(), executor); } @Override public FxAccountClient getClient() { return client; } @Override public long getCertificateDurationInMilliseconds() { return 12 * 60 * 60 * 1000; } @Override public long getAssertionDurationInMilliseconds() { return 15 * 60 * 1000; } @Override public BrowserIDKeyPair generateKeyPair() throws NoSuchAlgorithmException { return StateFactory.generateKeyPair(); } @Override public void handleTransition(Transition transition, State state) { Logger.info(LOG_TAG, "handleTransition: " + transition + " to " + state.getStateLabel()); } abstract public void handleNotMarried(State notMarried); abstract public void handleMarried(Married married); @Override public void handleFinal(State state) { Logger.info(LOG_TAG, "handleFinal: in " + state.getStateLabel()); fxAccount.setState(state); // Update any notifications displayed. final FxAccountNotificationManager notificationManager = new FxAccountNotificationManager(FxAccountSyncAdapter.NOTIFICATION_ID); notificationManager.update(context, fxAccount); if (state.getStateLabel() != StateLabel.Married) { handleNotMarried(state); return; } else { handleMarried((Married) state); } } } protected void getOAuthToken(final AccountAuthenticatorResponse response, final AndroidFxAccount fxAccount, final String scope) throws NetworkErrorException { Logger.info(LOG_TAG, "Fetching oauth token with scope: " + scope); final Responder responder = new Responder(response, fxAccount); final String oauthServerUri = fxAccount.getOAuthServerURI(); final String audience; try { audience = FxAccountUtils.getAudienceForURL(oauthServerUri); // The assertion gets traded in for an oauth bearer token. } catch (Exception e) { Logger.warn(LOG_TAG, "Got exception fetching oauth token.", e); responder.fail(e); return; } final FxAccountLoginStateMachine stateMachine = new FxAccountLoginStateMachine(); stateMachine.advance(fxAccount.getState(), StateLabel.Married, new FxADefaultLoginStateMachineDelegate(context, fxAccount) { @Override public void handleNotMarried(State state) { final String message = "Cannot fetch oauth token from state: " + state.getStateLabel(); Logger.warn(LOG_TAG, message); responder.fail(new RuntimeException(message)); } @Override public void handleMarried(final Married married) { final String assertion; try { assertion = married.generateAssertion(audience, JSONWebTokenUtils.DEFAULT_ASSERTION_ISSUER); if (FxAccountUtils.LOG_PERSONAL_INFORMATION) { JSONWebTokenUtils.dumpAssertion(assertion); } } catch (Exception e) { Logger.warn(LOG_TAG, "Got exception fetching oauth token.", e); responder.fail(e); return; } final FxAccountOAuthClient10 oauthClient = new FxAccountOAuthClient10(oauthServerUri, executor); Logger.debug(LOG_TAG, "OAuth fetch for scope: " + scope); oauthClient.authorization(FxAccountConstants.OAUTH_CLIENT_ID_FENNEC, assertion, null, scope, new RequestDelegate() { @Override public void handleSuccess(AuthorizationResponse result) { Logger.debug(LOG_TAG, "OAuth success."); FxAccountUtils.pii(LOG_TAG, "Fetched oauth token: " + result.access_token); responder.succeed(result.access_token); } @Override public void handleFailure(FxAccountAbstractClientRemoteException e) { Logger.error(LOG_TAG, "OAuth failure.", e); if (e.isInvalidAuthentication()) { // We were married, generated an assertion, and our assertion was rejected by the // oauth client. If it's a 401, we probably have a stale certificate. If instead of // a stale certificate we have bad credentials, the state machine will fail to sign // our public key and drive us back to Separated. fxAccount.setState(married.makeCohabitingState()); } responder.fail(e); } @Override public void handleError(Exception e) { Logger.error(LOG_TAG, "OAuth error.", e); responder.fail(e); } }); } }); } @Override public Bundle getAuthToken(final AccountAuthenticatorResponse response, final Account account, final String authTokenType, final Bundle options) throws NetworkErrorException { Logger.debug(LOG_TAG, "getAuthToken: " + authTokenType); // If we have a cached authToken, hand it over. final String cachedAuthToken = AccountManager.get(context).peekAuthToken(account, authTokenType); if (cachedAuthToken != null && !cachedAuthToken.isEmpty()) { Logger.info(LOG_TAG, "Return cached token."); final Bundle result = new Bundle(); result.putString(AccountManager.KEY_ACCOUNT_NAME, account.name); result.putString(AccountManager.KEY_ACCOUNT_TYPE, account.type); result.putString(AccountManager.KEY_AUTHTOKEN, cachedAuthToken); return result; } // If we're asked for an oauth::scope token, try to generate one. final String oauthPrefix = "oauth::"; if (authTokenType != null && authTokenType.startsWith(oauthPrefix)) { final String scope = authTokenType.substring(oauthPrefix.length()); final AndroidFxAccount fxAccount = new AndroidFxAccount(context, account); try { fxAccount.acquireSharedAccountStateLock(LOG_TAG); } catch (InterruptedException e) { Logger.warn(LOG_TAG, "Could not acquire account state lock; return error bundle."); final Bundle bundle = new Bundle(); bundle.putInt(AccountManager.KEY_ERROR_CODE, 1); bundle.putString(AccountManager.KEY_ERROR_MESSAGE, "Could not acquire account state lock."); return bundle; } getOAuthToken(response, fxAccount, scope); return null; } // Otherwise, fail. Logger.warn(LOG_TAG, "Returning error bundle for getAuthToken with unknown token type."); final Bundle bundle = new Bundle(); bundle.putInt(AccountManager.KEY_ERROR_CODE, 2); bundle.putString(AccountManager.KEY_ERROR_MESSAGE, "Unknown token type: " + authTokenType); return bundle; } @Override public String getAuthTokenLabel(String authTokenType) { Logger.debug(LOG_TAG, "getAuthTokenLabel"); return null; } @Override public Bundle hasFeatures(AccountAuthenticatorResponse response, Account account, String[] features) throws NetworkErrorException { Logger.debug(LOG_TAG, "hasFeatures"); return null; } @Override public Bundle updateCredentials(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options) throws NetworkErrorException { Logger.debug(LOG_TAG, "updateCredentials"); return null; } /** * If the account is going to be removed, broadcast an "account deleted" * intent. This allows us to clean up the account. *

* It is preferable to receive Android's LOGIN_ACCOUNTS_CHANGED_ACTION broadcast * than to create our own hacky broadcast here, but that doesn't include enough * information about which Accounts changed to correctly identify whether a Sync * account has been removed (when some Firefox channels are installed on the SD * card). We can work around this by storing additional state but it's both messy * and expensive because the broadcast is noisy. *

* Note that this is not called when an Android Account is blown away * due to the SD card being unmounted. */ @Override public Bundle getAccountRemovalAllowed(final AccountAuthenticatorResponse response, Account account) throws NetworkErrorException { Bundle result = super.getAccountRemovalAllowed(response, account); if (result == null || !result.containsKey(AccountManager.KEY_BOOLEAN_RESULT) || result.containsKey(AccountManager.KEY_INTENT)) { return result; } final boolean removalAllowed = result.getBoolean(AccountManager.KEY_BOOLEAN_RESULT); if (!removalAllowed) { return result; } // Broadcast a message to all Firefox channels sharing this Android // Account type telling that this Firefox account has been deleted. // // Broadcast intents protected with permissions are secure, so it's okay // to include private information such as a password. final AndroidFxAccount androidFxAccount = new AndroidFxAccount(context, account); // Deleting the pickle file in a blocking manner will avoid race conditions that might happen when // an account is unpickled while an FxAccount is being deleted. // Also we have an assumption that this method is always called from a background thread, so we delete // the pickle file directly without being afraid from a StrictMode violation. ThreadUtils.assertNotOnUiThread(); final Intent serviceIntent = androidFxAccount.populateDeletedAccountIntent( new Intent(context, FxAccountDeletedService.class) ); Logger.info(LOG_TAG, "Account named " + account.name + " being removed; " + "starting FxAccountDeletedService with action: " + serviceIntent.getAction() + "."); context.startService(serviceIntent); Logger.info(LOG_TAG, "Firefox account named " + account.name + " being removed; " + "deleting saved pickle file '" + FxAccountConstants.ACCOUNT_PICKLE_FILENAME + "'."); deletePickle(); return result; } private void deletePickle() { try { AccountPickler.deletePickle(context, FxAccountConstants.ACCOUNT_PICKLE_FILENAME); } catch (Exception e) { // This should never happen, but we really don't want to die in a background thread. Logger.warn(LOG_TAG, "Got exception deleting saved pickle file; ignoring.", e); } } }