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

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

AM: Switch to kotlin and support for eDrive

parent 00476ddc
Loading
Loading
Loading
Loading
+0 −415
Original line number Diff line number Diff line
/*
 * Nextcloud - Android Client
 *
 * SPDX-FileCopyrightText: 2019 David Luhmer <david-dev@live.de>
 * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
 *
 * More information here: https://github.com/abeluck/android-streams-ipc
 */
package com.nextcloud.android.sso;

import android.accounts.Account;
import android.accounts.AuthenticatorException;
import android.accounts.OperationCanceledException;
import android.content.Context;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Binder;
import android.os.ParcelFileDescriptor;
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.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.operations.RemoteOperation;

import org.apache.commons.httpclient.HttpConnection;
import org.apache.commons.httpclient.HttpMethodBase;
import org.apache.commons.httpclient.HttpState;
import org.apache.commons.httpclient.NameValuePair;
import org.apache.commons.httpclient.methods.DeleteMethod;
import org.apache.commons.httpclient.methods.GetMethod;
import org.apache.commons.httpclient.methods.HeadMethod;
import org.apache.commons.httpclient.methods.InputStreamRequestEntity;
import org.apache.commons.httpclient.methods.PostMethod;
import org.apache.commons.httpclient.methods.PutMethod;
import org.apache.commons.httpclient.methods.RequestEntity;
import org.apache.commons.httpclient.methods.StringRequestEntity;
import org.apache.jackrabbit.webdav.DavConstants;
import org.apache.jackrabbit.webdav.client.methods.MkColMethod;
import org.apache.jackrabbit.webdav.client.methods.PropFindMethod;
import org.apache.jackrabbit.webdav.property.DavPropertyNameSet;

