supertokens / supertokens-react-native

React Native SDK for SuperTokens
Other
28 stars 11 forks source link

Support graphql-request using fetch pollyfills #100

Open jono-allen opened 1 year ago

jono-allen commented 1 year ago

Continuing from https://discord.com/channels/603466164219281420/1084681154013184040/1084927349562286081

Summary

React-native can authenticate using st-react-native but api calls to protected routes do not get cookies or headers attached to perform authenticated requests. This only affects react-native but not web react apps.

The issue:

Using graphql requests on react-native/expo to perform an authenticated fetch to an express server with supertokens-node, the fetch calls fails to contain the cookies or headers required to authenticate the request. Using pure "fetch" on react-native, the request passes. Using graphql-requests on with next.js the request passes. I tried with whatwg-fetch which also fails.

Tested using st-core@5.x and st-node@14.0.2 with supertokens-react-native@4.0.1

Prior to "supertokens-node": "12.1.6" graphql-requests would work but after 13.x this no longer works

nkshah2 commented 1 year ago

Hey @jono-allen

Just to confirm, you are saying this used to work with supertokens-node 12.1.6? Im clarifying because graphql-requests should have no relation to the backend SDK

jono-allen commented 1 year ago

Hey @nkshah2 ,

Sorry I should also mention that we had our managed ST core upgraded as well, I can't remember what version it was on beforehand but we are now on 5.x. (Think it might have been 3.x) I rolled everything back except core to a known working version of our code that did work before the upgrade and can confirm that was not able to perform any authenticated requests via graphql-request or cross-fetch.

I have switched back to pure react native fetch using the latest version of (react-native, react and node) super tokens sdk and can perform authenticated requests.

nkshah2 commented 1 year ago

Right can you confirm the versions for the following:

If you can please provide versions for when the authenticated requests work and when they don't. Will be really helpful when we try to recreate the issue

rishabhpoddar commented 1 year ago

@jono-allen any updates?

jono-allen commented 1 year ago

Hey sorry for the delay. Was hoping to put together a example but haven't had time.

Should note that we are using supertokens in a monorepo with next.js and expo

Here are the version when authenticated requests worked on react-native via graphql-requests

"supertokens-react-native": "4.0.0"
"supertokens-auth-react": "0.27.2"
"supertokens-node": "12.1.6",
"graphql-request": "5.1.0",
Uknown supertokens core version (Using managed service)

Here are the version when authenticated requests failed on react-native via graphql-requests

"supertokens-react-native": "4.0.1"
"supertokens-auth-react": "0.32.3",
 "supertokens-web-js": "^0.5.0"
 "supertokens-node": "14.0.2",
"graphql-request": "5.1.0",
Uknown supertokens core version (Using managed service). Requested update to work with latest version

Api calls that worked, then did not work using following graphql api request

import { GraphQLClient } from 'graphql-request'
import { API_BASE_PATH } from 'app/constants'
import { Session } from 'app/services/auth/superTokens'
import { Platform } from 'react-native'
type ErrorAnyResponse = {
  error: Error
  response: Response
  request: Request
}
async function refreshMiddleware(response: unknown) {
  if (Platform.OS === 'web') {
    // web response works as expected
    return
  }
  const result = response as Response | ErrorAnyResponse
  if ('response' in result && result.response.status === 401) {
    console.log('Attempting refresh')
    await Session.attemptRefreshingSession()
    return
  }
  if ('status' in result && result.status === 401) {
    console.log('Attempting refresh')
    await Session.attemptRefreshingSession()
  }
}

const storeApi = new GraphQLClient(`${API_BASE_PATH}/graphql`, {
  credentials: 'include',
  mode: 'cors',
  responseMiddleware: async (response) => {
    await refreshMiddleware(response)
  },
})

storeApi.request(MyQuery) // fails to authenicate

Solution without using graphql requests

Versions

"supertokens-node": "14.0.2",
"supertokens-react-native": "4.0.1"
Removed graphql requests
import { API_BASE_PATH } from 'app/constants'
import type { TypedDocumentNode } from '@graphql-typed-document-node/core'
import type { GraphQLError } from 'graphql/error/GraphQLError.js'

export declare type RequestDocument = string | DocumentNode

