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,
};
}