import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
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.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;
import static com.nextcloud.android.sso.Constants.EXCEPTION_INVALID_TOKEN;
import static com.nextcloud.android.sso.Constants.EXCEPTION_UNSUPPORTED_METHOD;
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";

    private static final int HTTP_STATUS_CODE_OK = 200;
    private static final int HTTP_STATUS_CODE_MULTIPLE_CHOICES = 300;

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

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

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

    public ParcelFileDescriptor performNextcloudRequestAndBodyStreamV2(
        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;
        Response response = new Response();

        try {
            // Start request and catch exceptions
            NextcloudRequest request = deserializeObjectAndCloseStream(is);
            response = processRequestV2(request, requestBodyInputStream);
        } catch (Exception e) {
            logger.log(Level.SEVERE, "Error during Nextcloud request", e);
            exception = e;
        }

        try {
            // Write exception to the stream followed by the actual network stream
            InputStream exceptionStream = serializeObjectToInputStreamV2(exception, response.getPlainHeadersString());
            InputStream resultStream = new java.io.SequenceInputStream(exceptionStream, response.getBody());

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

        return null;
    }

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

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

    private ByteArrayInputStream serializeObjectToInputStreamV2(Exception exception, String headers) {
        byte[] baosByteArray = new byte[0];
        try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
             ObjectOutputStream oos = new ObjectOutputStream(baos)) {
            oos.writeObject(exception);
            oos.writeObject(headers);
            oos.flush();
            oos.close();

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

        return new ByteArrayInputStream(baosByteArray);
    }

    private <T extends Serializable> T deserializeObjectAndCloseStream(InputStream is) throws IOException,
        ClassNotFoundException {
        ObjectInputStream ois = new ObjectInputStream(is);
        T result = (T) ois.readObject();
        is.close();
        ois.close();
        return result;
    }

    public class NCPropFindMethod extends PropFindMethod {
        NCPropFindMethod(String uri, int propfindType, int depth) throws IOException {
            super(uri, propfindType, new DavPropertyNameSet(), depth);
        }

        @Override
        protected void processResponseBody(HttpState httpState, HttpConnection httpConnection) {
            // Do not process the response body here. Instead pass it on to client app.
        }
    }

    private HttpMethodBase buildMethod(NextcloudRequest request, Uri baseUri, InputStream requestBodyInputStream)
        throws IOException {
        String requestUrl = baseUri + request.getUrl();
        HttpMethodBase method;
        switch (request.getMethod()) {
            case "GET":
                method = new GetMethod(requestUrl);
                break;

            case "POST":
                method = new PostMethod(requestUrl);
                if (requestBodyInputStream != null) {
                    RequestEntity requestEntity = new InputStreamRequestEntity(requestBodyInputStream);
                    ((PostMethod) method).setRequestEntity(requestEntity);
                } else if (request.getRequestBody() != null) {
                    StringRequestEntity requestEntity = new StringRequestEntity(
                        request.getRequestBody(),
                        CONTENT_TYPE_APPLICATION_JSON,
                        CHARSET_UTF8);
                    ((PostMethod) method).setRequestEntity(requestEntity);
                }
                break;

            case "PATCH":
                method = new PatchMethod(requestUrl);
                if (requestBodyInputStream != null) {
                    RequestEntity requestEntity = new InputStreamRequestEntity(requestBodyInputStream);
                    ((PatchMethod) method).setRequestEntity(requestEntity);
                } else if (request.getRequestBody() != null) {
                    StringRequestEntity requestEntity = new StringRequestEntity(
                        request.getRequestBody(),
                        CONTENT_TYPE_APPLICATION_JSON,
                        CHARSET_UTF8);
                    ((PatchMethod) method).setRequestEntity(requestEntity);
                }
                break;

            case "PUT":
                method = new PutMethod(requestUrl);
                if (requestBodyInputStream != null) {
                    RequestEntity requestEntity = new InputStreamRequestEntity(requestBodyInputStream);
                    ((PutMethod) method).setRequestEntity(requestEntity);
                } else if (request.getRequestBody() != null) {
                    StringRequestEntity requestEntity = new StringRequestEntity(
                        request.getRequestBody(),
                        CONTENT_TYPE_APPLICATION_JSON,
                        CHARSET_UTF8);
                    ((PutMethod) method).setRequestEntity(requestEntity);
                }
                break;

            case "DELETE":
                method = new DeleteMethod(requestUrl);
                break;

            case "PROPFIND":
                method = new NCPropFindMethod(requestUrl, DavConstants.PROPFIND_ALL_PROP, DavConstants.DEPTH_1);
                if (request.getRequestBody() != null) {
                    //text/xml; charset=UTF-8 is taken from XmlRequestEntity... Should be application/xml
                    StringRequestEntity requestEntity = new StringRequestEntity(
                        request.getRequestBody(),
                        "text/xml; charset=UTF-8",
                        CHARSET_UTF8);
                    ((PropFindMethod) method).setRequestEntity(requestEntity);
                }
                break;

            case "MKCOL":
                method = new MkColMethod(requestUrl);
                break;

            case "HEAD":
                method = new HeadMethod(requestUrl);
                break;

            default:
                throw new UnsupportedOperationException(EXCEPTION_UNSUPPORTED_METHOD);

        }
        return method;
    }

    /*
    * 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;
        }

        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 = AccountManagerUtils.getAccountByName(context, 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().isEmpty() || request.getUrl().charAt(0) != PATH_SEPARATOR) {
            throw new IllegalStateException(EXCEPTION_INVALID_REQUEST_URL,
                    new IllegalStateException("URL need to start with a /"));
        }

        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()));
        }

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

        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(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);
        } else {
            InputStream inputStream = method.getResponseBodyAsStream();
            String total = "No response body";

            // If response body is available
            if (inputStream != null) {
                total = inputStreamToString(inputStream);
                logger.severe(total);
            }

            method.releaseConnection();
            throw new IllegalStateException(EXCEPTION_HTTP_REQUEST_FAILED,
                                            new IllegalStateException(String.valueOf(status),
                                                                      new IllegalStateException(total)));
        }
    }

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

        SharedPreferences sharedPreferences = context.getSharedPreferences(SSO_SHARED_PREFERENCE,
                Context.MODE_PRIVATE);
        String hash = sharedPreferences.getString(callingPackageName + DELIMITER + request.getAccountName(), "");
        return validateToken(hash, request.getToken());
    }

    private boolean validateToken(String hash, String token) {
        if (!hash.contains("$")) {
            throw new IllegalStateException(EXCEPTION_INVALID_TOKEN);
        }

        String salt = hash.split("\\$")[1]; // TODO extract "$"

        String newHash = EncryptionUtils.generateSHA512(token, salt);

        // As discussed with Lukas R. at the Nextcloud Conf 2018, always compare whole strings
        // and don't exit prematurely if the string does not match anymore to prevent timing-attacks
        return isEqual(hash.getBytes(), newHash.getBytes());
    }

    // Taken from http://codahale.com/a-lesson-in-timing-attacks/
    private static boolean isEqual(byte[] a, byte[] b) {
        if (a.length != b.length) {
            return false;
        }

        int result = 0;
        for (int i = 0; i < a.length; i++) {
            result |= a[i] ^ b[i];
        }
        return result == 0;
    }

    private static String inputStreamToString(InputStream inputStream) {
        try {
            StringBuilder total = new StringBuilder();
            BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
            String line = reader.readLine();
            while (line != null) {
                total.append(line).append('\n');
                line = reader.readLine();
            }
            return total.toString();
        } catch (Exception e) {
            return e.getMessage();
        }
    }

    @VisibleForTesting
    public static NameValuePair[] convertListToNVP(Collection<QueryParam> list) {
        NameValuePair[] nvp = new NameValuePair[list.size()];
        int i = 0;
        for (QueryParam pair : list) {
            nvp[i] = new NameValuePair(pair.key, pair.value);
            i++;
        }
        return nvp;
    }
}
+0 −132
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)
    )
}
+0 −100

File deleted.

Preview size limit exceeded, changes collapsed.

+0 −43
Original line number Diff line number Diff line
/*
 * Nextcloud - Android Client
 *
 * SPDX-FileCopyrightText: 2019 Tobias Kaminsky <tobias@kaminsky.me>
 * SPDX-FileCopyrightText: 2019 Nextcloud GmbH
 * SPDX-License-Identifier: AGPL-3.0-or-later OR GPL-2.0-only
 */
package com.nextcloud.android.sso;

import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;

public class PlainHeader implements Serializable {
    private static final long serialVersionUID = 3284979177401282512L;

    private String name;
    private String value;

    PlainHeader(String name, String value) {
        this.name = name;
        this.value = value;
    }

    private void writeObject(ObjectOutputStream oos) throws IOException {
        oos.writeObject(name);
        oos.writeObject(value);
    }

    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        name = (String) in.readObject();
        value = (String) in.readObject();
    }

    public String getName() {
        return this.name;
    }

    public String getValue() {
        return this.value;
    }
}
+0 −59

File deleted.

Preview size limit exceeded, changes collapsed.

Loading