Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 00476ddc authored by Mohammed Althaf T's avatar Mohammed Althaf T 😊
Browse files

AM: import /e/ Specific changes from account manager

parent 79006c1c
Loading
Loading
Loading
Loading
+9 −1
Original line number Diff line number Diff line
@@ -281,7 +281,6 @@ dependencies {
    implementation(libs.okhttp.base)
    implementation(libs.okhttp.brotli)
    implementation(libs.okhttp.logging)
    implementation(libs.openid.appauth)
    implementation(libs.unifiedpush) {
        // UnifiedPush connector seems to be using a workaround by importing this library.
        // Will be removed after https://github.com/tink-crypto/tink-java-apps/pull/5 is merged.
@@ -295,9 +294,12 @@ dependencies {
    implementation(libs.commons.lang)

    // e-Specific dependencies
    implementation(libs.appauth)
    implementation(libs.android.singlesignon)
    implementation(libs.androidx.runtime.livedata)
    implementation(libs.elib)
    implementation(libs.ez.vcard)
    implementation(libs.jackrabbit.webdav)
    implementation(libs.synctools) {
        exclude(group="androidx.test")
        exclude(group = "junit")
@@ -305,6 +307,12 @@ dependencies {
    implementation(libs.ical4j) {
        exclude(group = "commons-logging", module = "commons-logging")
    }
    implementation(libs.nextcloud.library) {
        exclude(group = "org.ogce", module = "xpp3")
    }
    implementation(libs.commons.httpclient) {
        exclude(group = "commons-logging", module = "commons-logging")
    }

    // for tests
    androidTestImplementation(libs.androidx.arch.core.testing)
+7 −0
Original line number Diff line number Diff line
@@ -29,3 +29,10 @@
# https://github.com/bitfireAT/davx5/issues/711 / https://github.com/square/okhttp/issues/8574
-keep class okhttp3.internal.idn.IdnaMappingTable { *; }
-keep class okhttp3.internal.idn.IdnaMappingTableInstanceKt{ *; }

-dontwarn edu.umd.cs.findbugs.annotations.SuppressFBWarnings

-keep class com.nextcloud.android.sso.** { *; }
-keep interface com.nextcloud.android.sso.** { *; }
-keep class org.apache.commons.httpclient.** { *; }
-keep interface org.apache.commons.httpclient.** { *; }
+29 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 e Foundation
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
 *
 */
package com.nextcloud.android.sso

import at.bitfire.davdroid.settings.AccountSettings
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent

@EntryPoint
@InstallIn(SingletonComponent::class)
interface BinderDependencies {
    fun accountSettingsFactory(): AccountSettings.Factory
}
+60 −172
Original line number Diff line number Diff line
@@ -21,13 +21,13 @@ import android.text.TextUtils;
import com.nextcloud.android.sso.aidl.IInputStreamService;
import com.nextcloud.android.sso.aidl.NextcloudRequest;
import com.nextcloud.android.sso.aidl.ParcelFileDescriptorUtil;
import com.nextcloud.client.account.UserAccountManager;
import com.nextcloud.android.utils.AccountManagerUtils;
import com.owncloud.android.utils.EncryptionUtils;
import com.owncloud.android.lib.common.OwnCloudAccount;
import com.owncloud.android.lib.common.OwnCloudClient;
import com.owncloud.android.lib.common.OwnCloudClientManager;
import com.owncloud.android.lib.common.OwnCloudClientManagerFactory;
import com.owncloud.android.lib.common.utils.Log_OC;
import com.owncloud.android.utils.EncryptionUtils;
import com.owncloud.android.lib.common.operations.RemoteOperation;

import org.apache.commons.httpclient.HttpConnection;
import org.apache.commons.httpclient.HttpMethodBase;
@@ -58,10 +58,12 @@ import java.io.Serializable;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.logging.Level;
import java.util.logging.Logger;

import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;

import static com.nextcloud.android.sso.Constants.DELIMITER;
import static com.nextcloud.android.sso.Constants.EXCEPTION_ACCOUNT_NOT_FOUND;
import static com.nextcloud.android.sso.Constants.EXCEPTION_HTTP_REQUEST_FAILED;
import static com.nextcloud.android.sso.Constants.EXCEPTION_INVALID_REQUEST_URL;
@@ -74,7 +76,6 @@ import static com.nextcloud.android.sso.Constants.SSO_SHARED_PREFERENCE;
 * Stream binder to pass usable InputStreams across the process boundary in Android.
 */
public class InputStreamBinder extends IInputStreamService.Stub {

    private final static String TAG = "InputStreamBinder";
    private static final String CONTENT_TYPE_APPLICATION_JSON = "application/json";
    private static final String CHARSET_UTF8 = "UTF-8";
@@ -83,13 +84,12 @@ public class InputStreamBinder extends IInputStreamService.Stub {
    private static final int HTTP_STATUS_CODE_MULTIPLE_CHOICES = 300;

    private static final char PATH_SEPARATOR = '/';
    private static final int ZERO_LENGTH = 0;
    private Context context;
    private UserAccountManager accountManager;
    public static final String DELIMITER = "_";
    private final Context context;
    private final Logger logger = Logger.getLogger(TAG);

    public InputStreamBinder(Context context, UserAccountManager accountManager) {
    public InputStreamBinder(Context context) {
        this.context = context;
        this.accountManager = accountManager;
    }

    public ParcelFileDescriptor performNextcloudRequestV2(ParcelFileDescriptor input) {
@@ -112,7 +112,7 @@ public class InputStreamBinder extends IInputStreamService.Stub {
            NextcloudRequest request = deserializeObjectAndCloseStream(is);
            response = processRequestV2(request, requestBodyInputStream);
        } catch (Exception e) {
            Log_OC.e(TAG, "Error during Nextcloud request", e);
            logger.log(Level.SEVERE, "Error during Nextcloud request", e);
            exception = e;
        }

@@ -122,61 +122,22 @@ public class InputStreamBinder extends IInputStreamService.Stub {
            InputStream resultStream = new java.io.SequenceInputStream(exceptionStream, response.getBody());

            return ParcelFileDescriptorUtil.pipeFrom(resultStream,
                                                     thread -> Log_OC.d(TAG, "Done sending result"),
                                                     response.getMethod());
                    thread -> logger.log(Level.INFO, "InputStreamBinder: Done sending result"));
        } catch (IOException e) {
            Log_OC.e(TAG, "Error while sending response back to client app", e);
            logger.log(Level.SEVERE, "Error while sending response back to client app", e);
        }

        return null;
    }

    public ParcelFileDescriptor performNextcloudRequest(ParcelFileDescriptor input) {
        return performNextcloudRequestAndBodyStream(input, null);
        return performNextcloudRequestAndBodyStreamV2(input, null);
    }

    public ParcelFileDescriptor performNextcloudRequestAndBodyStream(
            ParcelFileDescriptor input,
            ParcelFileDescriptor requestBodyParcelFileDescriptor) {
        // read the input
        final InputStream is = new ParcelFileDescriptor.AutoCloseInputStream(input);

        final InputStream requestBodyInputStream = requestBodyParcelFileDescriptor != null ?
            new ParcelFileDescriptor.AutoCloseInputStream(requestBodyParcelFileDescriptor) : null;
        Exception exception = null;
        HttpMethodBase httpMethod = null;
        InputStream httpStream = new InputStream() {
            @Override
            public int read() {
                return ZERO_LENGTH;
            }
        };

        try {
            // Start request and catch exceptions
            NextcloudRequest request = deserializeObjectAndCloseStream(is);
            httpMethod = processRequest(request, requestBodyInputStream);
            httpStream = httpMethod.getResponseBodyAsStream();
        } catch (Exception e) {
            Log_OC.e(TAG, "Error during Nextcloud request", e);
            exception = e;
        }

        try {
            // Write exception to the stream followed by the actual network stream
            InputStream exceptionStream = serializeObjectToInputStream(exception);
            InputStream resultStream;
            if (httpStream != null) {
                resultStream = new java.io.SequenceInputStream(exceptionStream, httpStream);
            } else {
                resultStream = exceptionStream;
            }
            return ParcelFileDescriptorUtil.pipeFrom(resultStream,
                                                     thread -> Log_OC.d(TAG, "Done sending result"),
                                                     httpMethod);
        } catch (IOException e) {
            Log_OC.e(TAG, "Error while sending response back to client app", e);
        }
        return null;
        return performNextcloudRequestAndBodyStreamV2(input, requestBodyParcelFileDescriptor);
    }

    private ByteArrayInputStream serializeObjectToInputStreamV2(Exception exception, String headers) {
@@ -190,21 +151,12 @@ public class InputStreamBinder extends IInputStreamService.Stub {

            baosByteArray = baos.toByteArray();
        } catch (IOException e) {
            Log_OC.e(TAG, "Error while sending response back to client app", e);
            logger.log(Level.SEVERE, "Error while sending response back to client app", e);
        }

        return new ByteArrayInputStream(baosByteArray);
    }

    private <T extends Serializable> ByteArrayInputStream serializeObjectToInputStream(T obj) throws IOException {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(baos);
        oos.writeObject(obj);
        oos.flush();
        oos.close();
        return new ByteArrayInputStream(baos.toByteArray());
    }

    private <T extends Serializable> T deserializeObjectAndCloseStream(InputStream is) throws IOException,
        ClassNotFoundException {
        ObjectInputStream ois = new ObjectInputStream(is);
@@ -307,78 +259,26 @@ public class InputStreamBinder extends IInputStreamService.Stub {
        return method;
    }

    private HttpMethodBase processRequest(final NextcloudRequest request, final InputStream requestBodyInputStream)
        throws UnsupportedOperationException,
        com.owncloud.android.lib.common.accounts.AccountUtils.AccountNotFoundException,
        OperationCanceledException, AuthenticatorException, IOException {
        Account account = accountManager.getAccountByName(request.getAccountName());
        if (account == null) {
            throw new IllegalStateException(EXCEPTION_ACCOUNT_NOT_FOUND);
        }

        // Validate token
        if (!isValid(request)) {
            throw new IllegalStateException(EXCEPTION_INVALID_TOKEN);
        }

        // Validate URL
        if (request.getUrl().length() == 0 || request.getUrl().charAt(0) != PATH_SEPARATOR) {
            throw new IllegalStateException(EXCEPTION_INVALID_REQUEST_URL,
                                            new IllegalStateException("URL need to start with a /"));
        }

        OwnCloudClientManager ownCloudClientManager = OwnCloudClientManagerFactory.getDefaultSingleton();
        OwnCloudAccount ocAccount = new OwnCloudAccount(account, context);
        OwnCloudClient client = ownCloudClientManager.getClientFor(ocAccount, context);

        HttpMethodBase method = buildMethod(request, client.getBaseUri(), requestBodyInputStream);

        if (request.getParameterV2() != null && !request.getParameterV2().isEmpty()) {
            method.setQueryString(convertListToNVP(request.getParameterV2()));
        } else {
            method.setQueryString(convertMapToNVP(request.getParameter()));
        }
        method.addRequestHeader("OCS-APIREQUEST", "true");

        for (Map.Entry<String, List<String>> header : request.getHeader().entrySet()) {
            // https://stackoverflow.com/a/3097052
            method.addRequestHeader(header.getKey(), TextUtils.join(",", header.getValue()));

            if ("OCS-APIREQUEST".equalsIgnoreCase(header.getKey())) {
                throw new IllegalStateException(
                    "The 'OCS-APIREQUEST' header will be automatically added by the Nextcloud SSO Library. " +
                        "Please remove the header before making a request");
            }
        }

        client.setFollowRedirects(request.isFollowRedirects());
        int status = client.executeMethod(method);

        // Check if status code is 2xx --> https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#2xx_Success
        if (status >= HTTP_STATUS_CODE_OK && status < HTTP_STATUS_CODE_MULTIPLE_CHOICES) {
            return method;
        } else {
            InputStream inputStream = method.getResponseBodyAsStream();
            String total = "No response body";

            // If response body is available
            if (inputStream != null) {
                total = inputStreamToString(inputStream);
                Log_OC.e(TAG, total);
    /*
    * for non ocs/dav requests (nextcloud app: ex: notes app), when OIDC is used, we need to pass an special header.
    * We should not pass this header for ocs/dav requests as it can cause session cookie not being used for those request.
    *
    * These nextcloud app request paths contain `/index.php/apps/` on them.
     */
    private boolean shouldAddHeaderForOidcLogin(@NonNull Context context, @NonNull Account account, @NonNull String path) {
        boolean isOidcAccount = AccountManagerUtils.isOidcAccount(context, account);
        if (!isOidcAccount) {
            return false;
        }

            method.releaseConnection();
            throw new IllegalStateException(EXCEPTION_HTTP_REQUEST_FAILED,
                                            new IllegalStateException(String.valueOf(status),
                                                                      new IllegalStateException(total)));
        }
        return path.contains("/index.php/apps/");
    }

    private Response processRequestV2(final NextcloudRequest request, final InputStream requestBodyInputStream)
        throws UnsupportedOperationException,
        com.owncloud.android.lib.common.accounts.AccountUtils.AccountNotFoundException,
        OperationCanceledException, AuthenticatorException, IOException {
        Account account = accountManager.getAccountByName(request.getAccountName());
        Account account = AccountManagerUtils.getAccountByName(context, request.getAccountName());
        if (account == null) {
            throw new IllegalStateException(EXCEPTION_ACCOUNT_NOT_FOUND);
        }
@@ -389,39 +289,48 @@ public class InputStreamBinder extends IInputStreamService.Stub {
        }

        // Validate URL
        if (request.getUrl().length() == 0 || request.getUrl().charAt(0) != PATH_SEPARATOR) {
        if (request.getUrl().isEmpty() || request.getUrl().charAt(0) != PATH_SEPARATOR) {
            throw new IllegalStateException(EXCEPTION_INVALID_REQUEST_URL,
                    new IllegalStateException("URL need to start with a /"));
        }

        OwnCloudClientManager ownCloudClientManager = OwnCloudClientManagerFactory.getDefaultSingleton();
        OwnCloudAccount ocAccount = new OwnCloudAccount(account, context);
        OwnCloudClient client = ownCloudClientManager.getClientFor(ocAccount, context);
        if (AccountManagerUtils.isOidcAccount(context, account)) {
            // Blocking call
            OidcTokenRefresher.refresh(context, account);
        }

        final OwnCloudClientManager ownCloudClientManager = OwnCloudClientManagerFactory.getDefaultSingleton();
        final OwnCloudAccount ownCloudAccount = new OwnCloudAccount(account, context);
        final OwnCloudClient client = ownCloudClientManager.getClientFor(ownCloudAccount, context, OwnCloudClient.DONT_USE_COOKIES);

        HttpMethodBase method = buildMethod(request, client.getBaseUri(), requestBodyInputStream);

        if (request.getParameterV2() != null && !request.getParameterV2().isEmpty()) {
            method.setQueryString(convertListToNVP(request.getParameterV2()));
        } else {
            method.setQueryString(convertMapToNVP(request.getParameter()));
        }

        method.addRequestHeader("OCS-APIREQUEST", "true");

        for (Map.Entry<String, List<String>> header : request.getHeader().entrySet()) {
            // https://stackoverflow.com/a/3097052
            method.addRequestHeader(header.getKey(), TextUtils.join(",", header.getValue()));

            if ("OCS-APIREQUEST".equalsIgnoreCase(header.getKey())) {
                throw new IllegalStateException(
                    "The 'OCS-APIREQUEST' header will be automatically added by the Nextcloud SSO Library. " +
                        "Please remove the header before making a request");
        }

        method.setRequestHeader(
                RemoteOperation.OCS_API_HEADER,
                RemoteOperation.OCS_API_HEADER_VALUE
        );

        if (shouldAddHeaderForOidcLogin(context, account, request.getUrl())) {
            method.setRequestHeader(
                    RemoteOperation.OIDC_LOGIN_WITH_TOKEN,
                    RemoteOperation.OIDC_LOGIN_WITH_TOKEN_VALUE
            );
        }

        client.setFollowRedirects(request.isFollowRedirects());
        client.setFollowRedirects(true);
        int status = client.executeMethod(method);

        ownCloudClientManager.saveAllClients(context, account.type);

        // Check if status code is 2xx --> https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#2xx_Success
        if (status >= HTTP_STATUS_CODE_OK && status < HTTP_STATUS_CODE_MULTIPLE_CHOICES) {
            return new Response(method);
@@ -432,7 +341,7 @@ public class InputStreamBinder extends IInputStreamService.Stub {
            // If response body is available
            if (inputStream != null) {
                total = inputStreamToString(inputStream);
                Log_OC.e(TAG, total);
                logger.severe(total);
            }

            method.releaseConnection();
@@ -443,19 +352,12 @@ public class InputStreamBinder extends IInputStreamService.Stub {
    }

    private boolean isValid(NextcloudRequest request) {
        String[] callingPackageNames = context.getPackageManager().getPackagesForUid(Binder.getCallingUid());
        String callingPackageName = context.getPackageManager().getNameForUid(Binder.getCallingUid());

        SharedPreferences sharedPreferences = context.getSharedPreferences(SSO_SHARED_PREFERENCE,
                Context.MODE_PRIVATE);
        for (String callingPackageName : callingPackageNames) {
        String hash = sharedPreferences.getString(callingPackageName + DELIMITER + request.getAccountName(), "");
            if (hash.isEmpty())
                continue;
            if (validateToken(hash, request.getToken())) {
                return true;
            }
        }
        return false;
        return validateToken(hash, request.getToken());
    }

    private boolean validateToken(String hash, String token) {
@@ -500,20 +402,6 @@ public class InputStreamBinder extends IInputStreamService.Stub {
        }
    }

    @VisibleForTesting
    public static NameValuePair[] convertMapToNVP(Map<String, String> map) {
        final var nvp = new NameValuePair[map.size()];
        int i = 0;

        for (Map.Entry<String, String> entry : map.entrySet()) {
            final var nameValuePair = new NameValuePair(entry.getKey(), entry.getValue());
            nvp[i] = nameValuePair;
            i++;
        }

        return nvp;
    }

    @VisibleForTesting
    public static NameValuePair[] convertListToNVP(Collection<QueryParam> list) {
        NameValuePair[] nvp = new NameValuePair[list.size()];
+132 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 e Foundation
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 */

package com.nextcloud.android.sso

import android.accounts.Account
import android.content.Context
import at.bitfire.davdroid.network.HttpClientBuilder
import at.bitfire.davdroid.settings.AccountSettings
import dagger.hilt.android.EntryPointAccessors
import kotlinx.coroutines.runBlocking
import net.openid.appauth.AuthState
import net.openid.appauth.AuthorizationService
import net.openid.appauth.ClientAuthentication
import org.jetbrains.annotations.Blocking
import java.util.logging.Level
import java.util.logging.Logger
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine

/**
 * Utility for refreshing OpenID Connect (OIDC) tokens in the Android AccountManager.
 *
 * This object exposes a synchronous, blocking entry point for token refresh requests
 * and internally uses coroutines to perform the refresh operation with proper
 * callback-to-suspension conversion.
 */
object OidcTokenRefresher {

    val logger: Logger = Logger.getLogger(this.javaClass.name)

    /**
     * Refreshes the OIDC token for the given [Account].
     *
     * It will:
     * 1. Invoke the authorization service to refresh tokens.
     * 2. Update AccountManager on successful refresh or log failures.
     *
     * **Threading:** This method uses [runBlocking] and therefore must **not** be
     * called from the Main/UI thread. It is annotated with `@Blocking` to signal
     * blocking behavior.
     */
    @JvmStatic
    @Blocking
    fun refresh(context: Context, account: Account) {
        runBlocking {
            val accountSettingsFactory = EntryPointAccessors.fromApplication(
                context.applicationContext,
                BinderDependencies::class.java
            ).accountSettingsFactory()

            val accountSettings = accountSettingsFactory.create(account)
            val credentials = accountSettings.credentials()
            val authState = credentials.authState

            if (authState == null) {
                logger.log(Level.FINE, "Account: $account has null AuthState, refresh isn't possible.")
                return@runBlocking
            }

            val authorizationService =
                EntryPointAccessors.fromApplication(context, HttpClientBuilder.HttpClientEntryPoint::class.java)
                    .authorizationService()

            val updatedAuthState = runCatching {
                refreshAuthState(authorizationService, authState)
            }.getOrNull()

            if (updatedAuthState != null) {
                updateAndroidAccountManagerAuthState(accountSettings, updatedAuthState)
            } else {
                logger.warning("Couldn't update AuthState for account: $account")
            }
        }
    }

    /**
     * Suspends until the authState has fresh tokens from AuthorizationService.
     *
     * Internally it bridges the callback-based `performActionWithFreshTokens`
     * API into a coroutine suspension using [suspendCoroutine]. On success, it
     * resumes with the same [AuthState] instance containing updated tokens. On
     * failure, it throws the encountered [Throwable].
     *
     * @param authService The [AuthorizationService] to use for token refresh.
     * @param authState   The current [AuthState] containing existing tokens.
     * @param clientAuth  [ClientAuthentication] mechanism (e.g., client secret).
     * @return The same [AuthState] instance with refreshed tokens.
     * @throws Exception if the refresh operation fails.
     */
    private suspend fun refreshAuthState(
        authService: AuthorizationService, authState: AuthState
    ): AuthState {
        return suspendCoroutine { continuation ->
            authState.performActionWithFreshTokens(
                authService,
            ) { accessToken, _, authorizationException ->
                when {
                    accessToken != null -> continuation.resume(authState)
                    authorizationException != null -> continuation.resumeWithException(
                        authorizationException
                    )
                }
            }
        }
    }

    /**
     * Persists an updated [AuthState] back into the Android AccountManager.
     */
    private fun updateAndroidAccountManagerAuthState(
        accountSettings: AccountSettings, updatedAuthState: AuthState
    ) = accountSettings.credentials(
        accountSettings.credentials().copy(authState = updatedAuthState)
    )
}
Loading