Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bright-auth-skeleton.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/dev-platform-auth': minor
---

Add the `@shopify/dev-platform-auth` package skeleton.
3 changes: 2 additions & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
"@shopify/cli-kit",
"@shopify/theme",
"@shopify/plugin-cloudflare",
"@shopify/plugin-did-you-mean"
"@shopify/plugin-did-you-mean",
"@shopify/dev-platform-auth"
]],
"access": "public",
"baseBranch": "main",
Expand Down
1 change: 1 addition & 0 deletions configurations/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,5 +85,6 @@ export const aliases = (packagePath: string) => {
{find: '@shopify/theme', replacement: path.join(packagePath, '../theme/src/index')},
{find: '@shopify/organizations', replacement: path.join(packagePath, '../organizations/src/index')},
{find: '@shopify/store', replacement: path.join(packagePath, '../store/src/index')},
{find: '@shopify/dev-platform-auth', replacement: path.join(packagePath, '../dev-platform-auth/src/index')},
]
}
11 changes: 11 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,17 @@
]
}
},
"packages/dev-platform-auth": {
"entry": [
"**/index.ts!"
],
"project": "**/*.ts!",
"vite": {
"config": [
"vite.config.ts"
]
}
},
"packages/store": {
"entry": [
"**/{commands,hooks}/**/*.ts!",
Expand Down
1 change: 1 addition & 0 deletions packages/cli-kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
]
},
"dependencies": {
"@shopify/dev-platform-auth": "workspace:*",
"@apidevtools/json-schema-ref-parser": "11.9.3",
"@bugsnag/js": "8.9.0",
"@graphql-typed-document-node/core": "3.2.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import {ensureAuthenticatedAdminAsApp} from './session.js'
import {shopifyFetch} from './http.js'
import {describe, expect, test, vi} from 'vitest'

vi.mock('./http.js')

describe('ensureAuthenticatedAdminAsApp client credentials errors', () => {
test('does not include upstream status text in the error', async () => {
vi.mocked(shopifyFetch).mockResolvedValueOnce({
status: 500,
statusText: 'attacker-controlled upstream detail',
text: async () => JSON.stringify({error: 'invalid_client'}),
} as unknown as Awaited<ReturnType<typeof shopifyFetch>>)

const error = await ensureAuthenticatedAdminAsApp('mystore.myshopify.com', 'client123', 'secret456').catch(
(caught) => caught,
)

expect(error).toBeInstanceOf(Error)
expect((error as Error).message).toBe(
'Failed to get access token for app client123 on store mystore.myshopify.com: HTTP status 500',
)
expect((error as Error).message).not.toContain('attacker-controlled upstream detail')
})
})
55 changes: 23 additions & 32 deletions packages/cli-kit/src/public/node/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
setLastSeenUserIdAfterAuth,
} from '../../private/node/session.js'
import {isThemeAccessSession} from '../../private/node/api/rest.js'
import {createClientCredentialsClient, type AuthFetch} from '@shopify/dev-platform-auth'

