appwrite / sdk-for-react-native

[READ ONLY] Official Appwrite React Native SDK πŸ’™ βš›οΈŽ
https://appwrite.io
BSD 3-Clause "New" or "Revised" License
3.18k stars 20 forks source link

πŸ› Bug Report: React Native - createOAuth2Session throws error #10

Open KartikBazzad opened 1 year ago

KartikBazzad commented 1 year ago

πŸ‘Ÿ Reproduction steps

Start a new React Native project. Enable OAuth.

import React from 'react';
import {Button, Text, View} from 'react-native';
import {Client, Account} from 'appwrite';

const client = new Client()
  .setEndpoint('https://endpoint/v1')
  .setProject('12343455667');

const App = () => {
  function handleUserLogin() {
    const account = new Account(client);
    const session = account.createOAuth2Session('google');
    return session;
  }

  return (
    <View>
      <Text>Hello world</Text>
      <Button title="Login" onPress={handleUserLogin} />
    </View>
  );
};

πŸ‘ Expected behavior

Should have opened a window for selecting an account from the OAuth client like google

πŸ‘Ž Actual Behavior

Throwed Error

TypeError: Cannot set property href of [object WorkerLocation] which has only a getter

image

🎲 Appwrite version

Different version (specify in environment)

πŸ’» Operating system

Windows

🧱 Your Environment

I am using Appwrite v:1.0.1.500 React Native 0.70.1 React 18.1.0

πŸ‘€ Have you spent some time to check if this issue has been raised before?

🏒 Have you read the Code of Conduct?

Alwinseb01 commented 1 year ago

I would like to work on this issue please

stnguyen90 commented 1 year ago

This is where the error occurs:

https://github.com/appwrite/sdk-for-web/blob/98d0c5a212e7fd7a11d9f11625e1a4b7347e5cca/src/services/account.ts#L674

Looks like href is read only in react native

khanghk commented 1 year ago

I try but don't show login form of google. Please check. Thanks

DiegoBM commented 1 year ago

React-native can't just open a web url. The Appwrite SDK was designed for web browsers, which make redirections, and for that reason certain parts like OAuth authentication won't work out of the box in react-native. There are a few ways around this though:

  1. As far as I know, the recommended way to do OAuth authentication in mobile is by triggering the phone's external browser to do the authentication and go back to your app with the token. There are libraries that do this such as "react-native-app-auth". but bear in mind that, as of today, it will require some configuration on your app, although you can find all the steps in their github page.
  2. You can roll out your own OAuth implementation by adding a WebView into your app and capturing the redirections. This is not recommended anymore though, due to potential security implications, and certain providers like Facebook have started to try to prevent developers from using this method (although at the moment can still be overridden)
  3. If you choose to go with option 2. I created long ago a react-native component that implements the OAuth flow over a WebView and should work with the latest Appwrite version as of December 2022. You can find it here "react-native-appwrite-oauth".

Hope this helps

Note: this might be relevant for issue #1177797467

ghost commented 1 year ago

I was looking for a solution to avoid closed common other backend services. I think AppWrite is doing that well and it would be so useful if AppWrite supports oauth with reactive-native in the future.

eldadfux commented 5 months ago

We're working a new dedicated React Native SDK. Moving this issue to the new repository

winterdouglas commented 4 months ago

Hey, nice that you're putting some efforts on a dedicated react-native sdk! :)

Turns out that I've been trying appwrite in a RN app and this was the first thing that I came across.

I managed to workaround the issue for now by overriding the Account and doing my own implementation based on the other appwrite mobile sdks.

I've used expo-web-browser for the auth session (react-native-inappbrowser-reborn is another option), along with @react-native-cookies/cookies to set the needed cookie.

If anybody's interested on it until it's implemented in the sdk, here are my snippets:

Use at your own risk!

/lib/appwrite/account.ts

import * as WebBrowser from "expo-web-browser";

