Skip to content

Commit

Permalink
add logout redirect option to Customer Account Client (#1871)
Browse files Browse the repository at this point in the history
* add logout redirect option

* add doc

* add test

* add handling for not login & remove possibility to redirect to non app origin url + add test

* ensure we only redirect within the app

* check for cross site redirect, if that occur warn and redirect to default
  • Loading branch information
michenly committed Mar 21, 2024
1 parent 10031a2 commit ca1dcbb
Show file tree
Hide file tree
Showing 5 changed files with 293 additions and 30 deletions.
5 changes: 5 additions & 0 deletions .changeset/long-boxes-punch.md
@@ -0,0 +1,5 @@
---
'@shopify/hydrogen': patch
---

✨ Add `postLogoutRedirectUri` option to customer account client's logout method
198 changes: 189 additions & 9 deletions packages/hydrogen/src/customer/customer.test.ts
Expand Up @@ -113,30 +113,210 @@ describe('customer', () => {
expect(params.get('code_challenge_method')).toBe('S256');
});

it('Redirects to the customer account api logout url', async () => {
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,
customerAccountId: 'customerAccountId',
customerAccountUrl: 'https://customer-api',
request: new Request('https://localhost'),
request: new Request(origin),
waitUntil: vi.fn(),
authUrl,
});

const response = await customer.logout();
const response = await customer.login();
const url = new URL(response.headers.get('location')!);

expect(response.status).toBe(302);
expect(response.headers.get('Set-Cookie')).toBe('cookie');
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('redirect_uri')).toBe(
new URL('/account/authorize', origin).toString(),
);
});