import type { DocumentNode, OperationDefinitionNode } from 'graphql'
import { parse, print } from 'graphql'
export interface GraphQLRequestContext<V extends Variables = Variables> {
  query: string | string[]
  variables?: V
}

export interface GraphQLResponse<T = unknown> {
  data?: T
  errors?: GraphQLError[]
  extensions?: unknown
  status: number
  [key: string]: unknown
}

const extractOperationName = (document: DocumentNode): string | undefined => {
  let operationName = undefined

  const operationDefinitions = document.definitions.filter(
    (definition) => definition.kind === `OperationDefinition`
  ) as OperationDefinitionNode[]

  if (operationDefinitions.length === 1) {
    operationName = operationDefinitions[0]?.name?.value
  }

  return operationName
}
export const resolveRequestDocument = (
  document: RequestDocument
): { query: string; operationName?: string } => {
  if (typeof document === `string`) {
    let operationName = undefined

    try {
      const parsedDocument = parse(document)
      operationName = extractOperationName(parsedDocument)
    } catch (err) {
      // Failed parsing the document, the operationName will be undefined
    }

    return { query: document, operationName }
  }

  const operationName = extractOperationName(document)

  return { query: print(document), operationName }
}

export class ClientError extends Error {
  response: GraphQLResponse
  request: GraphQLRequestContext

  constructor(response: GraphQLResponse, request: GraphQLRequestContext) {
    const message = `${ClientError.extractMessage(response)}: ${JSON.stringify({
      response,
      request,
    })}`

    super(message)

    Object.setPrototypeOf(this, ClientError.prototype)

    this.response = response
    this.request = request

    // this is needed as Safari doesn't support .captureStackTrace
    if (typeof Error.captureStackTrace === `function`) {
      Error.captureStackTrace(this, ClientError)
    }
  }

  private static extractMessage(response: GraphQLResponse): string {
    return (
      response.errors?.[0]?.message ??
      `GraphQL Error (Code: ${response.status})`
    )
  }
}

export declare type Variables = {
  [key: string]: any
}
export interface GraphQLClientResponse<Data> {
  status: number
  headers: Headers
  data: Data
  extensions?: unknown
}

function graphqlRequest() {
  const headers = {
    'Content-Type': 'application/json',
  }

  const buildOptions = <V extends Variables = Variables>(
    query: string,
    operationName?: string,
    variables?: V
  ): RequestInit => {
    return {
      method: 'POST',
      headers: headers,
      credentials: 'include',
      mode: 'cors',
      body: JSON.stringify({
        query,
        operationName: operationName,
        variables,
      }),
    }
  }

  return {
    request: async <T, V extends Variables = Variables>(
      document: RequestDocument | TypedDocumentNode<T, V>,
      variables?: V
    ): Promise<T> => {
      const { query, operationName } = resolveRequestDocument(document)

      const options = buildOptions(query, operationName, variables)
      const res = await fetch(`${API_BASE_PATH}/graphql`, options)
      if (!res.ok) {
        const errorResult =
          typeof res === `string`
            ? {
                error: res,
              }
            : res
        throw new ClientError(
          {
            ...errorResult,
            status: res.status,
            headers: res.headers,
          },
          { query, variables }
        )
      }
      const json = await res.json()
      if (json?.data) {
        return json.data
      }
      throw new Error('No data on json response')
    },
  }
}
graphqlRequest.request(MyQuery)
nkshah2 commented 1 year ago

Hey @jono-allen

Thanks for the details. Since everything seems to be working with normal fetch its not a bug. We will look into why support for graphql requests broke when moving between versions but since its not a fundamental issue with the SDK itself it will not be a priority for the team.

Leaving this issue open, we'll update here when we start making progress on this

edwinvrgs commented 11 months ago

I'm facing this issue too, using whatwg-fetch to polifyll fetch for @apollo-client in a Expo app (native and web).

The problem was on the web app, it was not being authenticated properly because of the missing cookie. I was able to get around by doing this:

    SuperTokens.init({
        recipeList: [
            Passwordless.init({}),
            Session.init({
                tokenTransferMethod: "header", // This did the trick
            }),
        ],
        appInfo: {
        // ...
        },
    })