diff --git a/.changeset/three-taxis-bow.md b/.changeset/three-taxis-bow.md new file mode 100644 index 0000000000..d1620728ad --- /dev/null +++ b/.changeset/three-taxis-bow.md @@ -0,0 +1,5 @@ +--- +'@shopify/hydrogen': patch +--- + +✨ add a utility `getAccessToken` to customerClient diff --git a/packages/hydrogen/docs/generated/generated_docs_data.json b/packages/hydrogen/docs/generated/generated_docs_data.json index 4a6b3a4d14..ea0d602125 100644 --- a/packages/hydrogen/docs/generated/generated_docs_data.json +++ b/packages/hydrogen/docs/generated/generated_docs_data.json @@ -8885,7 +8885,7 @@ "name": "CustomerClient", "value": "CustomerClient" }, - "value": "export function createCustomerClient({\n session,\n customerAccountId,\n customerAccountUrl,\n customerApiVersion = DEFAULT_CUSTOMER_API_VERSION,\n request,\n waitUntil,\n}: CustomerClientOptions): CustomerClient {\n if (customerApiVersion !== DEFAULT_CUSTOMER_API_VERSION) {\n console.log(\n `[h2:warn:createCustomerClient] You are using Customer Account API version ${customerApiVersion} when this version of Hydrogen was built for ${DEFAULT_CUSTOMER_API_VERSION}.`,\n );\n }\n\n if (!request?.url) {\n throw new Error(\n '[h2:error:createCustomerClient] The request object does not contain a URL.',\n );\n }\n const url = new URL(request.url);\n const origin =\n url.protocol === 'http:' ? url.origin.replace('http', 'https') : url.origin;\n\n const locks: Locks = {};\n\n const logSubRequestEvent =\n process.env.NODE_ENV === 'development'\n ? (query: string, startTime: number) => {\n (globalThis as any).__H2O_LOG_EVENT?.({\n eventType: 'subrequest',\n url: `https://shopify.dev/?${hashKey([\n `Customer Account `,\n /((query|mutation) [^\\s\\(]+)/g.exec(query)?.[0] ||\n query.substring(0, 10),\n ])}`,\n startTime,\n waitUntil,\n ...getDebugHeaders(request),\n });\n }\n : undefined;\n\n async function fetchCustomerAPI({\n query,\n type,\n variables = {},\n }: {\n query: string;\n type: 'query' | 'mutation';\n variables?: GenericVariables;\n }) {\n const accessToken = session.get('customer_access_token');\n const expiresAt = session.get('expires_at');\n\n if (!accessToken || !expiresAt)\n throw new BadRequest(\n 'Unauthorized',\n 'Login before querying the Customer Account API.',\n );\n\n await checkExpires({\n locks,\n expiresAt,\n session,\n customerAccountId,\n customerAccountUrl,\n origin,\n });\n\n const startTime = new Date().getTime();\n\n const response = await fetch(\n `${customerAccountUrl}/account/customer/api/${customerApiVersion}/graphql`,\n {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'User-Agent': USER_AGENT,\n Origin: origin,\n Authorization: accessToken,\n },\n body: JSON.stringify({\n operationName: 'SomeQuery',\n query,\n variables,\n }),\n },\n );\n\n logSubRequestEvent?.(query, startTime);\n\n const body = await response.text();\n\n const errorOptions: GraphQLErrorOptions = {\n response,\n type,\n query,\n queryVariables: variables,\n errors: undefined,\n client: 'customer',\n };\n\n if (!response.ok) {\n /**\n * The Customer API might return a string error, or a JSON-formatted {error: string}.\n * We try both and conform them to a single {errors} format.\n */\n let errors;\n try {\n errors = parseJSON(body);\n } catch (_e) {\n errors = [{message: body}];\n }\n\n throwGraphQLError({...errorOptions, errors});\n }\n\n try {\n return parseJSON(body);\n } catch (e) {\n throwGraphQLError({...errorOptions, errors: [{message: body}]});\n }\n }\n\n return {\n login: async () => {\n const loginUrl = new URL(customerAccountUrl + '/auth/oauth/authorize');\n\n const state = await generateState();\n const nonce = await generateNonce();\n\n loginUrl.searchParams.set('client_id', customerAccountId);\n loginUrl.searchParams.set('scope', 'openid email');\n loginUrl.searchParams.append('response_type', 'code');\n loginUrl.searchParams.append('redirect_uri', origin + '/authorize');\n loginUrl.searchParams.set(\n 'scope',\n 'openid email https://api.customers.com/auth/customer.graphql',\n );\n loginUrl.searchParams.append('state', state);\n loginUrl.searchParams.append('nonce', nonce);\n\n const verifier = await generateCodeVerifier();\n const challenge = await generateCodeChallenge(verifier);\n\n session.set('code-verifier', verifier);\n session.set('state', state);\n session.set('nonce', nonce);\n\n loginUrl.searchParams.append('code_challenge', challenge);\n loginUrl.searchParams.append('code_challenge_method', 'S256');\n\n return redirect(loginUrl.toString(), {\n headers: {\n 'Set-Cookie': await session.commit(),\n },\n });\n },\n logout: async () => {\n const idToken = session.get('id_token');\n\n clearSession(session);\n\n return redirect(\n `${customerAccountUrl}/auth/logout?id_token_hint=${idToken}`,\n {\n status: 302,\n\n headers: {\n 'Set-Cookie': await session.commit(),\n },\n },\n );\n },\n isLoggedIn: async () => {\n const expiresAt = session.get('expires_at');\n\n if (!session.get('customer_access_token') || !expiresAt) return false;\n\n const startTime = new Date().getTime();\n\n try {\n await checkExpires({\n locks,\n expiresAt,\n session,\n customerAccountId,\n customerAccountUrl,\n origin,\n });\n\n logSubRequestEvent?.(' check expires', startTime);\n } catch {\n return false;\n }\n\n return true;\n },\n mutate(mutation, options?) {\n mutation = minifyQuery(mutation);\n assertMutation(mutation, 'customer.mutate');\n\n return fetchCustomerAPI({query: mutation, type: 'mutation', ...options});\n },\n query(query, options?) {\n query = minifyQuery(query);\n assertQuery(query, 'customer.query');\n\n return fetchCustomerAPI({query, type: 'query', ...options});\n },\n authorize: async (redirectPath = '/') => {\n const code = url.searchParams.get('code');\n const state = url.searchParams.get('state');\n\n if (!code || !state) {\n clearSession(session);\n throw new BadRequest(\n 'Unauthorized',\n 'No code or state parameter found in the redirect URL.',\n );\n }\n\n if (session.get('state') !== state) {\n clearSession(session);\n throw new BadRequest(\n 'Unauthorized',\n 'The session state does not match the state parameter. Make sure that the session is configured correctly and passed to `createCustomerClient`.',\n );\n }\n\n const clientId = customerAccountId;\n const body = new URLSearchParams();\n\n body.append('grant_type', 'authorization_code');\n body.append('client_id', clientId);\n body.append('redirect_uri', origin + '/authorize');\n body.append('code', code);\n\n // Public Client\n const codeVerifier = session.get('code-verifier');\n\n if (!codeVerifier)\n throw new BadRequest(\n 'Unauthorized',\n 'No code verifier found in the session. Make sure that the session is configured correctly and passed to `createCustomerClient`.',\n );\n\n body.append('code_verifier', codeVerifier);\n\n const headers = {\n 'content-type': 'application/x-www-form-urlencoded',\n 'User-Agent': USER_AGENT,\n Origin: origin,\n };\n\n const response = await fetch(`${customerAccountUrl}/auth/oauth/token`, {\n method: 'POST',\n headers,\n body,\n });\n\n if (!response.ok) {\n throw new Response(await response.text(), {\n status: response.status,\n headers: {\n 'Content-Type': 'text/html; charset=utf-8',\n },\n });\n }\n\n const {access_token, expires_in, id_token, refresh_token} =\n await response.json();\n\n const sessionNonce = session.get('nonce');\n const responseNonce = await getNonce(id_token);\n\n if (sessionNonce !== responseNonce) {\n throw new BadRequest(\n 'Unauthorized',\n `Returned nonce does not match: ${sessionNonce} !== ${responseNonce}`,\n );\n }\n\n session.set('customer_authorization_code_token', access_token);\n session.set(\n 'expires_at',\n new Date(new Date().getTime() + (expires_in! - 120) * 1000).getTime() +\n '',\n );\n session.set('id_token', id_token);\n session.set('refresh_token', refresh_token);\n\n const customerAccessToken = await exchangeAccessToken(\n session,\n customerAccountId,\n customerAccountUrl,\n origin,\n );\n\n session.set('customer_access_token', customerAccessToken);\n\n return redirect(redirectPath, {\n headers: {\n 'Set-Cookie': await session.commit(),\n },\n });\n },\n };\n}" + "value": "export function createCustomerClient({\n session,\n customerAccountId,\n customerAccountUrl,\n customerApiVersion = DEFAULT_CUSTOMER_API_VERSION,\n request,\n waitUntil,\n}: CustomerClientOptions): CustomerClient {\n if (customerApiVersion !== DEFAULT_CUSTOMER_API_VERSION) {\n console.log(\n `[h2:warn:createCustomerClient] You are using Customer Account API version ${customerApiVersion} when this version of Hydrogen was built for ${DEFAULT_CUSTOMER_API_VERSION}.`,\n );\n }\n\n if (!request?.url) {\n throw new Error(\n '[h2:error:createCustomerClient] The request object does not contain a URL.',\n );\n }\n const url = new URL(request.url);\n const origin =\n url.protocol === 'http:' ? url.origin.replace('http', 'https') : url.origin;\n\n const locks: Locks = {};\n\n const logSubRequestEvent =\n process.env.NODE_ENV === 'development'\n ? (query: string, startTime: number) => {\n (globalThis as any).__H2O_LOG_EVENT?.({\n eventType: 'subrequest',\n url: `https://shopify.dev/?${hashKey([\n `Customer Account `,\n /((query|mutation) [^\\s\\(]+)/g.exec(query)?.[0] ||\n query.substring(0, 10),\n ])}`,\n startTime,\n waitUntil,\n ...getDebugHeaders(request),\n });\n }\n : undefined;\n\n async function fetchCustomerAPI({\n query,\n type,\n variables = {},\n }: {\n query: string;\n type: 'query' | 'mutation';\n variables?: GenericVariables;\n }) {\n const accessToken = session.get('customer_access_token');\n const expiresAt = session.get('expires_at');\n\n if (!accessToken || !expiresAt)\n throw new BadRequest(\n 'Unauthorized',\n 'Login before querying the Customer Account API.',\n );\n\n await checkExpires({\n locks,\n expiresAt,\n session,\n customerAccountId,\n customerAccountUrl,\n origin,\n });\n\n const startTime = new Date().getTime();\n\n const response = await fetch(\n `${customerAccountUrl}/account/customer/api/${customerApiVersion}/graphql`,\n {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n 'User-Agent': USER_AGENT,\n Origin: origin,\n Authorization: accessToken,\n },\n body: JSON.stringify({\n operationName: 'SomeQuery',\n query,\n variables,\n }),\n },\n );\n\n logSubRequestEvent?.(query, startTime);\n\n const body = await response.text();\n\n const errorOptions: GraphQLErrorOptions = {\n response,\n type,\n query,\n queryVariables: variables,\n errors: undefined,\n client: 'customer',\n };\n\n if (!response.ok) {\n /**\n * The Customer API might return a string error, or a JSON-formatted {error: string}.\n * We try both and conform them to a single {errors} format.\n */\n let errors;\n try {\n errors = parseJSON(body);\n } catch (_e) {\n errors = [{message: body}];\n }\n\n throwGraphQLError({...errorOptions, errors});\n }\n\n try {\n return parseJSON(body);\n } catch (e) {\n throwGraphQLError({...errorOptions, errors: [{message: body}]});\n }\n }\n\n async function isLoggedIn() {\n const expiresAt = session.get('expires_at');\n\n if (!session.get('customer_access_token') || !expiresAt) return false;\n\n const startTime = new Date().getTime();\n\n try {\n await checkExpires({\n locks,\n expiresAt,\n session,\n customerAccountId,\n customerAccountUrl,\n origin,\n });\n\n logSubRequestEvent?.(' check expires', startTime);\n } catch {\n return false;\n }\n\n return true;\n }\n\n return {\n login: async () => {\n const loginUrl = new URL(customerAccountUrl + '/auth/oauth/authorize');\n\n const state = await generateState();\n const nonce = await generateNonce();\n\n loginUrl.searchParams.set('client_id', customerAccountId);\n loginUrl.searchParams.set('scope', 'openid email');\n loginUrl.searchParams.append('response_type', 'code');\n loginUrl.searchParams.append('redirect_uri', origin + '/authorize');\n loginUrl.searchParams.set(\n 'scope',\n 'openid email https://api.customers.com/auth/customer.graphql',\n );\n loginUrl.searchParams.append('state', state);\n loginUrl.searchParams.append('nonce', nonce);\n\n const verifier = await generateCodeVerifier();\n const challenge = await generateCodeChallenge(verifier);\n\n session.set('code-verifier', verifier);\n session.set('state', state);\n session.set('nonce', nonce);\n\n loginUrl.searchParams.append('code_challenge', challenge);\n loginUrl.searchParams.append('code_challenge_method', 'S256');\n\n return redirect(loginUrl.toString(), {\n headers: {\n 'Set-Cookie': await session.commit(),\n },\n });\n },\n logout: async () => {\n const idToken = session.get('id_token');\n\n clearSession(session);\n\n return redirect(\n `${customerAccountUrl}/auth/logout?id_token_hint=${idToken}`,\n {\n status: 302,\n\n headers: {\n 'Set-Cookie': await session.commit(),\n },\n },\n );\n },\n isLoggedIn,\n getAccessToken: async () => {\n const hasAccessToken = await isLoggedIn;\n\n if (!hasAccessToken) {\n return;\n } else {\n return session.get('customer_access_token');\n }\n },\n mutate(mutation, options?) {\n mutation = minifyQuery(mutation);\n assertMutation(mutation, 'customer.mutate');\n\n return fetchCustomerAPI({query: mutation, type: 'mutation', ...options});\n },\n query(query, options?) {\n query = minifyQuery(query);\n assertQuery(query, 'customer.query');\n\n return fetchCustomerAPI({query, type: 'query', ...options});\n },\n authorize: async (redirectPath = '/') => {\n const code = url.searchParams.get('code');\n const state = url.searchParams.get('state');\n\n if (!code || !state) {\n clearSession(session);\n throw new BadRequest(\n 'Unauthorized',\n 'No code or state parameter found in the redirect URL.',\n );\n }\n\n if (session.get('state') !== state) {\n clearSession(session);\n throw new BadRequest(\n 'Unauthorized',\n 'The session state does not match the state parameter. Make sure that the session is configured correctly and passed to `createCustomerClient`.',\n );\n }\n\n const clientId = customerAccountId;\n const body = new URLSearchParams();\n\n body.append('grant_type', 'authorization_code');\n body.append('client_id', clientId);\n body.append('redirect_uri', origin + '/authorize');\n body.append('code', code);\n\n // Public Client\n const codeVerifier = session.get('code-verifier');\n\n if (!codeVerifier)\n throw new BadRequest(\n 'Unauthorized',\n 'No code verifier found in the session. Make sure that the session is configured correctly and passed to `createCustomerClient`.',\n );\n\n body.append('code_verifier', codeVerifier);\n\n const headers = {\n 'content-type': 'application/x-www-form-urlencoded',\n 'User-Agent': USER_AGENT,\n Origin: origin,\n };\n\n const response = await fetch(`${customerAccountUrl}/auth/oauth/token`, {\n method: 'POST',\n headers,\n body,\n });\n\n if (!response.ok) {\n throw new Response(await response.text(), {\n status: response.status,\n headers: {\n 'Content-Type': 'text/html; charset=utf-8',\n },\n });\n }\n\n const {access_token, expires_in, id_token, refresh_token} =\n await response.json();\n\n const sessionNonce = session.get('nonce');\n const responseNonce = await getNonce(id_token);\n\n if (sessionNonce !== responseNonce) {\n throw new BadRequest(\n 'Unauthorized',\n `Returned nonce does not match: ${sessionNonce} !== ${responseNonce}`,\n );\n }\n\n session.set('customer_authorization_code_token', access_token);\n session.set(\n 'expires_at',\n new Date(new Date().getTime() + (expires_in! - 120) * 1000).getTime() +\n '',\n );\n session.set('id_token', id_token);\n session.set('refresh_token', refresh_token);\n\n const customerAccessToken = await exchangeAccessToken(\n session,\n customerAccountId,\n customerAccountUrl,\n origin,\n );\n\n session.set('customer_access_token', customerAccessToken);\n\n return redirect(redirectPath, {\n headers: {\n 'Set-Cookie': await session.commit(),\n },\n });\n },\n };\n}" }, "CustomerClientOptions": { "filePath": "/customer/customer.ts", @@ -8976,7 +8976,7 @@ "filePath": "/customer/customer.ts", "syntaxKind": "TypeAliasDeclaration", "name": "CustomerClient", - "value": "{\n /** Start the OAuth login flow. This function should be called and returned from a Remix action. It redirects the user to a login domain. */\n login: () => Promise;\n /** On successful login, the user is redirect back to your app. This function validates the OAuth response and exchanges the authorization code for an access token and refresh token. It also persists the tokens on your session. This function should be called and returned from the Remix loader configured as the redirect URI within the Customer Account API settings. */\n authorize: (redirectPath?: string) => Promise;\n /** Returns if the user is logged in. It also checks if the access token is expired and refreshes it if needed. */\n isLoggedIn: () => Promise;\n /** Logout the user by clearing the session and redirecting to the login domain. It should be called and returned from a Remix action. */\n logout: () => Promise;\n /** Execute a GraphQL query against the Customer Account API. Usually you should first check if the user is logged in before querying the API. */\n query: <\n OverrideReturnType extends any = never,\n RawGqlString extends string = string,\n >(\n query: RawGqlString,\n ...options: ClientVariablesInRestParams<\n CustomerAccountQueries,\n RawGqlString\n >\n ) => Promise<\n CustomerAPIResponse<\n ClientReturn\n >\n >;\n /** Execute a GraphQL mutation against the Customer Account API. Usually you should first check if the user is logged in before querying the API. */\n mutate: <\n OverrideReturnType extends any = never,\n RawGqlString extends string = string,\n >(\n mutation: RawGqlString,\n ...options: ClientVariablesInRestParams<\n CustomerAccountMutations,\n RawGqlString\n >\n ) => Promise<\n CustomerAPIResponse<\n ClientReturn\n >\n >;\n}", + "value": "{\n /** Start the OAuth login flow. This function should be called and returned from a Remix action. It redirects the user to a login domain. */\n login: () => Promise;\n /** On successful login, the user is redirect back to your app. This function validates the OAuth response and exchanges the authorization code for an access token and refresh token. It also persists the tokens on your session. This function should be called and returned from the Remix loader configured as the redirect URI within the Customer Account API settings. */\n authorize: (redirectPath?: string) => Promise;\n /** Returns if the user is logged in. It also checks if the access token is expired and refreshes it if needed. */\n isLoggedIn: () => Promise;\n /** Returns CustomerAccessToken if the user is logged in. It always run a isLoggedIn refresh as well. */\n getAccessToken: () => Promise;\n /** Logout the user by clearing the session and redirecting to the login domain. It should be called and returned from a Remix action. */\n logout: () => Promise;\n /** Execute a GraphQL query against the Customer Account API. Usually you should first check if the user is logged in before querying the API. */\n query: <\n OverrideReturnType extends any = never,\n RawGqlString extends string = string,\n >(\n query: RawGqlString,\n ...options: ClientVariablesInRestParams<\n CustomerAccountQueries,\n RawGqlString\n >\n ) => Promise<\n CustomerAPIResponse<\n ClientReturn\n >\n >;\n /** Execute a GraphQL mutation against the Customer Account API. Usually you should first check if the user is logged in before querying the API. */\n mutate: <\n OverrideReturnType extends any = never,\n RawGqlString extends string = string,\n >(\n mutation: RawGqlString,\n ...options: ClientVariablesInRestParams<\n CustomerAccountMutations,\n RawGqlString\n >\n ) => Promise<\n CustomerAPIResponse<\n ClientReturn\n >\n >;\n}", "description": "", "members": [ { @@ -9000,6 +9000,13 @@ "value": "() => Promise", "description": "Returns if the user is logged in. It also checks if the access token is expired and refreshes it if needed." }, + { + "filePath": "/customer/customer.ts", + "syntaxKind": "PropertySignature", + "name": "getAccessToken", + "value": "() => Promise", + "description": "Returns CustomerAccessToken if the user is logged in. It always run a isLoggedIn refresh as well." + }, { "filePath": "/customer/customer.ts", "syntaxKind": "PropertySignature", diff --git a/packages/hydrogen/src/customer/customer.ts b/packages/hydrogen/src/customer/customer.ts index b560f5b7c0..345c587ca1 100644 --- a/packages/hydrogen/src/customer/customer.ts +++ b/packages/hydrogen/src/customer/customer.ts @@ -29,6 +29,7 @@ import { import {parseJSON} from '../utils/parse-json'; import {hashKey} from '../utils/hash'; import {CrossRuntimeRequest, getDebugHeaders} from '../utils/request'; +import {CustomerAccessToken} from '@shopify/hydrogen-react/storefront-api-types'; type CustomerAPIResponse = { data: ReturnType; @@ -68,6 +69,8 @@ export type CustomerClient = { authorize: (redirectPath?: string) => Promise; /** Returns if the user is logged in. It also checks if the access token is expired and refreshes it if needed. */ isLoggedIn: () => Promise; + /** Returns CustomerAccessToken if the user is logged in. It always run a isLoggedIn refresh as well. */ + getAccessToken: () => Promise; /** Logout the user by clearing the session and redirecting to the login domain. It should be called and returned from a Remix action. */ logout: () => Promise; /** Execute a GraphQL query against the Customer Account API. Usually you should first check if the user is logged in before querying the API. */ @@ -243,6 +246,31 @@ export function createCustomerClient({ } } + async function isLoggedIn() { + const expiresAt = session.get('expires_at'); + + if (!session.get('customer_access_token') || !expiresAt) return false; + + const startTime = new Date().getTime(); + + try { + await checkExpires({ + locks, + expiresAt, + session, + customerAccountId, + customerAccountUrl, + origin, + }); + + logSubRequestEvent?.(' check expires', startTime); + } catch { + return false; + } + + return true; + } + return { login: async () => { const loginUrl = new URL(customerAccountUrl + '/auth/oauth/authorize'); @@ -293,29 +321,15 @@ export function createCustomerClient({ }, ); }, - isLoggedIn: async () => { - const expiresAt = session.get('expires_at'); - - if (!session.get('customer_access_token') || !expiresAt) return false; - - const startTime = new Date().getTime(); - - try { - await checkExpires({ - locks, - expiresAt, - session, - customerAccountId, - customerAccountUrl, - origin, - }); - - logSubRequestEvent?.(' check expires', startTime); - } catch { - return false; + isLoggedIn, + getAccessToken: async () => { + const hasAccessToken = await isLoggedIn; + + if (!hasAccessToken) { + return; + } else { + return session.get('customer_access_token'); } - - return true; }, mutate(mutation, options?) { mutation = minifyQuery(mutation);