Skip to content
This repository has been archived by the owner on Apr 11, 2024. It is now read-only.

Commit

Permalink
Merge pull request #1036 from Shopify/introduce-token-exchange-api
Browse files Browse the repository at this point in the history
Introduce Token Exchange API
  • Loading branch information
rezaansyed authored Nov 13, 2023
2 parents 2a0b17d + 4fce514 commit 6c2274a
Show file tree
Hide file tree
Showing 15 changed files with 611 additions and 96 deletions.
5 changes: 5 additions & 0 deletions .changeset/healthy-pans-push.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@shopify/shopify-api": minor
---

Introduce token exchange API for fetching access tokens. This feature is currently unstable and is hidden behind the `unstable_tokenExchange` future flag.
4 changes: 3 additions & 1 deletion packages/shopify-api/adapters/__e2etests__/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,7 @@ export const config: ConfigInterface = {
httpRequests: false,
timestamps: false,
},
future: {},
future: {
unstable_tokenExchange: true,
},
};
4 changes: 3 additions & 1 deletion packages/shopify-api/future/flags.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export interface FutureFlags {}
export interface FutureFlags {
unstable_tokenExchange?: boolean;
}

export type FutureFlagOptions = FutureFlags | undefined;

Expand Down
30 changes: 17 additions & 13 deletions packages/shopify-api/lib/__tests__/test-config.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
import type {ConfigParams} from '../base-types';
// import type {FutureFlags} from '../../future/flags';
import type {FutureFlags} from '../../future/flags';
import {LATEST_API_VERSION, LogSeverity} from '../types';

// Uncomment this when we add a flag
// type DefaultedFutureFlag<
// Overrides extends Partial<ConfigParams>,
// Flag extends keyof FutureFlags,
// > = Overrides['future'] extends FutureFlags ? Overrides['future'][Flag] : true;
type DefaultedFutureFlag<
Overrides extends Partial<ConfigParams>,
Flag extends keyof FutureFlags,
> = Overrides['future'] extends FutureFlags ? Overrides['future'][Flag] : true;

type TestConfig<Overrides extends Partial<ConfigParams>> = ConfigParams &
Overrides & {
// Create an object with all future flags defaulted to active to ensure our tests are updated when we introduce new flags
// future: {
// // e.g.
// // v8_newHttpClients: DefaultedFutureFlag<Overrides, 'v8_newHttpClients'>;
// };
future: {
// e.g.
// v8_newHttpClients: DefaultedFutureFlag<Overrides, 'v8_newHttpClients'>;
unstable_tokenExchange: DefaultedFutureFlag<
Overrides,
'unstable_tokenExchange'
>;
};
};

export function testConfig<Overrides extends Partial<ConfigParams>>(
Expand All @@ -39,9 +43,9 @@ export function testConfig<Overrides extends Partial<ConfigParams>>(
timestamps: false,
...overrides.logger,
},
// future: {
// v8_newHttpClients: true,
// ...(overrides.future as Overrides['future']),
// },
future: {
unstable_tokenExchange: true,
...(overrides.future as Overrides['future']),
},
};
}
12 changes: 10 additions & 2 deletions packages/shopify-api/lib/auth/get-embedded-app-url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ import {sanitizeHost} from '../utils/shop-validator';
import {decodeHost} from './decode-host';
import {GetEmbeddedAppUrlParams} from './types';

