Getting 400 "invalid_grant" while obtaining access token from Customer Accounts API

I am trying to use Headless Customer API for Login inside expo react native app. I am able to successfully get login page in webview and do the login, after getting the code from login while trying to obtain customer token getting error.

{“error”: “invalid_grant”, “error_description”: “code_verifier is invalid.”}

Not sure where i am being wrong.

I am following the docs here,

import * as Crypto from 'expo-crypto';
import { router } from 'expo-router';
import { useEffect, useRef, useState } from 'react';
import { View } from 'react-native';
import WebView from 'react-native-webview';

import Toolbar from '../../screens/commonComponents/Toolbar';

import { AppSafeAreaView } from '@/screens/commonComponents/AppSafeAreaView';
import { Spinner } from '@/screens/commonComponents/Spinner';

const NewLogin = () => {
  const webViewRef = useRef(null);
  const [urlToOpen, setUrlToOpen] = useState('');
  const [verifier, setVerifier] = useState('');
  const [loading, setLoading] = useState(false);

  const generateURL = async () => {
    const state = await generateState();
    const nonce = await generateNonce(10);
    
    const url = `${process.env.EXPO_PUBLIC_SHOPIFY_AUTHORIZATION_URL}`;
    console.debug(
      'process.env.SHOPIFY_AUTHORIZATION_URL',
      url
    );
    const authorizationRequestUrl = new URL(url);
    authorizationRequestUrl.searchParams.append(
      'scope',
      'openid email customer-account-api:full',
    );
    authorizationRequestUrl.searchParams.append(
      'client_id',
      process.env.EXPO_PUBLIC_CLIENT_ID as string,
    );
    authorizationRequestUrl.searchParams.append('response_type', 'code');
    authorizationRequestUrl.searchParams.append(
      'redirect_uri',
      process.env.EXPO_PUBLIC_SHOPIFY_REDIRECT_URL as string,
    );
    authorizationRequestUrl.searchParams.append('state', state);
    authorizationRequestUrl.searchParams.append('nonce', nonce);
    const verifier = await generateCodeVerifier();
    setVerifier(verifier);
    const challenge = await generateCodeChallenge(verifier);

    authorizationRequestUrl.searchParams.append('code_challenge', challenge);
    authorizationRequestUrl.searchParams.append(
      'code_challenge_method',
      'S256',
    );
    setUrlToOpen(authorizationRequestUrl.toString());
    console.debug(
      'authorizationRequestUrl.toString()',
      authorizationRequestUrl.toString(),
    );
    return authorizationRequestUrl.toString();
  };
  useEffect(() => {
    generateURL();
  }, []);
  const obtainToken = async (code: string) => {
    setLoading(true);
    const body = new URLSearchParams();

    body.append('grant_type', 'authorization_code');
    body.append('client_id', `${process.env.EXPO_PUBLIC_CLIENT_ID}`);
    body.append(
      'redirect_uri',
      `${process.env.EXPO_PUBLIC_SHOPIFY_REDIRECT_URL}`,
    );
    body.append('code', code);
    body.append('code_verifier', verifier);

    const headers = {
      'content-type': 'application/x-www-form-urlencoded',
    };

    for (const [key, value] of body.entries()) {
      console.log(`${key}, ${value}`);
    }
    try {
      const response = await fetch(
        `${process.env.EXPO_PUBLIC_SHOPIFY_TOKEN_URL}`,
        {
          method: 'POST',
          headers,
          body: body.toString(),
        },
      );
      const responseData = await response.json();
      console.debug('responseData', responseData);
    } catch (error) {
      console.debug('error', error);
    } 
  };
  return (
    <AppSafeAreaView>
      <Toolbar
        statusBarColor={'white'}
        barStyle={'light-dark'}
        title={'Login'}
        logoTitleStyle={{}}
        leftIcon={true}
      />

      <View style={{ flex: 1 }}>
        {loading ? (
          <Spinner />
        ) : (
          <WebView
            style={{ flex: 1 }}
            ref={webViewRef}
            startInLoadingState={true}
            automaticallyAdjustContentInsets={false}
            renderLoading={() => <Spinner />}
            source={{
              uri: urlToOpen,
            }}
            onNavigationStateChange={(webViewState) => {
              const url = webViewState.url;
              if (url.includes('?code=')) {
                const urlObj = new URL(url);
                const code = urlObj.searchParams.get('code');
                console.debug('code', code);
                if (code) {
                  obtainToken(code);
                }
              }
            }}
          />
        )}
      </View>
    </AppSafeAreaView>
  );
};

export default NewLogin;

export async function generateCodeVerifier() {
  const random = generateRandomCode();
  return base64UrlEncode(random);
}

export async function generateCodeChallenge(codeVerifier: string) {
  const digest = await Crypto.digestStringAsync(
    Crypto.CryptoDigestAlgorithm.SHA256,
    codeVerifier,
    { encoding: Crypto.CryptoEncoding.BASE64 },
  );
  return base64UrlEncode(digest);
}

function generateRandomCode() {
  const array = new Uint8Array(32);
  Crypto.getRandomValues(array);
  return String.fromCharCode.apply(null, Array.from(array));
}

function base64UrlEncode(str: string) {
  const base64 = btoa(str);
  // This is to ensure that the encoding does not have +, /, or = characters in it.
  return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}

export async function generateState() {
  const timestamp = Date.now().toString();
  const randomString = Math.random().toString(36).substring(2);
  return timestamp + randomString;
}
export async function generateNonce(length: number) {
  const characters =
    'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  let nonce = '';

  for (let i = 0; i < length; i++) {
    const randomIndex = Math.floor(Math.random() * characters.length);
    nonce += characters.charAt(randomIndex);
  }

  return nonce;
}

export async function getNonce(token: string) {
  return decodeJwt(token).payload.nonce;
}

export function decodeJwt(token: string) {
  const [header, payload, signature] = token.split('.');

  const decodedHeader = JSON.parse(atob(header));
  const decodedPayload = JSON.parse(atob(payload));

  return {
    header: decodedHeader,
    payload: decodedPayload,
    signature,
  };
}

1 Like

Hey @Jatin_Emizentech

Comparing what you’ve provided with our docs, I see a potential issue that could lead to the error you are seeing.

For the hash generation method, I see you’re using Expo’s Crypto.digestStringAsync with CryptoEncoding.BASE64 , and then applying base64UrlEncode on that already base64-encoded string. This looks to be creating a double-encoding situation, producing a challenge value that doesn’t match what the API expects when verifying against your code_verifier.

In the documentation example, we first encode the input with TextEncoder before hashing, then convert the buffer to a string before doing the final base64 encoding.

@KyleG-Shopify Thanks it solved the blocker for me

2 Likes