import { Account, AppwriteException, OAuthProvider } from "appwrite";

import { handleIncomingCookie } from "./handleIncomingCookie";
import { buildQueryParams } from "./uri";

export class OauthAwareAccount extends Account {
  /**
   * @deprecated Temporaryly use {@link createOAuthSession} instead, can't use this because of its return type (not a promise)
   * @param provider
   * @param success
   * @param failure
   * @param scopes
   * @returns
   */
  override createOAuth2Session(
    provider: OAuthProvider,
    success?: string | undefined,
    failure?: string | undefined,
    scopes?: string[] | undefined,
  ): void | URL {
    return super.createOAuth2Session(provider, success, failure, scopes);
  }

  async createOAuthSession(
    provider: OAuthProvider,
    success?: string | undefined,
    failure?: string | undefined,
    scopes?: string[] | undefined,
  ): Promise<void | URL> {
    if (!provider) {
      throw new AppwriteException('Missing required parameter: "provider"');
    }

    const { endpoint, project } = this.client.config;
    const apiPath = `/account/sessions/oauth2/${provider}`;

    const payload: Record<string, string | string[] | undefined> = {
      success,
      failure,
      scopes,
      project,
    };

    const queryParams = buildQueryParams(payload);
    const authUrl = `${endpoint}${apiPath}${queryParams ? `?${queryParams}` : ""}`;
    const callbackUrl = `appwrite-callback-${project}`;

    const browserResult = await WebBrowser.openAuthSessionAsync(
      authUrl,
      callbackUrl,
    );

    if (browserResult.type !== "success") {
      return;
    }

    const url = browserResult.url;

    if (!(await handleIncomingCookie(url, endpoint))) {
      return;
    }

    return new URL(url);
  }
}

/lib/appwrite/handleIncomingCookie.ts

import CookieManager, { Cookie } from "@react-native-cookies/cookies";
import { AppwriteException } from "appwrite";

import { parseQueryParams } from "./uri";

export const handleIncomingCookie = async (url: string, endpoint: string) => {
  if (!url.includes("appwrite-callback")) {
    return false;
  }

  const queryParams = parseQueryParams(url);

  if (!queryParams.key || !queryParams.secret || !queryParams.domain) {
    throw new AppwriteException(
      "Invalid OAuth2 Response. Key, Secret and Domain not available.",
      500,
    );
  }

  const domainUrl = new URL(endpoint);

  const cookie: Cookie = {
    name: queryParams.key,
    value: queryParams.secret,
    path: queryParams.path,
    expires: queryParams.expires,
    secure: "secure" in queryParams,
    httpOnly: "httpOnly" in queryParams,
    domain: domainUrl.hostname,
  };

  return CookieManager.set(domainUrl.toString(), cookie);
};

/lib/appwrite/uri.ts

The sdk has some utilities for this already, like flatten in service.ts, I didn't use that because I based my implementation on Flutter's for this. I wasn't sure if I should encodeURIComponent or not, for example. Seems to work like this. Additionally, I didn't implement the recursive flatten, I do that with a simple map, which should be fine too.

export const buildQueryParams = (
  params: Record<string, string | string[] | undefined>,
) =>
  Object.keys(params).reduce((acc, currentKey) => {
    const currentValueForKey = params[currentKey];

    if (currentValueForKey === undefined) {
      return acc;
    }

    if (Array.isArray(currentValueForKey)) {
      const arrayQuery = currentValueForKey
        .map(
          (value) =>
            `${encodeURIComponent(`${currentKey}[]`)}=${encodeURIComponent(value)}`,
        )
        .join("&");
      return `${acc}&${arrayQuery}`;
    }

    return `${acc}&${encodeURIComponent(currentKey)}=${encodeURIComponent(currentValueForKey)}`;
  }, "");

