Skip to content

Commit 8d6a6a3

Browse files
committed
Add admin-as-app authentication
1 parent 9f10bdf commit 8d6a6a3

File tree

4 files changed

+216
-1
lines changed

4 files changed

+216
-1
lines changed
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import {adminAsAppRequestDoc} from './admin-as-app.js'
2+
import {graphqlRequestDoc} from '@shopify/cli-kit/node/api/graphql'
3+
import {AdminSession} from '@shopify/cli-kit/node/session'
4+
import {describe, test, expect, vi, beforeEach} from 'vitest'
5+
import {TypedDocumentNode} from '@graphql-typed-document-node/core'
6+
7+
vi.mock('@shopify/cli-kit/node/api/graphql')
8+
9+
describe('adminAsAppRequestDoc', () => {
10+
const mockSession: AdminSession = {
11+
token: 'test-app-token',
12+
storeFqdn: 'test-store.myshopify.com',
13+
}
14+
15+
const mockQuery: TypedDocumentNode<{shop: {name: string}}, {id: string}> = {} as any
16+
const mockVariables = {id: 'gid://shopify/Shop/123'}
17+
const mockResponse = {shop: {name: 'Test Shop'}}
18+
19+
beforeEach(() => {
20+
vi.mocked(graphqlRequestDoc).mockResolvedValue(mockResponse)
21+
})
22+
23+
test('calls graphqlRequestDoc with correct parameters', async () => {
24+
// When
25+
await adminAsAppRequestDoc({
26+
query: mockQuery,
27+
session: mockSession,
28+
variables: mockVariables,
29+
})
30+
31+
// Then
32+
expect(graphqlRequestDoc).toHaveBeenCalledWith({
33+
query: mockQuery,
34+
token: 'test-app-token',
35+
api: 'Admin',
36+
url: 'https://test-store.myshopify.com/admin/api/unstable/graphql.json',
37+
variables: mockVariables,
38+
})
39+
})
40+
41+
test('returns the response from graphqlRequestDoc', async () => {
42+
// When
43+
const result = await adminAsAppRequestDoc({
44+
query: mockQuery,
45+
session: mockSession,
46+
variables: mockVariables,
47+
})
48+
49+
// Then
50+
expect(result).toEqual(mockResponse)
51+
})
52+
53+
test('works without variables', async () => {
54+
// When
55+
await adminAsAppRequestDoc({
56+
query: mockQuery,
57+
session: mockSession,
58+
})
59+
60+
// Then
61+
expect(graphqlRequestDoc).toHaveBeenCalledWith({
62+
query: mockQuery,
63+
token: 'test-app-token',
64+
api: 'Admin',
65+
url: 'https://test-store.myshopify.com/admin/api/unstable/graphql.json',
66+
variables: undefined,
67+
})
68+
})
69+
})
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import {graphqlRequestDoc} from '@shopify/cli-kit/node/api/graphql'
2+
import {adminUrl} from '@shopify/cli-kit/node/api/admin'
3+
import {AdminSession} from '@shopify/cli-kit/node/session'
4+
import {Variables} from 'graphql-request'
5+
import {TypedDocumentNode} from '@graphql-typed-document-node/core'
6+
7+
/**
8+
* @param query - GraphQL query to execute.
9+
* @param session - Admin session.
10+
* @param variables - GraphQL variables to pass to the query.
11+
*/
12+
interface AdminAsAppRequestOptions<TResult, TVariables extends Variables> {
13+
query: TypedDocumentNode<TResult, TVariables>
14+
session: AdminSession
15+
variables?: TVariables
16+
}
17+
18+
/**
19+
* Sets up the request to the Shopify Admin API, on behalf of the app.
20+
*
21+
* @param session - Admin session.
22+
*/
23+
async function setupAdminAsAppRequest(session: AdminSession) {
24+
const api = 'Admin'
25+
const url = adminUrl(session.storeFqdn, 'unstable')
26+
return {
27+
token: session.token,
28+
api,
29+
url,
30+
}
31+
}
32+
33+
/**
34+
* Executes a GraphQL query against the Shopify Admin API, on behalf of the app. Uses typed documents.
35+
*
36+
* @param options - The options for the request.
37+
* @returns The response of the query of generic type <T>.
38+
*/
39+
export async function adminAsAppRequestDoc<TResult, TVariables extends Variables>(
40+
options: AdminAsAppRequestOptions<TResult, TVariables>,
41+
): Promise<TResult> {
42+
return graphqlRequestDoc<TResult, TVariables>({
43+
query: options.query,
44+
...(await setupAdminAsAppRequest(options.session)),
45+
variables: options.variables,
46+
})
47+
}

packages/cli-kit/src/public/node/session.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
ensureAuthenticatedAdmin,
3+
ensureAuthenticatedAdminAsApp,
34
ensureAuthenticatedAppManagementAndBusinessPlatform,
45
ensureAuthenticatedBusinessPlatform,
56
ensureAuthenticatedPartners,
@@ -8,6 +9,7 @@ import {
89
} from './session.js'
910