describe('logout', () => {
it('Redirects to the customer account api logout url', async () => {
const origin = 'https://shop123.com';

expect(params.get('id_token_hint')).toBe('id_token');
const customer = createCustomerAccountClient({
session,
customerAccountId: 'customerAccountId',
customerAccountUrl: 'https://customer-api',
request: new Request(origin),
waitUntil: vi.fn(),
});

// Session is cleared
expect(session.unset).toHaveBeenCalledWith(CUSTOMER_ACCOUNT_SESSION_KEY);
const response = await customer.logout();

expect(response.status).toBe(302);
expect(response.headers.get('Set-Cookie')).toBe('cookie');
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 () => {
Expand Down
49 changes: 34 additions & 15 deletions packages/hydrogen/src/customer/customer.ts
Expand Up @@ -35,14 +35,17 @@ import {
getDebugHeaders,
} from '../utils/request';
import {getCallerStackLine, withSyncStack} from '../utils/callsites';
import {getRedirectUrl} from '../utils/get-redirect-url';
import {
getRedirectUrl,
ensureLocalRedirectUrl,
} from '../utils/get-redirect-url';
import type {
CustomerAccountOptions,
CustomerAccount,
CustomerAPIResponse,
LoginOptions,
LogoutOptions,
} from './types';
import {LanguageCode} from '@shopify/hydrogen-react/storefront-api-types';

const DEFAULT_LOGIN_URL = '/account/login';
const DEFAULT_AUTH_URL = '/account/authorize';
Expand All @@ -67,7 +70,7 @@ export function createCustomerAccountClient({
customerApiVersion = DEFAULT_CUSTOMER_API_VERSION,
request,
waitUntil,
authUrl = DEFAULT_AUTH_URL,
authUrl,
customAuthStatusHandler,
logErrors = true,
}: CustomerAccountOptions): CustomerAccount {
Expand All @@ -91,7 +94,11 @@ export function createCustomerAccountClient({
requestUrl.protocol === 'http:'
? requestUrl.origin.replace('http', 'https')
: requestUrl.origin;
const redirectUri = authUrl.startsWith('/') ? origin + authUrl : authUrl;
const redirectUri = ensureLocalRedirectUrl({
requestUrl: request.url,
defaultUrl: DEFAULT_AUTH_URL,
redirectUrl: authUrl,
});
const customerAccountApiUrl = `${customerAccountUrl}/account/customer/api/${customerApiVersion}/graphql`;
const locks: Locks = {};

Expand Down Expand Up @@ -246,7 +253,7 @@ export function createCustomerAccountClient({
return {
login: async (options?: LoginOptions) => {
ifInvalidCredentialThrowError(customerAccountUrl, customerAccountId);
const loginUrl = new URL(customerAccountUrl + '/auth/oauth/authorize');
const loginUrl = new URL(`${customerAccountUrl}/auth/oauth/authorize`);

const state = generateState();
const nonce = generateNonce();
Expand Down Expand Up @@ -294,22 +301,34 @@ export function createCustomerAccountClient({
},
});
},
logout: async () => {

logout: async (options?: LogoutOptions) => {
ifInvalidCredentialThrowError(customerAccountUrl, customerAccountId);

const idToken = session.get(CUSTOMER_ACCOUNT_SESSION_KEY)?.idToken;
const postLogoutRedirectUri = ensureLocalRedirectUrl({
requestUrl: origin,
defaultUrl: origin,
redirectUrl: options?.postLogoutRedirectUri,
});

clearSession(session);
const logoutUrl = idToken
? new URL(
`${customerAccountUrl}/auth/logout?${new URLSearchParams([
['id_token_hint', idToken],
['post_logout_redirect_uri', postLogoutRedirectUri],
]).toString()}`,
).toString()
: postLogoutRedirectUri;

return redirect(
`${customerAccountUrl}/auth/logout?id_token_hint=${idToken}`,
{
status: 302,
clearSession(session);

headers: {
'Set-Cookie': await session.commit(),
},
return redirect(logoutUrl, {
status: 302,
headers: {
'Set-Cookie': await session.commit(),
},
);
});
},
isLoggedIn,
handleAuthStatus,
Expand Down
22 changes: 16 additions & 6 deletions packages/hydrogen/src/customer/types.ts
Expand Up @@ -50,6 +50,10 @@ export type LoginOptions = {
uiLocales?: LanguageCode;
};

export type LogoutOptions = {
postLogoutRedirectUri?: string;
};

export type CustomerAccount = {
/** Start the OAuth login flow. This function should be called and returned from a Remix action.
* It redirects the customer to a Shopify login domain. It also defined the final path the customer
Expand All @@ -71,8 +75,11 @@ export type CustomerAccount = {
getAccessToken: () => Promise<string | undefined>;
/** Creates the fully-qualified URL to your store's GraphQL endpoint.*/
getApiUrl: () => string;
/** Logout the customer by clearing the session and redirecting to the login domain. It should be called and returned from a Remix action. The path app should redirect to after logout can be setup in Customer Account API settings in admin.*/
logout: () => Promise<Response>;
/** Logout the customer by clearing the session and redirecting to the login domain. It should be called and returned from a Remix action. The path app should redirect to after logout can be setup in Customer Account API settings in admin.
*
* @param options.postLogoutRedirectUri - The url to redirect customer to after logout, should be a relative URL. This url will need to included in Customer Account API's application setup for logout URI. The default value is current app origin, which is automatically setup in admin when using `--customer-account-push` flag with dev.
* */
logout: (options?: LogoutOptions) => Promise<Response>;
/** Execute a GraphQL query against the Customer Account API. This method execute `handleAuthStatus()` ahead of query. */
query: <
OverrideReturnType extends any = never,
Expand Down Expand Up @@ -144,7 +151,7 @@ export type CustomerAccountForDocs = {
* `en`, `fr`, `cs`, `da`, `de`, `es`, `fi`, `it`, `ja`, `ko`, `nb`, `nl`, `pl`, `pt-BR`, `pt-PT`,
* `sv`, `th`, `tr`, `vi`, `zh-CN`, `zh-TW`. If supplied any other language code, it will default to `en`.
* */
login: (options?: LoginOptions) => Promise<Response>;
login?: (options?: LoginOptions) => Promise<Response>;
/** On successful login, the customer redirects 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 in admin. */
authorize?: () => Promise<Response>;
/** Returns if the customer is logged in. It also checks if the access token is expired and refreshes it if needed. */
Expand All @@ -154,9 +161,12 @@ export type CustomerAccountForDocs = {
/** Returns CustomerAccessToken if the customer is logged in. It also run a expiry check and does a token refresh if needed. */
getAccessToken?: () => Promise<string | undefined>;
/** Creates the fully-qualified URL to your store's GraphQL endpoint.*/
getApiUrl: () => string;
/** Logout the customer by clearing the session and redirecting to the login domain. It should be called and returned from a Remix action. The path app should redirect to after logout can be setup in Customer Account API settings in admin.*/
logout?: () => Promise<Response>;
getApiUrl?: () => string;
/** Logout the customer by clearing the session and redirecting to the login domain. It should be called and returned from a Remix action. The path app should redirect to after logout can be setup in Customer Account API settings in admin.
*
* @param options.postLogoutRedirectUri - The url to redirect customer to after logout, should be a relative URL. This url will need to included in Customer Account API's application setup for logout URI. The default value is current app origin, which is automatically setup in admin when using `--customer-account-push` flag with dev.
* */
logout?: (options?: LogoutOptions) => Promise<Response>;
/** Execute a GraphQL query against the Customer Account API. This method execute `handleAuthStatus()` ahead of query. */
query?: <TData = any>(
query: string,
Expand Down

0 comments on commit ca1dcbb

Please sign in to comment.