export function getEmbeddedAppUrl(config: ConfigInterface) {
export type GetEmbeddedAppUrl = (
params: GetEmbeddedAppUrlParams,
) => Promise<string>;

export type BuildEmbeddedAppUrl = (host: string) => string;

export function getEmbeddedAppUrl(config: ConfigInterface): GetEmbeddedAppUrl {
return async ({...adapterArgs}: GetEmbeddedAppUrlParams): Promise<string> => {
const request = await abstractConvertRequest(adapterArgs);

Expand Down Expand Up @@ -35,7 +41,9 @@ export function getEmbeddedAppUrl(config: ConfigInterface) {
};
}

export function buildEmbeddedAppUrl(config: ConfigInterface) {
export function buildEmbeddedAppUrl(
config: ConfigInterface,
): BuildEmbeddedAppUrl {
return (host: string): string => {
sanitizeHost()(host, true);
const decodedHost = decodeHost(host);
Expand Down
40 changes: 32 additions & 8 deletions packages/shopify-api/lib/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,43 @@
import {ConfigInterface} from '../base-types';
import {FeatureEnabled, FutureFlagOptions} from '../../future/flags';

import {begin, callback} from './oauth/oauth';
import {nonce} from './oauth/nonce';
import {safeCompare} from './oauth/safe-compare';
import {getEmbeddedAppUrl, buildEmbeddedAppUrl} from './get-embedded-app-url';
import {OAuthBegin, OAuthCallback, begin, callback} from './oauth/oauth';
import {Nonce, nonce} from './oauth/nonce';
import {SafeCompare, safeCompare} from './oauth/safe-compare';
import {
getEmbeddedAppUrl,
buildEmbeddedAppUrl,
GetEmbeddedAppUrl,
BuildEmbeddedAppUrl,
} from './get-embedded-app-url';
import {TokenExchange, tokenExchange} from './oauth/token-exchange';

export function shopifyAuth(config: ConfigInterface) {
return {
export function shopifyAuth<Config extends ConfigInterface>(
config: Config,
): ShopifyAuth<Config['future']> {
const shopify = {
begin: begin(config),
callback: callback(config),
nonce,
safeCompare,
getEmbeddedAppUrl: getEmbeddedAppUrl(config),
buildEmbeddedAppUrl: buildEmbeddedAppUrl(config),
};
} as ShopifyAuth<Config['future']>;

if (config.future?.unstable_tokenExchange) {
shopify.tokenExchange = tokenExchange(config);
}

return shopify;
}

export type ShopifyAuth = ReturnType<typeof shopifyAuth>;
export type ShopifyAuth<Future extends FutureFlagOptions> = {
begin: OAuthBegin;
callback: OAuthCallback;
nonce: Nonce;
safeCompare: SafeCompare;
getEmbeddedAppUrl: GetEmbeddedAppUrl;
buildEmbeddedAppUrl: BuildEmbeddedAppUrl;
} & (FeatureEnabled<Future, 'unstable_tokenExchange'> extends true
? {tokenExchange: TokenExchange}
: {[key: string]: never});
141 changes: 141 additions & 0 deletions packages/shopify-api/lib/auth/oauth/__tests__/create-session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import {createSession} from '../create-session';
import {Session, shopifyApi} from '../../..';
import {testConfig} from '../../../__tests__/test-config';

let shop: string;
const STATIC_UUID = 'test-uuid';

jest.useFakeTimers().setSystemTime(new Date('2023-11-11'));

jest.mock('uuid', () => ({v4: jest.fn(() => STATIC_UUID)}));
jest.requireMock('uuid');

beforeEach(() => {
shop = 'someshop.myshopify.io';
});

describe('createSession', () => {
describe('when receiving an offline token', () => {
test.each([true, false])(
`creates a new offline session when embedded is %s`,
(isEmbeddedApp) => {
const shopify = shopifyApi(testConfig({isEmbeddedApp}));

const accessTokenResponse = {
access_token: 'some access token string',
scope: shopify.config.scopes.toString(),
};

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

describe('when receiving an online token', () => {
test('creates a new online session with shop_user as id when embedded is true', () => {
const shopify = shopifyApi(testConfig({isEmbeddedApp: true}));

const onlineAccessInfo = {
expires_in: 525600,
associated_user_scope: 'pet_kitties',
associated_user: {
id: 8675309,
first_name: 'John',
last_name: 'Smith',
email: 'john@example.com',
email_verified: true,
account_owner: true,
locale: 'en',
collaborator: true,
},
};

const accessTokenResponse = {
access_token: 'some access token',
scope: 'pet_kitties, walk_dogs',
...onlineAccessInfo,
};

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

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

test('creates a new online session with uuid as id when embedded is false', () => {
const shopify = shopifyApi(testConfig({isEmbeddedApp: false}));

const onlineAccessInfo = {
expires_in: 525600,
associated_user_scope: 'pet_kitties',
associated_user: {
id: 8675309,
first_name: 'John',
last_name: 'Smith',
email: 'john@example.com',
email_verified: true,
account_owner: true,
locale: 'en',
collaborator: true,
},
};

const accessTokenResponse = {
access_token: 'some access token',
scope: 'pet_kitties, walk_dogs',
...onlineAccessInfo,
};

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

expect(session).toEqual(
new Session({
id: STATIC_UUID,
shop,
isOnline: true,
state: 'test-state',
accessToken: accessTokenResponse.access_token,
scope: accessTokenResponse.scope,
expires: new Date(Date.now() + onlineAccessInfo.expires_in * 1000),
onlineAccessInfo,
}),
);
});
});
});
Loading

0 comments on commit 6c2274a

Please sign in to comment.