/**
* Session Object to access the Admin API, includes the token and the store FQDN.
Expand Down Expand Up @@ -343,47 +344,37 @@ export async function ensureAuthenticatedAdminAsApp(
clientId: string,
clientSecret: string,
): Promise<AdminSession> {
const bodyData = {
client_id: clientId,
client_secret: clientSecret,
grant_type: 'client_credentials',
const fetch: AuthFetch = async (url, init) => {
const response = await shopifyFetch(url, {...init}, 'slow-request')
return {
status: response.status,
text: () => response.text(),
}
}
const tokenResponse = await shopifyFetch(
`https://${storeFqdn}/admin/oauth/access_token`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(bodyData),
},
'slow-request',
)
const result = await createClientCredentialsClient({fetch}).requestToken({
storeFqdn,
clientId,
clientSecret,
})

const body = await tokenResponse.text()
if ('accessToken' in result) return {token: result.accessToken, storeFqdn}

if (tokenResponse.status === 400) {
if (body.includes('app_not_installed')) {
if ('serverCode' in result) {
if (result.serverCode === 'app_not_installed') {
throw new AbortError(
outputContent`App is not installed on ${outputToken.green(
storeFqdn,
)}. Try running ${outputToken.genericShellCommand(`shopify app dev`)} to connect your app to the shop.`,
)
}
throw new AbortError(
`Failed to get access token for app ${clientId} on store ${storeFqdn}: ${tokenResponse.statusText}`,
)
throw new AbortError(clientCredentialsFailureMessage(result.status, storeFqdn, clientId))
}
try {
const tokenJson = JSON.parse(body) as {access_token: string}
return {token: tokenJson.access_token, storeFqdn}
} catch (error) {
if (error instanceof SyntaxError) {
throw new AbortError(
`Received invalid response from admin authentication service (HTTP ${tokenResponse.status}).`,
'The response could not be parsed as JSON. The service may be temporarily unavailable. Please try again.',
)
}
throw error
if (result.kind === 'malformed_response' || result.kind === 'unexpected_status') {
throw new AbortError(clientCredentialsFailureMessage(result.status, storeFqdn, clientId))
}
throw new AbortError(`Failed to get access token for app ${clientId} on store ${storeFqdn}: request failed`)
}

function clientCredentialsFailureMessage(status: number, storeFqdn: string, clientId: string): string {
return `Failed to get access token for app ${clientId} on store ${storeFqdn}: HTTP status ${status}`
}
7 changes: 7 additions & 0 deletions packages/dev-platform-auth/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# @shopify/dev-platform-auth

`@shopify/dev-platform-auth` provides portable Shopify developer auth flows.

This package currently provides the portable client-credentials contract and runtime, including transport types and testing helpers. Identity and Store PKCE flows are out of scope.

The portability contract is ESM on Node.js >=20, with no Node built-ins in the `.` entry. Only `.` and `./testing` are exported.
56 changes: 56 additions & 0 deletions packages/dev-platform-auth/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
{
"name": "@shopify/dev-platform-auth",
"version": "4.6.0",
"packageManager": "pnpm@10.11.1",
"private": false,
"description": "Portable Shopify developer auth flows",
"homepage": "https://github.com/shopify/cli#readme",
"bugs": {
"url": "https://community.shopify.dev/c/shopify-cli-libraries/14"
},
"repository": {
"type": "git",
"url": "https://github.com/Shopify/cli.git",
"directory": "packages/dev-platform-auth"
},
"license": "MIT",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"node": "./dist/index.js"
},
"./testing": {
"types": "./dist/testing/index.d.ts",
"import": "./dist/testing/index.js",
"node": "./dist/testing/index.js"
}
},
"files": ["dist"],
"scripts": {
"build": "nx build",
"clean": "nx clean",
"lint": "nx lint",
"lint:fix": "nx lint:fix",
"type-check": "nx type-check",
"vitest": "vitest"
},
"eslintConfig": {
"extends": ["../../.eslintrc.cjs"]
},
"devDependencies": {
"@types/node": "18.19.130",
"@vitest/coverage-istanbul": "^3.2.7",
"esbuild": "0.28.1"
},
"engines": {
"node": ">=20.10.0"
},
"publishConfig": {
"@shopify:registry": "https://registry.npmjs.org",
"access": "public"
},
"sideEffects": false,
"engine-strict": true
}
46 changes: 46 additions & 0 deletions packages/dev-platform-auth/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{
"name": "dev-platform-auth",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "packages/dev-platform-auth/src",
"projectType": "library",
"tags": ["scope:foundation"],
"targets": {
"clean": {
"executor": "nx:run-commands",
"options": {
"command": "pnpm rimraf dist/",
"cwd": "packages/dev-platform-auth"
}
},
"build": {
"executor": "nx:run-commands",
"outputs": ["{workspaceRoot}/packages/dev-platform-auth/dist"],
"inputs": ["{projectRoot}/src/**/*", "{projectRoot}/package.json", "{projectRoot}/tsconfig.build.json"],
"options": {
"command": "pnpm tsc -b ./tsconfig.build.json",
"cwd": "packages/dev-platform-auth"
}
},
"lint": {
"executor": "nx:run-commands",
"options": {
"command": "pnpm eslint src",
"cwd": "packages/dev-platform-auth"
}
},
"lint:fix": {
"executor": "nx:run-commands",
"options": {
"command": "pnpm eslint src --fix",
"cwd": "packages/dev-platform-auth"
}
},
"type-check": {
"executor": "nx:run-commands",
"options": {
"command": "pnpm tsc --noEmit",
"cwd": "packages/dev-platform-auth"
}
}
}
}
110 changes: 110 additions & 0 deletions packages/dev-platform-auth/src/client-credentials.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import {requestClientCredentialsToken} from './client-credentials.js'
import {describe, expect, test} from 'vitest'
import type {AuthFetch, ClientCredentialsTokenRequest} from './index.js'