export const parseQueryParams = (url: string) => {
  const queryParams = url.includes("?") ? url.split("?")[1] : url;

  if (!queryParams) {
    return {};
  }

  return queryParams.split("&").reduce(
    (acc, curr) => {
      const [key, value] = curr.split("=");
      return { ...acc, [key as string]: value };
    },
    {} as Record<string, string | undefined>,
  );
};

To use the code above simply instantiate a new OauthAwareAccount(client) rather than the Account from the sdk in your appwrite client configuration.

@eldadfux depending on what you envision for this on the react native sdk, I can PR something, just let me know.

Hope this helps in some way!

albertorg commented 3 months ago

A simpler way to do it is with the following code:

const url = account.createOAuth2Token(OAuthProvider.Google, 'here-call-back-url');

const browserResult = await WebBrowser.openAuthSessionAsync(url.href, 'here-call-back-url');

const urlObject = new URL(browserResult.url);
const secret = urlObject.searchParams.get('secret');
const userId = urlObject.searchParams.get('userId');

const session = await appwrite.account.createSession(userId, secret);

In summary, it is using createOAuth2Token instead of createOAuth2Session because createOAuth2Token returns secret and userId which are necessary to create the session.

AhmedAlsudairy commented 3 months ago

A simpler way to do it is with the following code:

const url = account.createOAuth2Token(OAuthProvider.Google, 'here-call-back-url');

const browserResult = await WebBrowser.openAuthSessionAsync(url.href, 'here-call-back-url');

const urlObject = new URL(browserResult.url);
const secret = urlObject.searchParams.get('secret');
const userId = urlObject.searchParams.get('userId');

const session = await appwrite.account.createSession(userId, secret);

In summary, it is using createOAuth2Token instead of createOAuth2Session because createOAuth2Token returns secret and userId which are necessary to create the session.

could you tell me what is 'here-call-back-url' i don't get it

albertorg commented 3 months ago

could you tell me what is 'here-call-back-url' i don't get it

The url to deep link back into your app.

xuelink commented 1 month ago

A simpler way to do it is with the following code:

const url = account.createOAuth2Token(OAuthProvider.Google, 'here-call-back-url');

const browserResult = await WebBrowser.openAuthSessionAsync(url.href, 'here-call-back-url');

const urlObject = new URL(browserResult.url);
const secret = urlObject.searchParams.get('secret');
const userId = urlObject.searchParams.get('userId');

const session = await appwrite.account.createSession(userId, secret);

In summary, it is using createOAuth2Token instead of createOAuth2Session because createOAuth2Token returns secret and userId which are necessary to create the session.

could you tell me what is 'here-call-back-url' i don't get it

Thank you, that is what i need !

Without deeplink, you can use following logic.

import {
  openAuthSessionAsync,
  WebBrowserAuthSessionResult,
} from "expo-web-browser";

...

  const [browserResult, setBrowserResult] =
    useState<WebBrowserAuthSessionResult | null>(null);

...

  const signInWithProvider = async (provider: OAuthProvider) => {
    console.log(`Signing in with ${provider}`);
    const url = createOAuth2Token(provider);

    if (url) {
      const urlString = url.toString();
      console.log(urlString);

      if (Platform.OS !== "web") {
        // Prevent the default behavior of linking to the default browser on native.
        // event.preventDefault();
        const result = await openAuthSessionAsync(urlString);
        setBrowserResult(result);
      }
    } else {
      console.error("Failed to obtain URL for provider:", provider);
    }
  };
  // authService.js
  export function createOAuth2Token(provider: OAuthProvider) {
  try {
    const token = account.createOAuth2Token(
      provider,
      SUCCESS_OAUTH2,
      FAILURE_OAUTH2
    );

    return token;
  } catch (error) {
    throw new Error(error);
  }
}

export async function createSession(userId: string, secret: string) {
  try {
    const session = await account.createSession(userId, secret);

    return session;
  } catch (error) {
    throw new Error(error);
  }
}