Skip to content

Commit

Permalink
Added query operation to the scopes api
Browse files Browse the repository at this point in the history
  • Loading branch information
alvaro-shopify committed Jul 18, 2024
1 parent ac9477b commit 192cc6b
Show file tree
Hide file tree
Showing 10 changed files with 282 additions and 1 deletion.
5 changes: 5 additions & 0 deletions .changeset/four-coats-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/shopify-app-remix': minor
---

Added query scopes information api
5 changes: 5 additions & 0 deletions .changeset/real-emus-walk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/shopify-api': patch
---

Expose AuthScopes type
1 change: 1 addition & 0 deletions packages/apps/shopify-api/lib/auth/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {AdapterArgs} from '../../runtime/http';

export * from './oauth/types';
export * from './scopes/index';
export {RequestedTokenType} from './oauth/token-exchange';

export interface GetEmbeddedAppUrlParams extends AdapterArgs {}
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ export function authStrategyFactory<
if (config.future.unstable_optionalScopesApi) {
return {
...context,
scopes: scopesApiFactory(params, context.session),
scopes: scopesApiFactory(params, context.session, context.admin),
};
}
return context;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export const WITH_GRANTED_AND_DECLARED = JSON.stringify({
data: {
app: {
requestedAccessScopes: [
{
handle: 'write_orders',
},
{
handle: 'read_reports',
},
],
installation: {
accessScopes: [
{
handle: 'read_orders',
},
{
handle: 'write_customers',
},
],
},
},
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import {
APP_URL,
BASE64_HOST,
TEST_SHOP,
expectExitIframeRedirect,
getJwt,
getThrownResponse,
mockExternalRequest,
setUpValidSession,
testConfig,
} from '../../../../__test-helpers';
import {LATEST_API_VERSION, shopifyApp} from '../../../..';
import {REAUTH_URL_HEADER} from '../../../const';

import * as responses from './mock-responses';

it('returns scopes information', async () => {
// GIVEN
const {scopes} = await setUpEmbeddedFlow();
await mockGraphqlRequest(200, responses.WITH_GRANTED_AND_DECLARED);

// WHEN
const result = await scopes.query();

// THEN
expect(result).not.toBeUndefined();
expect(result.granted.required).toEqual(['read_orders']);
expect(result.granted.optional).toEqual(['write_customers']);
expect(result.declared.required).toEqual(['write_orders', 'read_reports']);
});

it('redirects to exit-iframe with authentication using app bridge when embedded and Shopify invalidated the session', async () => {
// GIVEN
const {scopes} = await setUpEmbeddedFlow();
const requestMock = await mockGraphqlRequest(401);

// WHEN
const response = await getThrownResponse(
async () => scopes.query(),
requestMock,
);

// THEN
expectExitIframeRedirect(response);
});

it('returns app bridge redirection during request headers when Shopify invalidated the session', async () => {
// GIVEN
const {scopes} = await setUpFetchFlow();
const requestMock = await mockGraphqlRequest(401);

// WHEN
const response = await getThrownResponse(
async () => scopes.query(),
requestMock,
);

// THEN
expect(response.status).toEqual(401);

const {origin, pathname, searchParams} = new URL(
response.headers.get(REAUTH_URL_HEADER)!,
);
expect(origin).toEqual(APP_URL);
expect(pathname).toEqual('/auth');
expect(searchParams.get('shop')).toEqual(TEST_SHOP);
});

it('return an unexpected error when there is no authentication error', async () => {
// GIVEN
const {scopes} = await setUpFetchFlow();
await mockGraphqlRequest(500);

// WHEN / THEN
try {
await scopes.query();
} catch (error) {
expect(error.status).toEqual(500);
}
});

async function setUpEmbeddedFlow() {
const shopify = shopifyApp(
testConfig({
future: {unstable_newEmbeddedAuthStrategy: false},
}),
);
const expectedSession = await setUpValidSession(shopify.sessionStorage);

const {token} = getJwt();
const request = new Request(
`${APP_URL}?embedded=1&shop=${TEST_SHOP}&host=${BASE64_HOST}&id_token=${token}`,
);

return {
shopify,
expectedSession,
...(await shopify.authenticate.admin(request)),
};
}

async function setUpFetchFlow() {
const shopify = shopifyApp(
testConfig({
future: {unstable_newEmbeddedAuthStrategy: false},
}),
);
await setUpValidSession(shopify.sessionStorage);

const {token} = getJwt();
const request = new Request(APP_URL, {
headers: {Authorization: `Bearer ${token}`},
});

return {
shopify,
...(await shopify.authenticate.admin(request)),
};
}

async function mockGraphqlRequest(status = 401, responseContent?: string) {
const requestMock = new Request(
`https://${TEST_SHOP}/admin/api/${LATEST_API_VERSION}/graphql.json`,
{method: 'POST'},
);

await mockExternalRequest({
request: requestMock,
response: new Response(responseContent, {status}),
});

return requestMock;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {AdminApiContext} from '../../../../clients';

export interface FetchScopeInformationResponse {
app: {
requestedAccessScopes: {
handle: string;
}[];
optionalAccessScopes: {
handle: string;
}[];
installation: {
accessScopes: {
handle: string;
}[];
};
};
}

const FETCH_SCOPE_INFORMATION_QUERY = `#graphql
query FetchAccessScopes{
app {
requestedAccessScopes {
handle
}
installation {
accessScopes {
handle
}
}
}
}`;

export async function fetchScopeInformation(admin: AdminApiContext) {
const fetchScopeInformationResult = await admin.graphql(
FETCH_SCOPE_INFORMATION_QUERY,
);

return (await fetchScopeInformationResult.json())
.data as FetchScopeInformationResponse;
}
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import {Session} from '@shopify/shopify-api';

import {BasicParams} from '../../../types';
import {AdminApiContext} from '../../../clients';

import {ScopesApiContext} from './types';
import {requestScopesFactory} from './request';
import {queryScopesFactory} from './query';

export function scopesApiFactory(
params: BasicParams,
session: Session,
admin: AdminApiContext,
): ScopesApiContext {
return {
query: queryScopesFactory(params, session, admin),
request: requestScopesFactory(params, session),
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import {AuthScopes, Session} from '@shopify/shopify-api';

import {AdminApiContext} from '../../../clients';
import type {BasicParams} from '../../../types';

import {ScopesInformation} from './types';
import {
FetchScopeInformationResponse,
fetchScopeInformation,
} from './client/fetch-scopes-information';

export function queryScopesFactory(
params: BasicParams,
session: Session,
admin: AdminApiContext,
) {
return async function queryScopes() {
const {logger} = params;

logger.debug('Query scopes information: ', {
shop: session.shop,
});

const scopesInformation = await fetchScopeInformation(admin);
return mapFetchScopeInformation(scopesInformation);
};
}

function mapFetchScopeInformation(
fetchScopeInformation: FetchScopeInformationResponse,
): ScopesInformation {
const appInformation = fetchScopeInformation.app;
const declaredRequired = new AuthScopes(
appInformation.requestedAccessScopes.map((scope) => scope.handle),
);

const grantedRequired = appInformation.installation.accessScopes
.map((scope) => scope.handle)
.filter((scope) => declaredRequired.has(scope));

const grantedOptional = appInformation.installation.accessScopes
.map((scope) => scope.handle)
.filter((scope) => !declaredRequired.has(scope));

return {
granted: {
required: grantedRequired,
optional: grantedOptional,
},
declared: {
required: declaredRequired.toArray(),
},
};
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,18 @@
export interface ScopesApiContext {
query: () => Promise<ScopesInformation>;
request: (scopes: string[]) => Promise<void>;
}

export interface ScopesInformation {
granted: GrantedScopes;
declared: DeclaredScopes;
}

export interface DeclaredScopes {
required: string[];
}

export interface GrantedScopes {
required: string[];
optional: string[];
}

0 comments on commit 192cc6b

Please sign in to comment.