const request: ClientCredentialsTokenRequest = {
storeFqdn: 'example.myshopify.com',
clientId: 'client-id',
clientSecret: 'client-secret',
}

const requestWithSignal = request as ClientCredentialsTokenRequest & {signal: unknown}

test('does not expose cancellation on the client-credentials request', () => {
expect('signal' in requestWithSignal).toBe(false)
})

function fetchResponse(status: number, body: string): AuthFetch {
return async () => ({status, text: async () => body})
}

describe('requestClientCredentialsToken', () => {
test('returns the access token and store without fabricated expiry', async () => {
await expect(
requestClientCredentialsToken({fetch: fetchResponse(200, '{"access_token":"token"}')}, request),
).resolves.toEqual({
accessToken: 'token',
storeFqdn: request.storeFqdn,
})
})

test('maps app_not_installed only from a 400 raw response', async () => {
await expect(
requestClientCredentialsToken({fetch: fetchResponse(400, 'app_not_installed')}, request),
).resolves.toEqual({
serverCode: 'app_not_installed',
status: 400,
})
await expect(
requestClientCredentialsToken({fetch: fetchResponse(500, 'app_not_installed')}, request),
).resolves.toMatchObject({
kind: 'malformed_response',
status: 500,
})
})

test.each([200, 400, 500])('classifies malformed JSON as malformed_response for HTTP %s', async (status) => {
await expect(
requestClientCredentialsToken({fetch: fetchResponse(status, 'not-json')}, request),
).resolves.toMatchObject({
kind: 'malformed_response',
status,
})
})

test.each([
[500, true, 'server_error'],
[200, false, 'token'],
] as const)('uses numeric status rather than ok (%s/%s)', async (status, ok, expected) => {
const fetch: AuthFetch = async () => ({ok, status, text: async () => '{"access_token":"token"}'})
const result = await requestClientCredentialsToken({fetch}, request)
if (expected === 'token') {
expect(result).toEqual({accessToken: 'token', storeFqdn: request.storeFqdn})
} else {
expect(result).toEqual({serverCode: 'unknown_error', status})
expect(result).not.toHaveProperty('accessToken')
}
})

test('rejects successful JSON without a non-empty access token', async () => {
await expect(requestClientCredentialsToken({fetch: fetchResponse(200, '{}')}, request)).resolves.toEqual({
kind: 'malformed_response',
status: 200,
})
await expect(
requestClientCredentialsToken({fetch: fetchResponse(200, '{"access_token":""}')}, request),
).resolves.toEqual({
kind: 'malformed_response',
status: 200,
})
})

test('classifies an unexpected non-JSON error shape as unexpected_status', async () => {
await expect(requestClientCredentialsToken({fetch: fetchResponse(500, '"unexpected"')}, request)).resolves.toEqual({
kind: 'unexpected_status',
status: 500,
})
})

test.each([
['unknown code', '{"error":"not_a_known_code"}'],
['missing code', '{}'],
['empty code', '{"error":""}'],
])('uses a safe server code for %s', async (_name, body) => {
await expect(requestClientCredentialsToken({fetch: fetchResponse(400, body)}, request)).resolves.toMatchObject({
serverCode: body === '{"error":"not_a_known_code"}' ? 'not_a_known_code' : 'unknown_error',
status: 400,
})
})

test('classifies a fetch failure as transport_failed without leaking its cause', async () => {
const fetch: AuthFetch = async () => {
throw new Error('upstream secret')
}
await expect(requestClientCredentialsToken({fetch}, request)).resolves.toMatchObject({kind: 'transport_failed'})
})

test('classifies a missing fetch as transport_failed', async () => {
await expect(requestClientCredentialsToken({}, request)).resolves.toMatchObject({kind: 'transport_failed'})
})
})
Loading
Loading