1011
import {getPartnersToken} from './environment.js'
12+
import {shopifyFetch} from './http.js'
1113
import {ApplicationToken} from '../../private/node/session/schema.js'
1214
import {ensureAuthenticated, setLastSeenAuthMethod, setLastSeenUserIdAfterAuth} from '../../private/node/session.js'
1315
import {
@@ -29,6 +31,7 @@ vi.mock('../../private/node/session.js')
2931
vi.mock('../../private/node/session/exchange.js')
3032
vi.mock('../../private/node/session/store.js')
3133
vi.mock('./environment.js')
34+
vi.mock('./http.js')
3235

3336
describe('ensureAuthenticatedStorefront', () => {
3437
test('returns only storefront token if success', async () => {
@@ -271,3 +274,48 @@ describe('ensureAuthenticatedAppManagementAndBusinessPlatform', () => {
271274
expect(ensureAuthenticated).not.toHaveBeenCalled()
272275
})
273276
})
277+
278+
describe('ensureAuthenticatedAdminAsApp', () => {
279+
test('returns admin token if success', async () => {
280+
// Given
281+
vi.mocked(shopifyFetch).mockResolvedValueOnce({
282+
status: 200,
283+
json: async () => ({access_token: 'app_access_token'}),
284+
} as any)
285+
286+
// When
287+
const got = await ensureAuthenticatedAdminAsApp('mystore.myshopify.com', 'client123', 'secret456')
288+
289+
// Then
290+
expect(got).toEqual({token: 'app_access_token', storeFqdn: 'mystore.myshopify.com'})
291+
})
292+
293+
test('throws error if app is not installed', async () => {
294+
// Given
295+
vi.mocked(shopifyFetch).mockResolvedValueOnce({
296+
status: 400,
297+
text: async () => 'error: app_not_installed',
298+
} as any)
299+
300+
// When
301+
const got = ensureAuthenticatedAdminAsApp('mystore.myshopify.com', 'client123', 'secret456')
302+
303+
// Then
304+
await expect(got).rejects.toThrow(/App is not installed/)
305+
})
306+
307+
test('throws error on other 400 errors', async () => {
308+
// Given
309+
vi.mocked(shopifyFetch).mockResolvedValueOnce({
310+
status: 400,
311+
statusText: 'Bad Request',
312+
text: async () => 'invalid credentials',
313+
} as any)
314+
315+
// When
316+
const got = ensureAuthenticatedAdminAsApp('mystore.myshopify.com', 'client123', 'secret456')
317+
318+
// Then
319+
await expect(got).rejects.toThrow('Failed to get access token for app client123 on store mystore.myshopify.com')
320+
})
321+
})

packages/cli-kit/src/public/node/session.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import {BugError} from './error.js'
1+
import {AbortError, BugError} from './error.js'
22
import {getPartnersToken} from './environment.js'
33
import {nonRandomUUID} from './crypto.js'
4+
import {shopifyFetch} from './http.js'
45
import * as sessionStore from '../../private/node/session/store.js'
56
import {
67
exchangeCustomPartnerToken,
@@ -281,3 +282,53 @@ ${outputToken.json(scopes)}
281282
export function logout(): Promise<void> {
282283
return sessionStore.remove()
283284
}
285+
286+
/**
287+
* Ensure that we have a valid Admin session for the given store, with access on behalf of the app.
288+
*
289+
* See `ensureAuthenticatedAdmin` for access on behalf of a user.
290+
*
291+
* @param storeFqdn - Store fqdn to request auth for.
292+
* @param clientId - Client ID of the app.
293+
* @param clientSecret - Client secret of the app.
294+
* @returns The access token for the Admin API.
295+
*/
296+
export async function ensureAuthenticatedAdminAsApp(
297+
storeFqdn: string,
298+
clientId: string,
299+
clientSecret: string,
300+
): Promise<AdminSession> {
301+
const bodyData = {
302+
client_id: clientId,
303+
client_secret: clientSecret,
304+
grant_type: 'client_credentials',
305+
}
306+
const tokenResponse = await shopifyFetch(
307+
`https://${storeFqdn}/admin/oauth/access_token`,
308+
{
309+
method: 'POST',
310+
headers: {
311+
'Content-Type': 'application/json',
312+
},
313+
body: JSON.stringify(bodyData),
314+
},
315+
'slow-request',
316+
)
317+
318+
if (tokenResponse.status === 400) {
319+
const body = await tokenResponse.text()
320+
if (body.includes('app_not_installed')) {
321+
throw new AbortError(
322+
outputContent`App is not installed on ${outputToken.green(
323+
storeFqdn,
324+
)}. Try running ${outputToken.genericShellCommand(`shopify app dev`)} to connect your app to the shop.`,
325+
)
326+
}
327+
throw new AbortError(
328+
`Failed to get access token for app ${clientId} on store ${storeFqdn}: ${tokenResponse.statusText}`,
329+
)
330+
}
331+
332+
const tokenJson = (await tokenResponse.json()) as {access_token: string}
333+
return {token: tokenJson.access_token, storeFqdn}
334+
}

0 commit comments

Comments
 (0)