diff --git a/packages/hydrogen/src/createHydrogenContext.test.ts b/packages/hydrogen/src/createHydrogenContext.test.ts index 5fe8a47abf..eff430f924 100644 --- a/packages/hydrogen/src/createHydrogenContext.test.ts +++ b/packages/hydrogen/src/createHydrogenContext.test.ts @@ -58,6 +58,7 @@ const mockEnv = { 'PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID_value', PUBLIC_CUSTOMER_ACCOUNT_API_URL: 'PUBLIC_CUSTOMER_ACCOUNT_API_URL_value', PUBLIC_CHECKOUT_DOMAIN: 'PUBLIC_CHECKOUT_DOMAIN_value', + SHOP_ID: 'SHOP_ID_value', }; const defaultOptions = { @@ -179,7 +180,7 @@ describe('createHydrogenContext', () => { expect(vi.mocked(createCustomerAccountClient)).toHaveBeenCalledWith( expect.objectContaining({ customerAccountId: mockEnv.PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID, - customerAccountUrl: mockEnv.PUBLIC_CUSTOMER_ACCOUNT_API_URL, + shopId: mockEnv.SHOP_ID, }), ); }); diff --git a/packages/hydrogen/src/createHydrogenContext.ts b/packages/hydrogen/src/createHydrogenContext.ts index 8cc5d45e39..3b76887cfd 100644 --- a/packages/hydrogen/src/createHydrogenContext.ts +++ b/packages/hydrogen/src/createHydrogenContext.ts @@ -201,7 +201,7 @@ export function createHydrogenContext< // defaults customerAccountId: env.PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID, - customerAccountUrl: env.PUBLIC_CUSTOMER_ACCOUNT_API_URL, + shopId: env.SHOP_ID, }); /* diff --git a/packages/hydrogen/src/customer/auth.helpers.test.ts b/packages/hydrogen/src/customer/auth.helpers.test.ts index d796f772d1..351ba8e04d 100644 --- a/packages/hydrogen/src/customer/auth.helpers.test.ts +++ b/packages/hydrogen/src/customer/auth.helpers.test.ts @@ -58,7 +58,7 @@ describe('auth.helpers', () => { await refreshToken({ session, customerAccountId: 'customerAccountId', - customerAccountUrl: 'customerAccountUrl', + customerAccountTokenExchangeUrl: 'customerAccountTokenExchangeUrl', httpsOrigin: 'https://localhost', exchangeForStorefrontCustomerAccessToken, }); @@ -78,7 +78,7 @@ describe('auth.helpers', () => { await refreshToken({ session, customerAccountId: 'customerAccountId', - customerAccountUrl: 'customerAccountUrl', + customerAccountTokenExchangeUrl: 'customerAccountTokenExchangeUrl', httpsOrigin: 'https://localhost', exchangeForStorefrontCustomerAccessToken, }); @@ -107,7 +107,7 @@ describe('auth.helpers', () => { await refreshToken({ session, customerAccountId: 'customerAccountId', - customerAccountUrl: 'customerAccountUrl', + customerAccountTokenExchangeUrl: 'customerAccountTokenExchangeUrl', httpsOrigin: 'https://localhost', exchangeForStorefrontCustomerAccessToken, }); @@ -138,7 +138,7 @@ describe('auth.helpers', () => { await refreshToken({ session, customerAccountId: 'customerAccountId', - customerAccountUrl: 'customerAccountUrl', + customerAccountTokenExchangeUrl: 'customerAccountTokenExchangeUrl', httpsOrigin: 'https://localhost', exchangeForStorefrontCustomerAccessToken, }); @@ -197,7 +197,7 @@ describe('auth.helpers', () => { expiresAt: new Date().getTime() + 10000 + '', session, customerAccountId: 'customerAccountId', - customerAccountUrl: 'customerAccountUrl', + customerAccountTokenExchangeUrl: 'customerAccountTokenExchangeUrl', httpsOrigin: 'https://localhost', exchangeForStorefrontCustomerAccessToken, }); @@ -228,7 +228,7 @@ describe('auth.helpers', () => { expiresAt: '100', session, customerAccountId: 'customerAccountId', - customerAccountUrl: 'customerAccountUrl', + customerAccountTokenExchangeUrl: 'customerAccountTokenExchangeUrl', httpsOrigin: 'https://localhost', exchangeForStorefrontCustomerAccessToken, }); @@ -269,7 +269,7 @@ describe('auth.helpers', () => { expiresAt: '100', session, customerAccountId: 'customerAccountId', - customerAccountUrl: 'customerAccountUrl', + customerAccountTokenExchangeUrl: 'customerAccountTokenExchangeUrl', httpsOrigin: 'https://localhost', exchangeForStorefrontCustomerAccessToken, }); diff --git a/packages/hydrogen/src/customer/auth.helpers.ts b/packages/hydrogen/src/customer/auth.helpers.ts index 8a46bcfc52..e75324de57 100644 --- a/packages/hydrogen/src/customer/auth.helpers.ts +++ b/packages/hydrogen/src/customer/auth.helpers.ts @@ -70,14 +70,14 @@ export interface AccessTokenResponse { export async function refreshToken({ session, customerAccountId, - customerAccountUrl, + customerAccountTokenExchangeUrl, httpsOrigin, debugInfo, exchangeForStorefrontCustomerAccessToken, }: { session: HydrogenSession; customerAccountId: string; - customerAccountUrl: string; + customerAccountTokenExchangeUrl: string; httpsOrigin: string; debugInfo?: Partial; exchangeForStorefrontCustomerAccessToken: () => Promise; @@ -105,7 +105,7 @@ export async function refreshToken({ }; const startTime = new Date().getTime(); - const url = `${customerAccountUrl}/auth/oauth/token`; + const url = customerAccountTokenExchangeUrl; const response = await fetch(url, { method: 'POST', headers, @@ -139,7 +139,7 @@ export async function refreshToken({ const accessToken = await exchangeAccessToken( access_token, customerAccountId, - customerAccountUrl, + customerAccountTokenExchangeUrl, httpsOrigin, debugInfo, ); @@ -166,7 +166,7 @@ export async function checkExpires({ expiresAt, session, customerAccountId, - customerAccountUrl, + customerAccountTokenExchangeUrl, httpsOrigin, debugInfo, exchangeForStorefrontCustomerAccessToken, @@ -175,7 +175,7 @@ export async function checkExpires({ expiresAt: string; session: HydrogenSession; customerAccountId: string; - customerAccountUrl: string; + customerAccountTokenExchangeUrl: string; httpsOrigin: string; debugInfo?: Partial; exchangeForStorefrontCustomerAccessToken: () => Promise; @@ -187,7 +187,7 @@ export async function checkExpires({ locks.refresh = refreshToken({ session, customerAccountId, - customerAccountUrl, + customerAccountTokenExchangeUrl, httpsOrigin, debugInfo, exchangeForStorefrontCustomerAccessToken, @@ -251,7 +251,7 @@ export function generateState() { export async function exchangeAccessToken( authAccessToken: string | undefined, customerAccountId: string, - customerAccountUrl: string, + customerAccountTokenExchangeUrl: string, httpsOrigin: string, debugInfo?: Partial, ) { @@ -282,7 +282,7 @@ export async function exchangeAccessToken( }; const startTime = new Date().getTime(); - const url = `${customerAccountUrl}/auth/oauth/token`; + const url = customerAccountTokenExchangeUrl; const response = await fetch(url, { method: 'POST', headers, diff --git a/packages/hydrogen/src/customer/customer-account-helper.test.ts b/packages/hydrogen/src/customer/customer-account-helper.test.ts new file mode 100644 index 0000000000..22be9b5555 --- /dev/null +++ b/packages/hydrogen/src/customer/customer-account-helper.test.ts @@ -0,0 +1,103 @@ +import {describe, it, expect} from 'vitest'; +import {createCustomerAccountHelper, URL_TYPE} from './customer-account-helper'; + +const shopId = '1'; +const customerAccountUrl = `https://shopify.com/${shopId}`; + +describe('return correct urls', () => { + describe('when shopId is provided', () => { + const getAccountUrl = createCustomerAccountHelper( + '2024-10', + customerAccountUrl, + shopId, + ); + + it('returns customer account base url', () => { + expect(getAccountUrl(URL_TYPE.CA_BASE_URL)).toBe(customerAccountUrl); + }); + + it('returns customer account auth url', () => { + expect(getAccountUrl(URL_TYPE.CA_BASE_AUTH_URL)).toBe( + `https://shopify.com/authentication/${shopId}`, + ); + }); + + it('returns customer account graphql url', () => { + expect(getAccountUrl(URL_TYPE.GRAPHQL)).toBe( + `${customerAccountUrl}/account/customer/api/2024-10/graphql`, + ); + }); + + it('returns customer account authorize url', () => { + expect(getAccountUrl(URL_TYPE.AUTH)).toBe( + `https://shopify.com/authentication/${shopId}/oauth/authorize`, + ); + }); + + it('returns customer account login scope', () => { + expect(getAccountUrl(URL_TYPE.LOGIN_SCOPE)).toBe( + 'openid email customer-account-api:full', + ); + }); + + it('returns customer account token exchange url', () => { + expect(getAccountUrl(URL_TYPE.TOKEN_EXCHANGE)).toBe( + `https://shopify.com/authentication/${shopId}/oauth/token`, + ); + }); + + it('returns customer account logout url', () => { + expect(getAccountUrl(URL_TYPE.LOGOUT)).toBe( + `https://shopify.com/authentication/${shopId}/logout`, + ); + }); + }); + + describe('when shopId is not provided', () => { + const getAccountUrl = createCustomerAccountHelper( + '2024-10', + customerAccountUrl, + undefined, + ); + + it('returns customer account base url', () => { + expect(getAccountUrl(URL_TYPE.CA_BASE_URL)).toBe(customerAccountUrl); + }); + + it('returns customer account auth url', () => { + expect(getAccountUrl(URL_TYPE.CA_BASE_AUTH_URL)).toBe( + `${customerAccountUrl}/auth`, + ); + }); + + it('returns customer account graphql url', () => { + expect(getAccountUrl(URL_TYPE.GRAPHQL)).toBe( + `https://shopify.com/${shopId}/account/customer/api/2024-10/graphql`, + ); + }); + + it('returns customer account authorize url', () => { + expect(getAccountUrl(URL_TYPE.AUTH)).toBe( + `${customerAccountUrl}/auth/oauth/authorize`, + ); + }); + + it('returns customer account login scope', () => { + expect(getAccountUrl(URL_TYPE.LOGIN_SCOPE)).toBe( + 'openid email https://api.customers.com/auth/customer.graphql', + ); + }); + + it('returns customer account token exchange url', () => { + expect(getAccountUrl(URL_TYPE.TOKEN_EXCHANGE)).toBe( + `${customerAccountUrl}/auth/oauth/token`, + ); + }); + + it('returns customer account logout url', () => { + expect(getAccountUrl(URL_TYPE.LOGOUT)).toBe( + `${customerAccountUrl}/auth/logout`, + ); + }); + }); +}); diff --git a/packages/hydrogen/src/customer/customer-account-helper.ts b/packages/hydrogen/src/customer/customer-account-helper.ts new file mode 100644 index 0000000000..c1087eb49a --- /dev/null +++ b/packages/hydrogen/src/customer/customer-account-helper.ts @@ -0,0 +1,45 @@ +export enum URL_TYPE { + CA_BASE_URL = 'CA_BASE_URL', + CA_BASE_AUTH_URL = 'CA_BASE_AUTH_URL', + GRAPHQL = 'GRAPHQL', + AUTH = 'AUTH', + LOGIN_SCOPE = 'LOGIN_SCOPE', + TOKEN_EXCHANGE = 'TOKEN_EXCHANGE', + LOGOUT = 'LOGOUT', +} + +export function createCustomerAccountHelper( + customerApiVersion: string, + deprecatedCustomerAccountUrl?: string, + shopId?: string, +) { + const customerAccountUrl = shopId + ? `https://shopify.com/${shopId}` + : deprecatedCustomerAccountUrl; + + const customerAccountAuthUrl = shopId + ? `https://shopify.com/authentication/${shopId}` + : `${deprecatedCustomerAccountUrl}/auth`; + + return function getCustomerAccountUrl(urlType: URL_TYPE): string { + switch (urlType) { + case URL_TYPE.CA_BASE_URL: + // @ts-expect-error + return customerAccountUrl; + case URL_TYPE.CA_BASE_AUTH_URL: + return customerAccountAuthUrl; + case URL_TYPE.GRAPHQL: + return `${customerAccountUrl}/account/customer/api/${customerApiVersion}/graphql`; + case URL_TYPE.AUTH: + return `${customerAccountAuthUrl}/oauth/authorize`; + case URL_TYPE.LOGIN_SCOPE: + return shopId + ? 'openid email customer-account-api:full' + : 'openid email https://api.customers.com/auth/customer.graphql'; + case URL_TYPE.TOKEN_EXCHANGE: + return `${customerAccountAuthUrl}/oauth/token`; + case URL_TYPE.LOGOUT: + return `${customerAccountAuthUrl}/logout`; + } + }; +} diff --git a/packages/hydrogen/src/customer/customer.test.ts b/packages/hydrogen/src/customer/customer.test.ts index b114d3384f..716a6a9556 100644 --- a/packages/hydrogen/src/customer/customer.test.ts +++ b/packages/hydrogen/src/customer/customer.test.ts @@ -77,134 +77,146 @@ describe('customer', () => { }); describe('login & logout', () => { - it('Redirects to the customer account api login url', async () => { - const customer = createCustomerAccountClient({ - session, - customerAccountId: 'customerAccountId', - customerAccountUrl: 'https://customer-api', - request: new Request('https://localhost'), - waitUntil: vi.fn(), - }); - - const response = await customer.login(); - - expect(session.set).toHaveBeenCalledWith( - CUSTOMER_ACCOUNT_SESSION_KEY, - expect.objectContaining({ - state: expect.any(String), - nonce: expect.any(String), - codeVerifier: expect.any(String), - }), - ); + describe('using new auth url when shopId is present in env', () => { + it('Redirects to the customer account api login url', async () => { + const customer = createCustomerAccountClient({ + session, + customerAccountId: 'customerAccountId', + shopId: '1', + request: new Request('https://localhost'), + waitUntil: vi.fn(), + }); - expect(response.status).toBe(302); - const url = new URL(response.headers.get('location')!); + const response = await customer.login(); - expect(url.origin).toBe('https://customer-api'); - expect(url.pathname).toBe('/auth/oauth/authorize'); + expect(session.set).toHaveBeenCalledWith( + CUSTOMER_ACCOUNT_SESSION_KEY, + expect.objectContaining({ + state: expect.any(String), + nonce: expect.any(String), + codeVerifier: expect.any(String), + }), + ); - const params = new URLSearchParams(url.search); + expect(response.status).toBe(302); + const url = new URL(response.headers.get('location')!); - expect(params.get('client_id')).toBe('customerAccountId'); - expect(params.get('scope')).toBe( - 'openid email https://api.customers.com/auth/customer.graphql', - ); - expect(params.get('response_type')).toBe('code'); - expect(params.get('redirect_uri')).toBe( - 'https://localhost/account/authorize', - ); - expect(params.get('state')).toBeTruthy(); - expect(params.get('nonce')).toBeTruthy(); - expect(params.get('code_challenge')).toBeTruthy(); - expect(params.get('code_challenge_method')).toBe('S256'); - }); + expect(url.origin).toBe('https://shopify.com'); + expect(url.pathname).toBe('/authentication/1/oauth/authorize'); - it('Redirects to the customer account api login url with authUrl as param', async () => { - const origin = 'https://localhost'; - const authUrl = '/customer-account/auth'; + const params = new URLSearchParams(url.search); - const customer = createCustomerAccountClient({ - session, - customerAccountId: 'customerAccountId', - customerAccountUrl: 'https://customer-api', - request: new Request(origin), - waitUntil: vi.fn(), - authUrl, + expect(params.get('client_id')).toBe('customerAccountId'); + expect(params.get('scope')).toBe( + 'openid email customer-account-api:full', + ); + expect(params.get('response_type')).toBe('code'); + expect(params.get('redirect_uri')).toBe( + 'https://localhost/account/authorize', + ); + expect(params.get('state')).toBeTruthy(); + expect(params.get('nonce')).toBeTruthy(); + expect(params.get('code_challenge')).toBeTruthy(); + expect(params.get('code_challenge_method')).toBe('S256'); }); - const response = await customer.login(); - const url = new URL(response.headers.get('location')!); + it('Redirects to the customer account api login url with authUrl as param', async () => { + const origin = 'https://localhost'; + const authUrl = '/customer-account/auth'; - expect(url.origin).toBe('https://customer-api'); - expect(url.pathname).toBe('/auth/oauth/authorize'); + const customer = createCustomerAccountClient({ + session, + customerAccountId: 'customerAccountId', + shopId: '1', + request: new Request(origin), + waitUntil: vi.fn(), + authUrl, + }); - const params = new URLSearchParams(url.search); - expect(params.get('redirect_uri')).toBe( - new URL(authUrl, origin).toString(), - ); - }); + const response = await customer.login(); + const url = new URL(response.headers.get('location')!); - it('Redirects to the customer account api login url with DEFAULT_AUTH_URL as param if authUrl is cross domain', async () => { - const origin = 'https://something-good.com'; - const authUrl = 'https://something-bad.com/customer-account/auth'; + expect(url.origin).toBe('https://shopify.com'); + expect(url.pathname).toBe('/authentication/1/oauth/authorize'); - const customer = createCustomerAccountClient({ - session, - customerAccountId: 'customerAccountId', - customerAccountUrl: 'https://customer-api', - request: new Request(origin), - waitUntil: vi.fn(), - authUrl, + const params = new URLSearchParams(url.search); + expect(params.get('redirect_uri')).toBe( + new URL(authUrl, origin).toString(), + ); }); - const response = await customer.login(); - const url = new URL(response.headers.get('location')!); + it('Redirects to the customer account api login url with DEFAULT_AUTH_URL as param if authUrl is cross domain', async () => { + const origin = 'https://something-good.com'; + const authUrl = 'https://something-bad.com/customer-account/auth'; - expect(url.origin).toBe('https://customer-api'); - expect(url.pathname).toBe('/auth/oauth/authorize'); + const customer = createCustomerAccountClient({ + session, + customerAccountId: 'customerAccountId', + shopId: '1', + request: new Request(origin), + waitUntil: vi.fn(), + authUrl, + }); - const params = new URLSearchParams(url.search); - expect(params.get('redirect_uri')).toBe( - new URL('/account/authorize', origin).toString(), - ); - }); + const response = await customer.login(); + const url = new URL(response.headers.get('location')!); - describe('logout', () => { - it('Redirects to the customer account api logout url', async () => { - const origin = 'https://shop123.com'; + expect(url.origin).toBe('https://shopify.com'); + expect(url.pathname).toBe('/authentication/1/oauth/authorize'); + + const params = new URLSearchParams(url.search); + expect(params.get('redirect_uri')).toBe( + new URL('/account/authorize', origin).toString(), + ); + }); + }); + describe('using deprecated customerAccountUrl', () => { + it('Redirects to the customer account api login url', async () => { const customer = createCustomerAccountClient({ session, customerAccountId: 'customerAccountId', customerAccountUrl: 'https://customer-api', - request: new Request(origin), + request: new Request('https://localhost'), waitUntil: vi.fn(), }); - const response = await customer.logout(); + const response = await customer.login(); + + expect(session.set).toHaveBeenCalledWith( + CUSTOMER_ACCOUNT_SESSION_KEY, + expect.objectContaining({ + state: expect.any(String), + nonce: expect.any(String), + codeVerifier: expect.any(String), + }), + ); expect(response.status).toBe(302); const url = new URL(response.headers.get('location')!); expect(url.origin).toBe('https://customer-api'); - expect(url.pathname).toBe('/auth/logout'); + expect(url.pathname).toBe('/auth/oauth/authorize'); const params = new URLSearchParams(url.search); - expect(params.get('id_token_hint')).toBe('id_token'); - expect(params.get('post_logout_redirect_uri')).toBe( - new URL(origin).toString(), + expect(params.get('client_id')).toBe('customerAccountId'); + expect(params.get('scope')).toBe( + 'openid email https://api.customers.com/auth/customer.graphql', ); - - // Session is cleared - expect(session.unset).toHaveBeenCalledWith( - CUSTOMER_ACCOUNT_SESSION_KEY, + expect(params.get('response_type')).toBe('code'); + expect(params.get('redirect_uri')).toBe( + 'https://localhost/account/authorize', ); + expect(params.get('state')).toBeTruthy(); + expect(params.get('nonce')).toBeTruthy(); + expect(params.get('code_challenge')).toBeTruthy(); + expect(params.get('code_challenge_method')).toBe('S256'); }); - it('Redirects to the customer account api logout url with postLogoutRedirectUri in the param', async () => { - const origin = 'https://shop123.com'; - const postLogoutRedirectUri = '/post-logout-landing-page'; + it('Redirects to the customer account api login url with authUrl as param', async () => { + const origin = 'https://localhost'; + const authUrl = '/customer-account/auth'; const customer = createCustomerAccountClient({ session, @@ -212,408 +224,951 @@ describe('customer', () => { customerAccountUrl: 'https://customer-api', request: new Request(origin), waitUntil: vi.fn(), + authUrl, }); - const response = await customer.logout({postLogoutRedirectUri}); + const response = await customer.login(); + const url = new URL(response.headers.get('location')!); + + expect(url.origin).toBe('https://customer-api'); + expect(url.pathname).toBe('/auth/oauth/authorize'); + + const params = new URLSearchParams(url.search); + expect(params.get('redirect_uri')).toBe( + new URL(authUrl, origin).toString(), + ); + }); + + it('Redirects to the customer account api login url with DEFAULT_AUTH_URL as param if authUrl is cross domain', async () => { + const origin = 'https://something-good.com'; + const authUrl = 'https://something-bad.com/customer-account/auth'; + const customer = createCustomerAccountClient({ + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'https://customer-api', + request: new Request(origin), + waitUntil: vi.fn(), + authUrl, + }); + + const response = await customer.login(); const url = new URL(response.headers.get('location')!); + expect(url.origin).toBe('https://customer-api'); - expect(url.pathname).toBe('/auth/logout'); + expect(url.pathname).toBe('/auth/oauth/authorize'); const params = new URLSearchParams(url.search); - expect(params.get('id_token_hint')).toBe('id_token'); - expect(params.get('post_logout_redirect_uri')).toBe( - `${origin}${postLogoutRedirectUri}`, + expect(params.get('redirect_uri')).toBe( + new URL('/account/authorize', origin).toString(), ); + }); + }); + + describe('logout', () => { + describe('using new auth url when shopId is present in env', () => { + it('Redirects to the customer account api logout url', async () => { + const origin = 'https://shop123.com'; + + const customer = createCustomerAccountClient({ + session, + customerAccountId: 'customerAccountId', + shopId: '1', + request: new Request(origin), + waitUntil: vi.fn(), + }); + + const response = await customer.logout(); + + expect(response.status).toBe(302); + const url = new URL(response.headers.get('location')!); + + expect(url.origin).toBe('https://shopify.com'); + expect(url.pathname).toBe('/authentication/1/logout'); + + const params = new URLSearchParams(url.search); + + expect(params.get('id_token_hint')).toBe('id_token'); + expect(params.get('post_logout_redirect_uri')).toBe( + new URL(origin).toString(), + ); + + // Session is cleared + expect(session.unset).toHaveBeenCalledWith( + CUSTOMER_ACCOUNT_SESSION_KEY, + ); + }); - // Session is cleared - expect(session.unset).toHaveBeenCalledWith( + it('Redirects to the customer account api logout url with postLogoutRedirectUri in the param', async () => { + const origin = 'https://shop123.com'; + const postLogoutRedirectUri = '/post-logout-landing-page'; + + const customer = createCustomerAccountClient({ + session, + customerAccountId: 'customerAccountId', + shopId: '1', + request: new Request(origin), + waitUntil: vi.fn(), + }); + + const response = await customer.logout({postLogoutRedirectUri}); + + const url = new URL(response.headers.get('location')!); + expect(url.origin).toBe('https://shopify.com'); + expect(url.pathname).toBe('/authentication/1/logout'); + + const params = new URLSearchParams(url.search); + expect(params.get('id_token_hint')).toBe('id_token'); + expect(params.get('post_logout_redirect_uri')).toBe( + `${origin}${postLogoutRedirectUri}`, + ); + + // Session is cleared + expect(session.unset).toHaveBeenCalledWith( + CUSTOMER_ACCOUNT_SESSION_KEY, + ); + }); + + it('Redirects to app origin when customer is not login by default', async () => { + const origin = 'https://shop123.com'; + const mockSession: HydrogenSession = { + commit: vi.fn(() => new Promise((resolve) => resolve('cookie'))), + get: vi.fn(() => undefined) as HydrogenSession['get'], + set: vi.fn(), + unset: vi.fn(), + }; + + const customer = createCustomerAccountClient({ + session: mockSession, + customerAccountId: 'customerAccountId', + shopId: '1', + request: new Request(origin), + waitUntil: vi.fn(), + }); + + const response = await customer.logout(); + + const url = new URL(response.headers.get('location')!); + expect(url.toString()).toBe(new URL(origin).toString()); + + // Session is cleared + expect(mockSession.unset).toHaveBeenCalledWith( + CUSTOMER_ACCOUNT_SESSION_KEY, + ); + }); + + it('Redirects to postLogoutRedirectUri when customer is not login', async () => { + const origin = 'https://shop123.com'; + const postLogoutRedirectUri = '/post-logout-landing-page'; + + const mockSession: HydrogenSession = { + commit: vi.fn(() => new Promise((resolve) => resolve('cookie'))), + get: vi.fn(() => undefined) as HydrogenSession['get'], + set: vi.fn(), + unset: vi.fn(), + }; + + const customer = createCustomerAccountClient({ + session: mockSession, + customerAccountId: 'customerAccountId', + shopId: '1', + request: new Request(origin), + waitUntil: vi.fn(), + }); + + const response = await customer.logout({postLogoutRedirectUri}); + + const url = new URL(response.headers.get('location')!); + expect(url.toString()).toBe( + new URL(postLogoutRedirectUri, origin).toString(), + ); + + // Session is cleared + expect(mockSession.unset).toHaveBeenCalledWith( + CUSTOMER_ACCOUNT_SESSION_KEY, + ); + }); + + it('Redirects to app origin if postLogoutRedirectUri is cross-site when customer is not login', async () => { + const origin = 'https://shop123.com'; + const postLogoutRedirectUri = + 'https://something-bad.com/post-logout-landing-page'; + + const mockSession: HydrogenSession = { + commit: vi.fn(() => new Promise((resolve) => resolve('cookie'))), + get: vi.fn(() => undefined) as HydrogenSession['get'], + set: vi.fn(), + unset: vi.fn(), + }; + + const customer = createCustomerAccountClient({ + session: mockSession, + customerAccountId: 'customerAccountId', + shopId: '1', + request: new Request(origin), + waitUntil: vi.fn(), + }); + + const response = await customer.logout({postLogoutRedirectUri}); + + const url = new URL(response.headers.get('location')!); + expect(url.toString()).toBe(new URL(origin).toString()); + + // Session is cleared + expect(mockSession.unset).toHaveBeenCalledWith( + CUSTOMER_ACCOUNT_SESSION_KEY, + ); + }); + }); + + it('Saved redirectPath to session by default if `return_to` param was found', async () => { + const redirectPath = '/account/orders'; + const request = new Request( + `https://localhost?${new URLSearchParams({ + return_to: redirectPath, + }).toString()}`, + ); + + const customer = createCustomerAccountClient({ + session, + customerAccountId: 'customerAccountId', + shopId: '1', + request, + waitUntil: vi.fn(), + }); + + await customer.login(); + + expect(session.set).toHaveBeenCalledWith( CUSTOMER_ACCOUNT_SESSION_KEY, + expect.objectContaining({ + redirectPath, + }), ); }); - it('Redirects to app origin when customer is not login by default', async () => { - const origin = 'https://shop123.com'; - const mockSession: HydrogenSession = { - commit: vi.fn(() => new Promise((resolve) => resolve('cookie'))), - get: vi.fn(() => undefined) as HydrogenSession['get'], - set: vi.fn(), - unset: vi.fn(), - }; + it('Saved redirectPath to session by default if `redirect` param was found', async () => { + const redirectPath = '/account/orders'; + const request = new Request( + `https://localhost?${new URLSearchParams({ + redirect: redirectPath, + }).toString()}`, + ); const customer = createCustomerAccountClient({ - session: mockSession, + session, customerAccountId: 'customerAccountId', - customerAccountUrl: 'https://customer-api', - request: new Request(origin), + shopId: '1', + request, waitUntil: vi.fn(), }); - const response = await customer.logout(); + await customer.login(); - const url = new URL(response.headers.get('location')!); - expect(url.toString()).toBe(new URL(origin).toString()); + expect(session.set).toHaveBeenCalledWith( + CUSTOMER_ACCOUNT_SESSION_KEY, + expect.objectContaining({ + redirectPath, + }), + ); + }); + + it('Saved redirectPath to session by default if request referer was found', async () => { + const redirectPath = '/account/orders'; + const request = new Request('https://localhost'); + request.headers.set('Referer', redirectPath); + + const customer = createCustomerAccountClient({ + session, + customerAccountId: 'customerAccountId', + shopId: '1', + request, + waitUntil: vi.fn(), + }); + + await customer.login(); - // Session is cleared - expect(mockSession.unset).toHaveBeenCalledWith( + expect(session.set).toHaveBeenCalledWith( CUSTOMER_ACCOUNT_SESSION_KEY, + expect.objectContaining({ + redirectPath, + }), ); }); - it('Redirects to postLogoutRedirectUri when customer is not login', async () => { - const origin = 'https://shop123.com'; - const postLogoutRedirectUri = '/post-logout-landing-page'; + it('Saved redirectPath to session by default', async () => { + const customer = createCustomerAccountClient({ + session, + customerAccountId: 'customerAccountId', + shopId: '1', + request: new Request('https://localhost'), + waitUntil: vi.fn(), + }); - const mockSession: HydrogenSession = { - commit: vi.fn(() => new Promise((resolve) => resolve('cookie'))), - get: vi.fn(() => undefined) as HydrogenSession['get'], - set: vi.fn(), - unset: vi.fn(), - }; + await customer.login(); + + expect(session.set).toHaveBeenCalledWith( + CUSTOMER_ACCOUNT_SESSION_KEY, + expect.objectContaining({ + redirectPath: '/account', + }), + ); + }); + + describe('using deprecated customerAccountUrl', () => { + it('Redirects to the customer account api logout url', async () => { + const origin = 'https://shop123.com'; + + const customer = createCustomerAccountClient({ + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'https://customer-api', + request: new Request(origin), + waitUntil: vi.fn(), + }); + + const response = await customer.logout(); + + expect(response.status).toBe(302); + const url = new URL(response.headers.get('location')!); + + expect(url.origin).toBe('https://customer-api'); + expect(url.pathname).toBe('/auth/logout'); + + const params = new URLSearchParams(url.search); + + expect(params.get('id_token_hint')).toBe('id_token'); + expect(params.get('post_logout_redirect_uri')).toBe( + new URL(origin).toString(), + ); + + // Session is cleared + expect(session.unset).toHaveBeenCalledWith( + CUSTOMER_ACCOUNT_SESSION_KEY, + ); + }); + + it('Redirects to the customer account api logout url with postLogoutRedirectUri in the param', async () => { + const origin = 'https://shop123.com'; + const postLogoutRedirectUri = '/post-logout-landing-page'; + + const customer = createCustomerAccountClient({ + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'https://customer-api', + request: new Request(origin), + waitUntil: vi.fn(), + }); + + const response = await customer.logout({postLogoutRedirectUri}); + + const url = new URL(response.headers.get('location')!); + expect(url.origin).toBe('https://customer-api'); + expect(url.pathname).toBe('/auth/logout'); + + const params = new URLSearchParams(url.search); + expect(params.get('id_token_hint')).toBe('id_token'); + expect(params.get('post_logout_redirect_uri')).toBe( + `${origin}${postLogoutRedirectUri}`, + ); + + // Session is cleared + expect(session.unset).toHaveBeenCalledWith( + CUSTOMER_ACCOUNT_SESSION_KEY, + ); + }); + + it('Redirects to app origin when customer is not login by default', async () => { + const origin = 'https://shop123.com'; + const mockSession: HydrogenSession = { + commit: vi.fn(() => new Promise((resolve) => resolve('cookie'))), + get: vi.fn(() => undefined) as HydrogenSession['get'], + set: vi.fn(), + unset: vi.fn(), + }; + + const customer = createCustomerAccountClient({ + session: mockSession, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'https://customer-api', + request: new Request(origin), + waitUntil: vi.fn(), + }); + + const response = await customer.logout(); + + const url = new URL(response.headers.get('location')!); + expect(url.toString()).toBe(new URL(origin).toString()); + + // Session is cleared + expect(mockSession.unset).toHaveBeenCalledWith( + CUSTOMER_ACCOUNT_SESSION_KEY, + ); + }); + + it('Redirects to postLogoutRedirectUri when customer is not login', async () => { + const origin = 'https://shop123.com'; + const postLogoutRedirectUri = '/post-logout-landing-page'; + + const mockSession: HydrogenSession = { + commit: vi.fn(() => new Promise((resolve) => resolve('cookie'))), + get: vi.fn(() => undefined) as HydrogenSession['get'], + set: vi.fn(), + unset: vi.fn(), + }; + + const customer = createCustomerAccountClient({ + session: mockSession, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'https://customer-api', + request: new Request(origin), + waitUntil: vi.fn(), + }); + + const response = await customer.logout({postLogoutRedirectUri}); + + const url = new URL(response.headers.get('location')!); + expect(url.toString()).toBe( + new URL(postLogoutRedirectUri, origin).toString(), + ); + + // Session is cleared + expect(mockSession.unset).toHaveBeenCalledWith( + CUSTOMER_ACCOUNT_SESSION_KEY, + ); + }); + + it('Redirects to app origin if postLogoutRedirectUri is cross-site when customer is not login', async () => { + const origin = 'https://shop123.com'; + const postLogoutRedirectUri = + 'https://something-bad.com/post-logout-landing-page'; + + const mockSession: HydrogenSession = { + commit: vi.fn(() => new Promise((resolve) => resolve('cookie'))), + get: vi.fn(() => undefined) as HydrogenSession['get'], + set: vi.fn(), + unset: vi.fn(), + }; + + const customer = createCustomerAccountClient({ + session: mockSession, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'https://customer-api', + request: new Request(origin), + waitUntil: vi.fn(), + }); + + const response = await customer.logout({postLogoutRedirectUri}); + + const url = new URL(response.headers.get('location')!); + expect(url.toString()).toBe(new URL(origin).toString()); + + // Session is cleared + expect(mockSession.unset).toHaveBeenCalledWith( + CUSTOMER_ACCOUNT_SESSION_KEY, + ); + }); + }); + + it('Saved redirectPath to session by default if `return_to` param was found', async () => { + const redirectPath = '/account/orders'; + const request = new Request( + `https://localhost?${new URLSearchParams({ + return_to: redirectPath, + }).toString()}`, + ); const customer = createCustomerAccountClient({ - session: mockSession, + session, customerAccountId: 'customerAccountId', customerAccountUrl: 'https://customer-api', - request: new Request(origin), + request, waitUntil: vi.fn(), }); - const response = await customer.logout({postLogoutRedirectUri}); + await customer.login(); - const url = new URL(response.headers.get('location')!); - expect(url.toString()).toBe( - new URL(postLogoutRedirectUri, origin).toString(), + expect(session.set).toHaveBeenCalledWith( + CUSTOMER_ACCOUNT_SESSION_KEY, + expect.objectContaining({ + redirectPath, + }), + ); + }); + + it('Saved redirectPath to session by default if `redirect` param was found', async () => { + const redirectPath = '/account/orders'; + const request = new Request( + `https://localhost?${new URLSearchParams({ + redirect: redirectPath, + }).toString()}`, ); - // Session is cleared - expect(mockSession.unset).toHaveBeenCalledWith( + const customer = createCustomerAccountClient({ + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'https://customer-api', + request, + waitUntil: vi.fn(), + }); + + await customer.login(); + + expect(session.set).toHaveBeenCalledWith( CUSTOMER_ACCOUNT_SESSION_KEY, + expect.objectContaining({ + redirectPath, + }), ); }); - it('Redirects to app origin if postLogoutRedirectUri is cross-site when customer is not login', async () => { - const origin = 'https://shop123.com'; - const postLogoutRedirectUri = - 'https://something-bad.com/post-logout-landing-page'; - - const mockSession: HydrogenSession = { - commit: vi.fn(() => new Promise((resolve) => resolve('cookie'))), - get: vi.fn(() => undefined) as HydrogenSession['get'], - set: vi.fn(), - unset: vi.fn(), - }; + it('Saved redirectPath to session by default if request referer was found', async () => { + const redirectPath = '/account/orders'; + const request = new Request('https://localhost'); + request.headers.set('Referer', redirectPath); const customer = createCustomerAccountClient({ - session: mockSession, + session, customerAccountId: 'customerAccountId', customerAccountUrl: 'https://customer-api', - request: new Request(origin), + request, waitUntil: vi.fn(), }); - const response = await customer.logout({postLogoutRedirectUri}); + await customer.login(); - const url = new URL(response.headers.get('location')!); - expect(url.toString()).toBe(new URL(origin).toString()); + expect(session.set).toHaveBeenCalledWith( + CUSTOMER_ACCOUNT_SESSION_KEY, + expect.objectContaining({ + redirectPath, + }), + ); + }); + + it('Saved redirectPath to session by default', async () => { + const customer = createCustomerAccountClient({ + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'https://customer-api', + request: new Request('https://localhost'), + waitUntil: vi.fn(), + }); - // Session is cleared - expect(mockSession.unset).toHaveBeenCalledWith( + await customer.login(); + + expect(session.set).toHaveBeenCalledWith( CUSTOMER_ACCOUNT_SESSION_KEY, + expect.objectContaining({ + redirectPath: '/account', + }), ); }); }); + }); - it('Saved redirectPath to session by default if `return_to` param was found', async () => { - const redirectPath = '/account/orders'; - const request = new Request( - `https://localhost?${new URLSearchParams({ - return_to: redirectPath, - }).toString()}`, - ); + describe('authorize', () => { + describe('using new auth url when shopId is present in env', () => { + it('Throws unauthorized if no code or state params are passed', async () => { + const customer = createCustomerAccountClient({ + session, + customerAccountId: 'customerAccountId', + shopId: '1', + request: new Request('https://localhost'), + waitUntil: vi.fn(), + }); - const customer = createCustomerAccountClient({ - session, - customerAccountId: 'customerAccountId', - customerAccountUrl: 'https://customer-api', - request, - waitUntil: vi.fn(), + await expect(customer.authorize()).rejects.toThrowError( + 'Unauthorized No code or state parameter found in the redirect URL.', + ); }); - await customer.login(); + it("Throws unauthorized if state doesn't match session value", async () => { + const customer = createCustomerAccountClient({ + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'https://customer-api', + request: new Request('https://localhost?state=nomatch&code=code'), + waitUntil: vi.fn(), + }); - expect(session.set).toHaveBeenCalledWith( - CUSTOMER_ACCOUNT_SESSION_KEY, - expect.objectContaining({ - redirectPath, - }), - ); - }); + await expect(customer.authorize()).rejects.toThrowError( + 'Unauthorized The session state does not match the state parameter. Make sure that the session is configured correctly and passed to `createCustomerAccountClient`.', + ); + }); - it('Saved redirectPath to session by default if `redirect` param was found', async () => { - const redirectPath = '/account/orders'; - const request = new Request( - `https://localhost?${new URLSearchParams({ - redirect: redirectPath, - }).toString()}`, - ); + it('Throws if requesting the token fails', async () => { + const customer = createCustomerAccountClient({ + session, + customerAccountId: 'customerAccountId', + shopId: '1', + request: new Request('https://localhost?state=state&code=code'), + waitUntil: vi.fn(), + }); - const customer = createCustomerAccountClient({ - session, - customerAccountId: 'customerAccountId', - customerAccountUrl: 'https://customer-api', - request, - waitUntil: vi.fn(), - }); + fetch.mockResolvedValue(createFetchResponse('some text', {ok: false})); - await customer.login(); + await expect(customer.authorize()).rejects.toThrowError('some text'); + }); - expect(session.set).toHaveBeenCalledWith( - CUSTOMER_ACCOUNT_SESSION_KEY, - expect.objectContaining({ - redirectPath, - }), - ); - }); + it("Throws if the encoded nonce doesn't match the value in the session", async () => { + const customer = createCustomerAccountClient({ + session, + customerAccountId: 'customerAccountId', + shopId: '1', + request: new Request('https://localhost?state=state&code=code'), + waitUntil: vi.fn(), + }); - it('Saved redirectPath to session by default if request referer was found', async () => { - const redirectPath = '/account/orders'; - const request = new Request('https://localhost'); - request.headers.set('Referer', redirectPath); + fetch.mockResolvedValue( + createFetchResponse( + { + access_token: 'shcat_access_token', + expires_in: '', + id_token: `${btoa('{}')}.${btoa( + '{"nonce": "nomatch"}', + )}.signature`, + refresh_token: 'shcrt_refresh_token', + }, + {ok: true}, + ), + ); - const customer = createCustomerAccountClient({ - session, - customerAccountId: 'customerAccountId', - customerAccountUrl: 'https://customer-api', - request, - waitUntil: vi.fn(), + await expect(customer.authorize()).rejects.toThrowError( + 'Unauthorized Returned nonce does not match: nonce !== nomatch', + ); }); - await customer.login(); + it('Redirects on successful authorization and updates session', async () => { + const customer = createCustomerAccountClient({ + session, + customerAccountId: 'customerAccountId', + shopId: '1', + request: new Request('https://localhost?state=state&code=code'), + waitUntil: vi.fn(), + }); - expect(session.set).toHaveBeenCalledWith( - CUSTOMER_ACCOUNT_SESSION_KEY, - expect.objectContaining({ - redirectPath, - }), - ); - }); + fetch.mockResolvedValue( + createFetchResponse( + { + access_token: 'shcat_access_token', + expires_in: '', + id_token: `${btoa('{}')}.${btoa('{"nonce": "nonce"}')}.signature`, + refresh_token: 'shcrt_refresh_token', + }, + {ok: true}, + ), + ); - it('Saved redirectPath to session by default', async () => { - const customer = createCustomerAccountClient({ - session, - customerAccountId: 'customerAccountId', - customerAccountUrl: 'https://customer-api', - request: new Request('https://localhost'), - waitUntil: vi.fn(), + const response = await customer.authorize(); + + expect(response.status).toBe(302); + expect(response.headers.get('location')).toBe('/account'); + + expect(session.set).toHaveBeenCalledWith( + CUSTOMER_ACCOUNT_SESSION_KEY, + expect.objectContaining({ + accessToken: 'shcat_access_token', + expiresAt: expect.any(String), + idToken: 'e30=.eyJub25jZSI6ICJub25jZSJ9.signature', + refreshToken: 'shcrt_refresh_token', + }), + ); }); - await customer.login(); + it('Redirects to redirectPath on successful authorization and updates session', async () => { + const redirectPath = '/account/orders'; + session = { + commit: vi.fn(() => new Promise((resolve) => resolve('cookie'))), + get: vi.fn(() => { + return {...mockCustomerAccountSession, redirectPath}; + }) as HydrogenSession['get'], + set: vi.fn(), + unset: vi.fn(), + }; - expect(session.set).toHaveBeenCalledWith( - CUSTOMER_ACCOUNT_SESSION_KEY, - expect.objectContaining({ - redirectPath: '/account', - }), - ); - }); - }); + const customer = createCustomerAccountClient({ + session, + customerAccountId: 'customerAccountId', + shopId: '1', + request: new Request('https://localhost?state=state&code=code'), + waitUntil: vi.fn(), + }); - describe('authorize', () => { - it('Throws unauthorized if no code or state params are passed', async () => { - const customer = createCustomerAccountClient({ - session, - customerAccountId: 'customerAccountId', - customerAccountUrl: 'https://customer-api', - request: new Request('https://localhost'), - waitUntil: vi.fn(), + fetch.mockResolvedValue( + createFetchResponse( + { + access_token: 'shcat_access_token', + expires_in: '', + id_token: `${btoa('{}')}.${btoa('{"nonce": "nonce"}')}.signature`, + refresh_token: 'shcrt_refresh_token', + }, + {ok: true}, + ), + ); + + const response = await customer.authorize(); + + expect(response.status).toBe(302); + expect(response.headers.get('location')).toBe(redirectPath); + + expect(session.set).toHaveBeenCalledWith( + CUSTOMER_ACCOUNT_SESSION_KEY, + expect.objectContaining({ + accessToken: 'shcat_access_token', + expiresAt: expect.any(String), + idToken: 'e30=.eyJub25jZSI6ICJub25jZSJ9.signature', + refreshToken: 'shcrt_refresh_token', + }), + ); }); - await expect(customer.authorize()).rejects.toThrowError( - 'Unauthorized No code or state parameter found in the redirect URL.', - ); + it('exchanges for a storefront customer access token for b2b', async () => { + const redirectPath = '/account/orders'; + session = { + commit: vi.fn(() => new Promise((resolve) => resolve('cookie'))), + get: vi.fn(() => { + return {...mockCustomerAccountSession, redirectPath}; + }) as HydrogenSession['get'], + set: vi.fn(), + unset: vi.fn(), + }; + + const customer = createCustomerAccountClient({ + session, + customerAccountId: 'customerAccountId', + shopId: '1', + request: new Request('https://localhost?state=state&code=code'), + unstableB2b: true, + waitUntil: vi.fn(), + }); + + fetch.mockResolvedValue( + createFetchResponse( + { + access_token: 'shcat_access_token', + expires_in: '', + id_token: `${btoa('{}')}.${btoa('{"nonce": "nonce"}')}.signature`, + refresh_token: 'shcrt_refresh_token', + }, + {ok: true}, + ), + ); + + const response = await customer.authorize(); + + expect(response.status).toBe(302); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('https://shopify.com/1/account/customer/api'), + expect.objectContaining({ + body: expect.stringContaining( + 'storefrontCustomerAccessTokenCreate', + ), + }), + ); + }); }); - it("Throws unauthorized if state doesn't match session value", async () => { - const customer = createCustomerAccountClient({ - session, - customerAccountId: 'customerAccountId', - customerAccountUrl: 'https://customer-api', - request: new Request('https://localhost?state=nomatch&code=code'), - waitUntil: vi.fn(), + describe('using deprecated customerAccountUrl', () => { + it('Throws unauthorized if no code or state params are passed', async () => { + const customer = createCustomerAccountClient({ + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'https://customer-api', + request: new Request('https://localhost'), + waitUntil: vi.fn(), + }); + + await expect(customer.authorize()).rejects.toThrowError( + 'Unauthorized No code or state parameter found in the redirect URL.', + ); }); - await expect(customer.authorize()).rejects.toThrowError( - 'Unauthorized The session state does not match the state parameter. Make sure that the session is configured correctly and passed to `createCustomerAccountClient`.', - ); - }); + it("Throws unauthorized if state doesn't match session value", async () => { + const customer = createCustomerAccountClient({ + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'https://customer-api', + request: new Request('https://localhost?state=nomatch&code=code'), + waitUntil: vi.fn(), + }); - it('Throws if requesting the token fails', async () => { - const customer = createCustomerAccountClient({ - session, - customerAccountId: 'customerAccountId', - customerAccountUrl: 'https://customer-api', - request: new Request('https://localhost?state=state&code=code'), - waitUntil: vi.fn(), + await expect(customer.authorize()).rejects.toThrowError( + 'Unauthorized The session state does not match the state parameter. Make sure that the session is configured correctly and passed to `createCustomerAccountClient`.', + ); }); - fetch.mockResolvedValue(createFetchResponse('some text', {ok: false})); + it('Throws if requesting the token fails', async () => { + const customer = createCustomerAccountClient({ + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'https://customer-api', + request: new Request('https://localhost?state=state&code=code'), + waitUntil: vi.fn(), + }); - await expect(customer.authorize()).rejects.toThrowError('some text'); - }); + fetch.mockResolvedValue(createFetchResponse('some text', {ok: false})); - it("Throws if the encoded nonce doesn't match the value in the session", async () => { - const customer = createCustomerAccountClient({ - session, - customerAccountId: 'customerAccountId', - customerAccountUrl: 'https://customer-api', - request: new Request('https://localhost?state=state&code=code'), - waitUntil: vi.fn(), + await expect(customer.authorize()).rejects.toThrowError('some text'); }); - fetch.mockResolvedValue( - createFetchResponse( - { - access_token: 'access_token', - expires_in: '', - id_token: `${btoa('{}')}.${btoa('{"nonce": "nomatch"}')}.signature`, - refresh_token: 'refresh_token', - }, - {ok: true}, - ), - ); + it("Throws if the encoded nonce doesn't match the value in the session", async () => { + const customer = createCustomerAccountClient({ + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'https://customer-api', + request: new Request('https://localhost?state=state&code=code'), + waitUntil: vi.fn(), + }); - await expect(customer.authorize()).rejects.toThrowError( - 'Unauthorized Returned nonce does not match: nonce !== nomatch', - ); - }); + fetch.mockResolvedValue( + createFetchResponse( + { + access_token: 'access_token', + expires_in: '', + id_token: `${btoa('{}')}.${btoa( + '{"nonce": "nomatch"}', + )}.signature`, + refresh_token: 'refresh_token', + }, + {ok: true}, + ), + ); - it('Redirects on successful authorization and updates session', async () => { - const customer = createCustomerAccountClient({ - session, - customerAccountId: 'customerAccountId', - customerAccountUrl: 'https://customer-api', - request: new Request('https://localhost?state=state&code=code'), - waitUntil: vi.fn(), + await expect(customer.authorize()).rejects.toThrowError( + 'Unauthorized Returned nonce does not match: nonce !== nomatch', + ); }); - fetch.mockResolvedValue( - createFetchResponse( - { - access_token: 'access_token', - expires_in: '', - id_token: `${btoa('{}')}.${btoa('{"nonce": "nonce"}')}.signature`, - refresh_token: 'refresh_token', - }, - {ok: true}, - ), - ); - - const response = await customer.authorize(); + it('Redirects on successful authorization and updates session', async () => { + const customer = createCustomerAccountClient({ + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'https://customer-api', + request: new Request('https://localhost?state=state&code=code'), + waitUntil: vi.fn(), + }); - expect(response.status).toBe(302); - expect(response.headers.get('location')).toBe('/account'); + fetch.mockResolvedValue( + createFetchResponse( + { + access_token: 'access_token', + expires_in: '', + id_token: `${btoa('{}')}.${btoa('{"nonce": "nonce"}')}.signature`, + refresh_token: 'refresh_token', + }, + {ok: true}, + ), + ); - expect(session.set).toHaveBeenCalledWith( - CUSTOMER_ACCOUNT_SESSION_KEY, - expect.objectContaining({ - accessToken: 'access_token', - expiresAt: expect.any(String), - idToken: 'e30=.eyJub25jZSI6ICJub25jZSJ9.signature', - refreshToken: 'refresh_token', - }), - ); - }); + const response = await customer.authorize(); - it('Redirects to redirectPath on successful authorization and updates session', async () => { - const redirectPath = '/account/orders'; - session = { - commit: vi.fn(() => new Promise((resolve) => resolve('cookie'))), - get: vi.fn(() => { - return {...mockCustomerAccountSession, redirectPath}; - }) as HydrogenSession['get'], - set: vi.fn(), - unset: vi.fn(), - }; + expect(response.status).toBe(302); + expect(response.headers.get('location')).toBe('/account'); - const customer = createCustomerAccountClient({ - session, - customerAccountId: 'customerAccountId', - customerAccountUrl: 'https://customer-api', - request: new Request('https://localhost?state=state&code=code'), - waitUntil: vi.fn(), + expect(session.set).toHaveBeenCalledWith( + CUSTOMER_ACCOUNT_SESSION_KEY, + expect.objectContaining({ + accessToken: 'access_token', + expiresAt: expect.any(String), + idToken: 'e30=.eyJub25jZSI6ICJub25jZSJ9.signature', + refreshToken: 'refresh_token', + }), + ); }); - fetch.mockResolvedValue( - createFetchResponse( - { - access_token: 'access_token', - expires_in: '', - id_token: `${btoa('{}')}.${btoa('{"nonce": "nonce"}')}.signature`, - refresh_token: 'refresh_token', - }, - {ok: true}, - ), - ); + it('Redirects to redirectPath on successful authorization and updates session', async () => { + const redirectPath = '/account/orders'; + session = { + commit: vi.fn(() => new Promise((resolve) => resolve('cookie'))), + get: vi.fn(() => { + return {...mockCustomerAccountSession, redirectPath}; + }) as HydrogenSession['get'], + set: vi.fn(), + unset: vi.fn(), + }; - const response = await customer.authorize(); + const customer = createCustomerAccountClient({ + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'https://customer-api', + request: new Request('https://localhost?state=state&code=code'), + waitUntil: vi.fn(), + }); - expect(response.status).toBe(302); - expect(response.headers.get('location')).toBe(redirectPath); + fetch.mockResolvedValue( + createFetchResponse( + { + access_token: 'access_token', + expires_in: '', + id_token: `${btoa('{}')}.${btoa('{"nonce": "nonce"}')}.signature`, + refresh_token: 'refresh_token', + }, + {ok: true}, + ), + ); - expect(session.set).toHaveBeenCalledWith( - CUSTOMER_ACCOUNT_SESSION_KEY, - expect.objectContaining({ - accessToken: 'access_token', - expiresAt: expect.any(String), - idToken: 'e30=.eyJub25jZSI6ICJub25jZSJ9.signature', - refreshToken: 'refresh_token', - }), - ); - }); + const response = await customer.authorize(); - it('exchanges for a storefront customer access token for b2b', async () => { - const redirectPath = '/account/orders'; - session = { - commit: vi.fn(() => new Promise((resolve) => resolve('cookie'))), - get: vi.fn(() => { - return {...mockCustomerAccountSession, redirectPath}; - }) as HydrogenSession['get'], - set: vi.fn(), - unset: vi.fn(), - }; + expect(response.status).toBe(302); + expect(response.headers.get('location')).toBe(redirectPath); - const customer = createCustomerAccountClient({ - session, - customerAccountId: 'customerAccountId', - customerAccountUrl: 'https://customer-api', - request: new Request('https://localhost?state=state&code=code'), - unstableB2b: true, - waitUntil: vi.fn(), + expect(session.set).toHaveBeenCalledWith( + CUSTOMER_ACCOUNT_SESSION_KEY, + expect.objectContaining({ + accessToken: 'access_token', + expiresAt: expect.any(String), + idToken: 'e30=.eyJub25jZSI6ICJub25jZSJ9.signature', + refreshToken: 'refresh_token', + }), + ); }); - fetch.mockResolvedValue( - createFetchResponse( - { - access_token: 'access_token', - expires_in: '', - id_token: `${btoa('{}')}.${btoa('{"nonce": "nonce"}')}.signature`, - refresh_token: 'refresh_token', - }, - {ok: true}, - ), - ); + it('exchanges for a storefront customer access token for b2b', async () => { + const redirectPath = '/account/orders'; + session = { + commit: vi.fn(() => new Promise((resolve) => resolve('cookie'))), + get: vi.fn(() => { + return {...mockCustomerAccountSession, redirectPath}; + }) as HydrogenSession['get'], + set: vi.fn(), + unset: vi.fn(), + }; - const response = await customer.authorize(); + const customer = createCustomerAccountClient({ + session, + customerAccountId: 'customerAccountId', + customerAccountUrl: 'https://customer-api', + request: new Request('https://localhost?state=state&code=code'), + unstableB2b: true, + waitUntil: vi.fn(), + }); - expect(response.status).toBe(302); + fetch.mockResolvedValue( + createFetchResponse( + { + access_token: 'access_token', + expires_in: '', + id_token: `${btoa('{}')}.${btoa('{"nonce": "nonce"}')}.signature`, + refresh_token: 'refresh_token', + }, + {ok: true}, + ), + ); - expect(fetch).toHaveBeenCalledWith( - expect.stringContaining('https://customer-api/account/customer/api'), - expect.objectContaining({ - body: expect.stringContaining('storefrontCustomerAccessTokenCreate'), - }), - ); + const response = await customer.authorize(); + + expect(response.status).toBe(302); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('https://customer-api/account/customer/api'), + expect.objectContaining({ + body: expect.stringContaining( + 'storefrontCustomerAccessTokenCreate', + ), + }), + ); + }); }); }); diff --git a/packages/hydrogen/src/customer/customer.ts b/packages/hydrogen/src/customer/customer.ts index 946c593edc..2a4aa649c1 100644 --- a/packages/hydrogen/src/customer/customer.ts +++ b/packages/hydrogen/src/customer/customer.ts @@ -48,6 +48,7 @@ import type { LogoutOptions, Buyer, } from './types'; +import {createCustomerAccountHelper, URL_TYPE} from './customer-account-helper'; const DEFAULT_LOGIN_URL = '/account/login'; const DEFAULT_AUTH_URL = '/account/authorize'; @@ -68,7 +69,8 @@ function defaultAuthStatusHandler(request: CrossRuntimeRequest) { export function createCustomerAccountClient({ session, customerAccountId, - customerAccountUrl, + customerAccountUrl: deprecatedCustomerAccountUrl, + shopId, customerApiVersion = DEFAULT_CUSTOMER_API_VERSION, request, waitUntil, @@ -108,7 +110,19 @@ export function createCustomerAccountClient({ defaultUrl: DEFAULT_AUTH_URL, redirectUrl: authUrl, }); - const customerAccountApiUrl = `${customerAccountUrl}/account/customer/api/${customerApiVersion}/graphql`; + + const getCustomerAccountUrl = createCustomerAccountHelper( + customerApiVersion, + deprecatedCustomerAccountUrl, + shopId, + ); + + const ifInvalidCredentialThrowError = createIfInvalidCredentialThrowError( + getCustomerAccountUrl, + customerAccountId, + ); + + const customerAccountApiUrl = getCustomerAccountUrl(URL_TYPE.GRAPHQL); const locks: Locks = {}; async function fetchCustomerAPI({ @@ -211,7 +225,8 @@ export function createCustomerAccountClient({ } async function isLoggedIn() { - if (!customerAccountUrl || !customerAccountId) return false; + if (!shopId && (!deprecatedCustomerAccountUrl || !customerAccountId)) + return false; const customerAccount = session.get(CUSTOMER_ACCOUNT_SESSION_KEY); const accessToken = customerAccount?.accessToken; @@ -228,7 +243,9 @@ export function createCustomerAccountClient({ expiresAt, session, customerAccountId, - customerAccountUrl, + customerAccountTokenExchangeUrl: getCustomerAccountUrl( + URL_TYPE.TOKEN_EXCHANGE, + ), httpsOrigin, debugInfo: { waitUntil, @@ -261,7 +278,7 @@ export function createCustomerAccountClient({ mutation: Parameters[0], options?: Parameters[1], ) { - ifInvalidCredentialThrowError(customerAccountUrl, customerAccountId); + ifInvalidCredentialThrowError(); mutation = minifyQuery(mutation); assertMutation(mutation, 'customer.mutate'); @@ -276,7 +293,7 @@ export function createCustomerAccountClient({ query: Parameters[0], options?: Parameters[1], ) { - ifInvalidCredentialThrowError(customerAccountUrl, customerAccountId); + ifInvalidCredentialThrowError(); query = minifyQuery(query); assertQuery(query, 'customer.query'); @@ -338,8 +355,8 @@ export function createCustomerAccountClient({ return { login: async (options?: LoginOptions) => { - ifInvalidCredentialThrowError(customerAccountUrl, customerAccountId); - const loginUrl = new URL(`${customerAccountUrl}/auth/oauth/authorize`); + ifInvalidCredentialThrowError(); + const loginUrl = new URL(getCustomerAccountUrl(URL_TYPE.AUTH)); const state = generateState(); const nonce = generateNonce(); @@ -350,7 +367,7 @@ export function createCustomerAccountClient({ loginUrl.searchParams.append('redirect_uri', redirectUri); loginUrl.searchParams.set( 'scope', - 'openid email https://api.customers.com/auth/customer.graphql', + getCustomerAccountUrl(URL_TYPE.LOGIN_SCOPE), ); loginUrl.searchParams.append('state', state); loginUrl.searchParams.append('nonce', nonce); @@ -385,7 +402,7 @@ export function createCustomerAccountClient({ }, logout: async (options?: LogoutOptions) => { - ifInvalidCredentialThrowError(customerAccountUrl, customerAccountId); + ifInvalidCredentialThrowError(); const idToken = session.get(CUSTOMER_ACCOUNT_SESSION_KEY)?.idToken; const postLogoutRedirectUri = ensureLocalRedirectUrl({ @@ -396,7 +413,7 @@ export function createCustomerAccountClient({ const logoutUrl = idToken ? new URL( - `${customerAccountUrl}/auth/logout?${new URLSearchParams([ + `${getCustomerAccountUrl(URL_TYPE.LOGOUT)}?${new URLSearchParams([ ['id_token_hint', idToken], ['post_logout_redirect_uri', postLogoutRedirectUri], ]).toString()}`, @@ -414,7 +431,7 @@ export function createCustomerAccountClient({ mutate: mutate as CustomerAccount['mutate'], query: query as CustomerAccount['query'], authorize: async () => { - ifInvalidCredentialThrowError(customerAccountUrl, customerAccountId); + ifInvalidCredentialThrowError(); const code = requestUrl.searchParams.get('code'); const state = requestUrl.searchParams.get('state'); @@ -466,7 +483,7 @@ export function createCustomerAccountClient({ const stackInfo = getCallerStackLine?.(); const startTime = new Date().getTime(); - const url = `${customerAccountUrl}/auth/oauth/token`; + const url = getCustomerAccountUrl(URL_TYPE.TOKEN_EXCHANGE); const response = await fetch(url, { method: 'POST', headers, @@ -509,17 +526,21 @@ export function createCustomerAccountClient({ ); } - const customerAccessToken = await exchangeAccessToken( - access_token, - customerAccountId, - customerAccountUrl, - httpsOrigin, - { - waitUntil, - stackInfo, - ...getDebugHeaders(request), - }, - ); + let customerAccessToken = access_token; + + if (!shopId) { + customerAccessToken = await exchangeAccessToken( + access_token, + customerAccountId, + getCustomerAccountUrl(URL_TYPE.TOKEN_EXCHANGE), + httpsOrigin, + { + waitUntil, + stackInfo, + ...getDebugHeaders(request), + }, + ); + } const redirectPath = session.get( CUSTOMER_ACCOUNT_SESSION_KEY, @@ -543,25 +564,29 @@ export function createCustomerAccountClient({ }; } -function ifInvalidCredentialThrowError( - customerAccountUrl?: string, +function createIfInvalidCredentialThrowError( + getCustomerAccountUrl: (urlType: URL_TYPE) => string, customerAccountId?: string, ) { - try { - if (!customerAccountUrl || !customerAccountId) throw Error(); - new URL(customerAccountUrl); - } catch { - console.error( - new Error( - '[h2:error:customerAccount] You do not have the valid credential to use Customer Account API.\nRun `h2 env pull` to link your store credentials.', - ), - ); + return function ifInvalidCredentialThrowError() { + try { + if (!customerAccountId) throw Error(); - const publicMessage = - process.env.NODE_ENV === 'production' - ? 'Internal Server Error' - : 'You do not have the valid credential to use Customer Account API (/account).'; + new URL(getCustomerAccountUrl(URL_TYPE.CA_BASE_URL)); + new URL(getCustomerAccountUrl(URL_TYPE.CA_BASE_AUTH_URL)); + } catch { + console.error( + new Error( + '[h2:error:customerAccount] You do not have the valid credential to use Customer Account API.\nRun `h2 env pull` to link your store credentials.', + ), + ); - throw new Response(publicMessage, {status: 500}); - } + const publicMessage = + process.env.NODE_ENV === 'production' + ? 'Internal Server Error' + : 'You do not have the valid credential to use Customer Account API (/account).'; + + throw new Response(publicMessage, {status: 500}); + } + }; } diff --git a/packages/hydrogen/src/customer/types.ts b/packages/hydrogen/src/customer/types.ts index 082a4be101..e65ce9f9bb 100644 --- a/packages/hydrogen/src/customer/types.ts +++ b/packages/hydrogen/src/customer/types.ts @@ -130,8 +130,12 @@ export type CustomerAccountOptions = { session: HydrogenSession; /** Unique UUID prefixed with `shp_` associated with the application, this should be visible in the customer account api settings in the Hydrogen admin channel. Mock.shop doesn't automatically supply customerAccountId. Use `npx shopify hydrogen env pull` to link your store credentials. */ customerAccountId: string; - /** The account URL associated with the application, this should be visible in the customer account api settings in the Hydrogen admin channel. Mock.shop doesn't automatically supply customerAccountUrl. Use `npx shopify hydrogen env pull` to link your store credentials. */ - customerAccountUrl: string; + /** + * @deprecated use `shopId` instead. The account URL associated with the application, this should be visible in the customer account api settings in the Hydrogen admin channel. Mock.shop doesn't automatically supply customerAccountUrl. Use `npx shopify hydrogen env pull` to link your store credentials. + */ + customerAccountUrl?: string; + /** The shop id. Mock.shop doesn't automatically supply shopId. Use `npx shopify hydrogen env pull` to link your store credentials */ + shopId?: string; /** Override the version of the API */ customerApiVersion?: string; /** The object for the current Request. It should be provided by your platform. */ diff --git a/packages/hydrogen/src/types.d.ts b/packages/hydrogen/src/types.d.ts index 8fa117be30..96d0829a82 100644 --- a/packages/hydrogen/src/types.d.ts +++ b/packages/hydrogen/src/types.d.ts @@ -49,6 +49,7 @@ export interface HydrogenEnv { PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID: string; PUBLIC_CUSTOMER_ACCOUNT_API_URL: string; PUBLIC_CHECKOUT_DOMAIN: string; + SHOP_ID: string; } export type StorefrontHeaders = { diff --git a/templates/skeleton/app/routes/account.orders.$id.tsx b/templates/skeleton/app/routes/account.orders.$id.tsx index 18e9f596ce..6362e5f6b5 100644 --- a/templates/skeleton/app/routes/account.orders.$id.tsx +++ b/templates/skeleton/app/routes/account.orders.$id.tsx @@ -29,7 +29,9 @@ export async function loader({params, context}: LoaderFunctionArgs) { const lineItems = flattenConnection(order.lineItems); const discountApplications = flattenConnection(order.discountApplications); - const fulfillmentStatus = flattenConnection(order.fulfillments)[0].status; + + const fulfillmentStatus = + flattenConnection(order.fulfillments)[0]?.status ?? 'N/A'; const firstDiscount = discountApplications[0]?.value;