Skip to content

Commit

Permalink
feat: added support client credentials in shopify-api-js
Browse files Browse the repository at this point in the history
  • Loading branch information
fwaadahmad1 committed Jan 31, 2025
1 parent fcd67ac commit c16c2cd
Show file tree
Hide file tree
Showing 8 changed files with 354 additions and 36 deletions.
5 changes: 5 additions & 0 deletions .changeset/proud-dolls-join.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/shopify-api': minor
---

Introduces Client credentials token acquisition flow to `shopify-api-js` library
3 changes: 3 additions & 0 deletions packages/apps/shopify-api/lib/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
BuildEmbeddedAppUrl,
} from './get-embedded-app-url';
import {TokenExchange, tokenExchange} from './oauth/token-exchange';
import {ClientCredentials, clientCredentials} from './oauth/client-credentials';

export {AuthScopes} from './scopes';

Expand All @@ -24,6 +25,7 @@ export function shopifyAuth<Config extends ConfigInterface>(
getEmbeddedAppUrl: getEmbeddedAppUrl(config),
buildEmbeddedAppUrl: buildEmbeddedAppUrl(config),
tokenExchange: tokenExchange(config),
clientCredentials: clientCredentials(config),
} as ShopifyAuth;

return shopify;
Expand All @@ -37,4 +39,5 @@ export interface ShopifyAuth {
getEmbeddedAppUrl: GetEmbeddedAppUrl;
buildEmbeddedAppUrl: BuildEmbeddedAppUrl;
tokenExchange: TokenExchange;
clientCredentials: ClientCredentials;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import {shopifyApi} from '../../..';
import {testConfig} from '../../../__tests__/test-config';
import {queueMockResponse} from '../../../__tests__/test-helper';
import * as ShopifyErrors from '../../../error';
import {DataType} from '../../../clients/types';

describe('clientCredentials', () => {
const shop = 'test-shop.myshopify.io';

describe('with valid parameters', () => {
test('returns a session on success', async () => {
const shopify = shopifyApi(testConfig());
const successResponse = {
access_token: 'some_access_token',
scope: 'write_products,read_orders',
expires_in: 3600,
};

const expectedExpiration = new Date(
Date.now() + successResponse.expires_in * 1000,
).getTime();

queueMockResponse(JSON.stringify(successResponse));

const response = await shopify.auth.clientCredentials({
shop,
});

// Verify the request was made with correct parameters
expect({
method: 'POST',
domain: shop,
path: '/admin/oauth/access_token',
headers: {
'Content-Type': DataType.JSON,
Accept: DataType.JSON,
},
data: {
client_id: shopify.config.apiKey,
client_secret: shopify.config.apiSecretKey,
grant_type: 'client_credentials',
},
}).toMatchMadeHttpRequest();

// Verify the response contains expected session data
expect(response.session).toEqual(
expect.objectContaining({
accessToken: successResponse.access_token,
scope: successResponse.scope,
}),
);

expect(response.session?.expires?.getTime()).toBeWithinSecondsOf(
expectedExpiration,
1,
);
});

test('throws error when response is not successful', async () => {
const shopify = shopifyApi(testConfig());
const errorResponse = {
error: 'invalid_client',
error_description: 'Client authentication failed',
};

queueMockResponse(JSON.stringify(errorResponse), {
statusCode: 400,
statusText: 'Bad request',
});

await expect(
shopify.auth.clientCredentials({
shop,
}),
).rejects.toThrow(ShopifyErrors.HttpResponseError);
});
});

describe('with invalid parameters', () => {
test('throws error for invalid shop domain', async () => {
const shopify = shopifyApi(testConfig());
const invalidShop = 'invalid-shop-url';

await expect(
shopify.auth.clientCredentials({
shop: invalidShop,
}),
).rejects.toThrow(ShopifyErrors.InvalidShopError);
});

test('throws error for non-myshopify domain', async () => {
const shopify = shopifyApi(testConfig());
const invalidShop = 'test-shop.something.com';

await expect(
shopify.auth.clientCredentials({
shop: invalidShop,
}),
).rejects.toThrow(ShopifyErrors.InvalidShopError);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ beforeEach(() => {
});

describe('createSession', () => {
describe('when receiving an offline token', () => {
describe('when receiving an offline token with no expiry', () => {
test.each([true, false])(
`creates a new offline session when embedded is %s`,
`creates a new offline session with no expiry when embedded is %s`,
(isEmbeddedApp) => {
const shopify = shopifyApi(testConfig({isEmbeddedApp}));
const scopes = shopify.config.scopes
Expand Down Expand Up @@ -49,6 +49,44 @@ describe('createSession', () => {
},
);
});
describe('when receiving an offline token with expiry', () => {
test.each([true, false])(
`creates a new offline session with expiry when embedded is %s`,
(isEmbeddedApp) => {
const shopify = shopifyApi(testConfig({isEmbeddedApp}));
const scopes = shopify.config.scopes
? shopify.config.scopes.toString()
: '';

const accessTokenResponse = {
access_token: 'some access token string',
scope: scopes,
expires_in: 525600,
};

const session = createSession({
config: shopify.config,
accessTokenResponse,
shop,
state: 'test-state',
});

expect(session).toEqual(
new Session({
id: `offline_${shop}`,
shop,
isOnline: false,
state: 'test-state',
accessToken: accessTokenResponse.access_token,
scope: accessTokenResponse.scope,
expires: new Date(
Date.now() + accessTokenResponse.expires_in * 1000,
),
}),
);
},
);
});

describe('when receiving an online token', () => {
test('creates a new online session with shop_user as id when embedded is true', () => {
Expand Down
98 changes: 96 additions & 2 deletions packages/apps/shopify-api/lib/auth/oauth/__tests__/oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -566,7 +566,6 @@ describe('callback', () => {
const successResponse = {
access_token: 'some access token',
scope: 'pet_kitties, walk_dogs',
expires_in: 525600,
};
const testCallbackQuery: QueryMock = {
shop,
Expand Down Expand Up @@ -596,6 +595,102 @@ describe('callback', () => {
expect(responseCookies.shopify_app_session).toBeUndefined();
});

test('callback throws an error when the request is done by a bot', async () => {
const shopify = shopifyApi(testConfig());

const botRequest = {
method: 'GET',
url: 'https://my-test-app.myshopify.io/totally-real-request',
headers: {
'User-Agent': 'Googlebot',
},
} as NormalizedRequest;

await expect(
shopify.auth.callback({
rawRequest: botRequest,
}),
).rejects.toThrow(ShopifyErrors.BotActivityDetected);
});
test('create Session without setting an OAuth cookie for expiring offline tokens, embedded apps', async () => {
const shopify = shopifyApi(testConfig({isEmbeddedApp: true}));

const beginResponse: NormalizedResponse = await shopify.auth.begin({
shop,
isOnline: false,
callbackPath: '/some-callback',
rawRequest: request,
});
setCallbackCookieFromResponse(
request,
beginResponse,
shopify.config.apiSecretKey,
);

const successResponse = {
access_token: 'some access token',
scope: 'pet_kitties, walk_dogs',
expires_in: 525600,
};
const expectedExpiration = new Date(
Date.now() + successResponse.expires_in * 1000,
).getTime();
const testCallbackQuery: QueryMock = {
shop,
state: VALID_NONCE,
timestamp: getCurrentTimeInSec().toString(),
code: 'some random auth code',
};
const expectedHmac = await generateLocalHmac(shopify.config)(
testCallbackQuery,
);
testCallbackQuery.hmac = expectedHmac;
request.url += `?${new URLSearchParams(testCallbackQuery).toString()}`;

queueMockResponse(JSON.stringify(successResponse));

const callbackResponse = await shopify.auth.callback({rawRequest: request});

const responseCookies = Cookies.parseCookies(
callbackResponse.headers['Set-Cookie'],
);

expect(callbackResponse.session).toEqual(
expect.objectContaining({
id: getOfflineId(shopify.config)(shop),
isOnline: false,
accessToken: successResponse.access_token,
scope: successResponse.scope,
shop,
state: VALID_NONCE,
}),
);
expect(callbackResponse.session.expires?.getTime()).toBeWithinSecondsOf(
expectedExpiration,
1,
);

expect(responseCookies.shopify_app_session).toBeUndefined();
});

test('callback throws an error when the request is done by a bot', async () => {
const shopify = shopifyApi(testConfig());

const botRequest = {
method: 'GET',
url: 'https://my-test-app.myshopify.io/totally-real-request',
headers: {
'User-Agent': 'Googlebot',
},
} as NormalizedRequest;

await expect(
shopify.auth.callback({
rawRequest: botRequest,
}),
).rejects.toThrow(ShopifyErrors.BotActivityDetected);
});

test('callback throws an error when the request is done by a bot', async () => {
const shopify = shopifyApi(testConfig());

Expand Down Expand Up @@ -632,7 +727,6 @@ describe('callback', () => {
const successResponse = {
access_token: 'some access token',
scope: 'pet_kitties, walk_dogs',
expires_in: 525600,
};
const testCallbackQuery: QueryMock = {
shop,
Expand Down
62 changes: 62 additions & 0 deletions packages/apps/shopify-api/lib/auth/oauth/client-credentials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import {ConfigInterface} from '../../base-types';
import {throwFailedRequest} from '../../clients/common';
import {DataType} from '../../clients/types';
import {Session} from '../../session/session';
import {fetchRequestFactory} from '../../utils/fetch-request';
import {sanitizeShop} from '../../utils/shop-validator';

import {createSession} from './create-session';
import {AccessTokenResponse} from './types';

export interface ClientCredentialsParams {
shop: string;
}

const ClientCredentialsGrantType = 'client_credentials';

export type ClientCredentials = (
params: ClientCredentialsParams,
) => Promise<{session: Session}>;

export function clientCredentials(config: ConfigInterface): ClientCredentials {
return async ({shop}: ClientCredentialsParams) => {
const cleanShop = sanitizeShop(config)(shop, true);
if (!cleanShop) {
throw new Error('Invalid shop domain');
}

const requestConfig = {
method: 'POST',
body: JSON.stringify({
client_id: config.apiKey,
client_secret: config.apiSecretKey,
grant_type: ClientCredentialsGrantType,
}),
headers: {
'Content-Type': DataType.JSON,
Accept: DataType.JSON,
},
};

const postResponse = await fetchRequestFactory(config)(
`https://${cleanShop}/admin/oauth/access_token`,
requestConfig,
);

const responseData = (await postResponse.json()) as AccessTokenResponse;

if (!postResponse.ok) {
throwFailedRequest(responseData, false, postResponse);
}

return {
session: createSession({
accessTokenResponse: responseData,
shop: cleanShop,
// We need to keep this as an empty string as our template DB schemas have this required
state: '',
config,
}),
};
};
}
Loading

0 comments on commit c16c2cd

Please sign in to comment.