From 46400688f0136abdfc224d91c11268f6a1aa33d7 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Sat, 8 Aug 2026 12:48:50 -0400 Subject: [PATCH 1/9] Add dev platform auth package skeleton Assisted-By: devx/b95434f1-5993-47b3-b56c-380241eff68f --- .changeset/bright-auth-skeleton.md | 5 ++ .changeset/config.json | 3 +- package.json | 11 ++++ packages/dev-platform-auth/README.md | 7 +++ packages/dev-platform-auth/package.json | 56 +++++++++++++++++++ packages/dev-platform-auth/project.json | 46 +++++++++++++++ packages/dev-platform-auth/src/index.ts | 3 + .../dev-platform-auth/src/portability.test.ts | 38 +++++++++++++ .../dev-platform-auth/src/testing/index.ts | 1 + packages/dev-platform-auth/tests/consumer.mjs | 5 ++ .../dev-platform-auth/tsconfig.build.json | 4 ++ packages/dev-platform-auth/tsconfig.json | 14 +++++ packages/dev-platform-auth/vite.config.ts | 3 + pnpm-lock.yaml | 12 ++++ 14 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 .changeset/bright-auth-skeleton.md create mode 100644 packages/dev-platform-auth/README.md create mode 100644 packages/dev-platform-auth/package.json create mode 100644 packages/dev-platform-auth/project.json create mode 100644 packages/dev-platform-auth/src/index.ts create mode 100644 packages/dev-platform-auth/src/portability.test.ts create mode 100644 packages/dev-platform-auth/src/testing/index.ts create mode 100644 packages/dev-platform-auth/tests/consumer.mjs create mode 100644 packages/dev-platform-auth/tsconfig.build.json create mode 100644 packages/dev-platform-auth/tsconfig.json create mode 100644 packages/dev-platform-auth/vite.config.ts diff --git a/.changeset/bright-auth-skeleton.md b/.changeset/bright-auth-skeleton.md new file mode 100644 index 00000000000..57af0687d26 --- /dev/null +++ b/.changeset/bright-auth-skeleton.md @@ -0,0 +1,5 @@ +--- +'@shopify/dev-platform-auth': minor +--- + +Add the `@shopify/dev-platform-auth` package skeleton. diff --git a/.changeset/config.json b/.changeset/config.json index d12fe1e0daa..607e96235eb 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -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", diff --git a/package.json b/package.json index fb7d604dcb4..49548951acb 100644 --- a/package.json +++ b/package.json @@ -248,6 +248,17 @@ ] } }, + "packages/dev-platform-auth": { + "entry": [ + "**/index.ts!" + ], + "project": "**/*.ts!", + "vite": { + "config": [ + "vite.config.ts" + ] + } + }, "packages/store": { "entry": [ "**/{commands,hooks}/**/*.ts!", diff --git a/packages/dev-platform-auth/README.md b/packages/dev-platform-auth/README.md new file mode 100644 index 00000000000..a4187459040 --- /dev/null +++ b/packages/dev-platform-auth/README.md @@ -0,0 +1,7 @@ +# @shopify/dev-platform-auth + +`@shopify/dev-platform-auth` provides portable Shopify developer auth flows. + +This package is currently a skeleton. Identity device flow and `client_credentials` flow modules will arrive in subsequent pull requests. + +The portability contract is ESM on Node.js >=20, with no Node built-ins in the `.` entry. Only `.` and `./testing` are exported. diff --git a/packages/dev-platform-auth/package.json b/packages/dev-platform-auth/package.json new file mode 100644 index 00000000000..fff33fd9139 --- /dev/null +++ b/packages/dev-platform-auth/package.json @@ -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 +} diff --git a/packages/dev-platform-auth/project.json b/packages/dev-platform-auth/project.json new file mode 100644 index 00000000000..4c17ed8d9f4 --- /dev/null +++ b/packages/dev-platform-auth/project.json @@ -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" + } + } + } +} diff --git a/packages/dev-platform-auth/src/index.ts b/packages/dev-platform-auth/src/index.ts new file mode 100644 index 00000000000..0cd68764c53 --- /dev/null +++ b/packages/dev-platform-auth/src/index.ts @@ -0,0 +1,3 @@ +export const PACKAGE_NAME = '@shopify/dev-platform-auth' + +export type AuthFlowPlaceholder = 'skeleton' diff --git a/packages/dev-platform-auth/src/portability.test.ts b/packages/dev-platform-auth/src/portability.test.ts new file mode 100644 index 00000000000..747fd3e2c6b --- /dev/null +++ b/packages/dev-platform-auth/src/portability.test.ts @@ -0,0 +1,38 @@ +/* eslint-disable no-restricted-imports, import-x/order */ +import {execFileSync} from 'node:child_process' +import {readFileSync} from 'node:fs' +import {resolve} from 'node:path' +import {build} from 'esbuild' +import {describe, expect, test} from 'vitest' + +const packageRoot = resolve(__dirname, '..') +const entry = resolve(packageRoot, 'dist/index.js') + +function buildPackage() { + execFileSync('pnpm', ['exec', 'nx', 'build', 'dev-platform-auth'], { + cwd: resolve(packageRoot, '../..'), + stdio: 'ignore', + }) +} + +describe('package portability', () => { + test('can be consumed by plain JavaScript', () => { + buildPackage() + execFileSync(process.execPath, [resolve(packageRoot, 'tests/consumer.mjs')], {stdio: 'pipe'}) + }) + + test('does not include Node built-ins or CommonJS in the portable entry', () => { + buildPackage() + const output = readFileSync(entry, 'utf8') + const nodeBuiltins = + /(?:node:)?(?:assert|buffer|child_process|cluster|crypto|dgram|dns|events|fs|http|https|module|net|os|path|perf_hooks|process|querystring|readline|stream|string_decoder|timers|tls|tty|url|util|v8|vm|worker_threads|zlib)/ + + expect(output).not.toMatch(nodeBuiltins) + expect(output).not.toContain('require(') + }) + + test('bundles for browsers without external dependencies', async () => { + buildPackage() + await expect(build({entryPoints: [entry], bundle: true, platform: 'browser', write: false})).resolves.toBeDefined() + }) +}) diff --git a/packages/dev-platform-auth/src/testing/index.ts b/packages/dev-platform-auth/src/testing/index.ts new file mode 100644 index 00000000000..ce50e735361 --- /dev/null +++ b/packages/dev-platform-auth/src/testing/index.ts @@ -0,0 +1 @@ +export const TESTING_PACKAGE_NAME = '@shopify/dev-platform-auth/testing' diff --git a/packages/dev-platform-auth/tests/consumer.mjs b/packages/dev-platform-auth/tests/consumer.mjs new file mode 100644 index 00000000000..f55476d8927 --- /dev/null +++ b/packages/dev-platform-auth/tests/consumer.mjs @@ -0,0 +1,5 @@ +import {PACKAGE_NAME} from '../dist/index.js' + +if (PACKAGE_NAME !== '@shopify/dev-platform-auth') { + throw new Error(`Unexpected package name: ${PACKAGE_NAME}`) +} diff --git a/packages/dev-platform-auth/tsconfig.build.json b/packages/dev-platform-auth/tsconfig.build.json new file mode 100644 index 00000000000..d472b2c2aa0 --- /dev/null +++ b/packages/dev-platform-auth/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["**/*.test.ts"] +} diff --git a/packages/dev-platform-auth/tsconfig.json b/packages/dev-platform-auth/tsconfig.json new file mode 100644 index 00000000000..3d7207d293a --- /dev/null +++ b/packages/dev-platform-auth/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../configurations/tsconfig.json", + "include": ["./src/**/*.ts"], + "exclude": ["./dist"], + "compilerOptions": { + "lib": ["ES2022"], + "target": "ES2022", + "outDir": "dist", + "rootDir": "src", + "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo", + "types": ["node", "vitest/importMeta"] + }, + "references": [] +} diff --git a/packages/dev-platform-auth/vite.config.ts b/packages/dev-platform-auth/vite.config.ts new file mode 100644 index 00000000000..9536586ca45 --- /dev/null +++ b/packages/dev-platform-auth/vite.config.ts @@ -0,0 +1,3 @@ +import config from '../../configurations/vite.config' + +export default config(__dirname) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac9dc0316e9..b4a199c139a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -526,6 +526,18 @@ importers: specifier: ^2.1.1 version: 2.1.1(esbuild@0.28.1) + packages/dev-platform-auth: + devDependencies: + '@types/node': + specifier: 18.19.130 + version: 18.19.130 + '@vitest/coverage-istanbul': + specifier: ^3.2.7 + version: 3.2.7(vitest@4.1.10) + esbuild: + specifier: 0.28.1 + version: 0.28.1 + packages/e2e: devDependencies: '@iarna/toml': From 5518be68a4ec30f1ee88c723dba98e912943c0df Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Wed, 12 Aug 2026 12:40:04 -0400 Subject: [PATCH 2/9] Define portable auth contract prototype surface Assisted-By: devx/17376415-2e96-4ea0-9057-0d8f9bfa20da --- packages/dev-platform-auth/README.md | 2 +- packages/dev-platform-auth/src/index.ts | 152 +++++++++++++++++++++++- 2 files changed, 152 insertions(+), 2 deletions(-) diff --git a/packages/dev-platform-auth/README.md b/packages/dev-platform-auth/README.md index a4187459040..fc1d5d2cc2f 100644 --- a/packages/dev-platform-auth/README.md +++ b/packages/dev-platform-auth/README.md @@ -2,6 +2,6 @@ `@shopify/dev-platform-auth` provides portable Shopify developer auth flows. -This package is currently a skeleton. Identity device flow and `client_credentials` flow modules will arrive in subsequent pull requests. +This package is currently a contract prototype. It defines the shared protocol types and `AuthProtocolError`; Identity device flow, `client_credentials`, and Store PKCE runtime modules will arrive in subsequent pull requests. The portability contract is ESM on Node.js >=20, with no Node built-ins in the `.` entry. Only `.` and `./testing` are exported. diff --git a/packages/dev-platform-auth/src/index.ts b/packages/dev-platform-auth/src/index.ts index 0cd68764c53..a5afd6542d7 100644 --- a/packages/dev-platform-auth/src/index.ts +++ b/packages/dev-platform-auth/src/index.ts @@ -1,3 +1,153 @@ export const PACKAGE_NAME = '@shopify/dev-platform-auth' -export type AuthFlowPlaceholder = 'skeleton' +/** Epoch-millisecond credentials shared by auth adapters. */ +export interface IdentityToken { + accessToken: string + refreshToken: string + expiresAt: number + scopes: string[] + userId: string + alias?: string +} + +export interface ApplicationToken { + accessToken: string + expiresAt: number + scopes: string[] + storeFqdn?: string +} + +export interface Session { + identity: IdentityToken + applications: Record +} + +export type ShopifyApplication = 'admin' | 'partners' | 'storefront-renderer' | 'business-platform' | 'app-management' + +export interface IdentityClientConfig { + identityOrigin: string + clientId: string + applicationIds: Record + fetch?: AuthFetch + clock?: Clock + logger?: AuthLogger +} + +export interface AuthFetchResponse { + ok: boolean + status: number + text(): Promise +} + +export interface AuthSignal { + readonly aborted: boolean + addEventListener(type: 'abort', listener: () => void, options?: {once?: boolean}): void + removeEventListener(type: 'abort', listener: () => void): void +} + +export type AuthFetch = ( + url: string, + init: { + method: string + headers: Record + body?: string + signal?: AuthSignal + }, +) => Promise + +export interface Clock { + now(): number + sleep?(milliseconds: number, signal?: AuthSignal): Promise +} + +export type AuthLogEvent = + | { + event: 'device_authorization_started' | 'device_authorization_completed' + application?: ShopifyApplication + } + | { + event: 'device_authorization_pending' | 'device_authorization_slow_down' + application?: ShopifyApplication + } + | { + event: 'auth_protocol_error' + code: AuthErrorCode + status?: number + } + +export interface AuthLogger { + debug(event: AuthLogEvent): void +} + +export interface DeviceAuthorization { + userCode: string + verificationUri: string + verificationUriComplete?: string + expiresIn: number + interval?: number +} + +export type DeviceCodeExchangeResult = + | {status: 'complete'; token: IdentityToken} + | {status: 'pending'} + | {status: 'slow_down'} + +export interface BaseApplicationTokenRequest { + identityToken: string + scopes: string[] + signal?: AuthSignal +} + +export type ApplicationTokenRequest = + | (BaseApplicationTokenRequest & { + application: 'admin' + storeFqdn: string + }) + | (BaseApplicationTokenRequest & { + application: Exclude + storeFqdn?: never + }) + +export interface IdentityClient { + requestDeviceAuthorization(options: {scopes: string[]; signal?: AuthSignal}): Promise + exchangeDeviceCode(options: {deviceCode: string; signal?: AuthSignal}): Promise + refreshIdentityToken(options: {token: IdentityToken; signal?: AuthSignal}): Promise + exchangeApplicationToken(request: ApplicationTokenRequest): Promise +} + +export interface ClientCredentialsTokenRequest { + storeFqdn: string + clientId: string + clientSecret: string + signal?: AuthSignal +} + +export interface ClientCredentialsClient { + requestToken(options: ClientCredentialsTokenRequest): Promise +} + +export type AuthErrorCode = + | 'invalid_grant' + | 'invalid_request' + | 'invalid_target' + | 'access_denied' + | 'expired_token' + | 'invalid_response' + | 'app_not_installed' + | 'unknown' + +export interface AuthProtocolErrorOptions { + status?: number +} + +export class AuthProtocolError extends Error { + readonly code: AuthErrorCode + readonly status?: number + + constructor(code: AuthErrorCode, options: AuthProtocolErrorOptions = {}) { + super(code) + this.name = 'AuthProtocolError' + this.code = code + this.status = options.status + } +} From b2ccb502205e573f120dde8177583e23948638d5 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Wed, 12 Aug 2026 14:56:09 -0400 Subject: [PATCH 3/9] Separate identity and access contracts Assisted-By: devx/17376415-2e96-4ea0-9057-0d8f9bfa20da --- packages/dev-platform-auth/README.md | 2 +- packages/dev-platform-auth/src/index.ts | 17 ++++++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/dev-platform-auth/README.md b/packages/dev-platform-auth/README.md index fc1d5d2cc2f..c4d5243fb63 100644 --- a/packages/dev-platform-auth/README.md +++ b/packages/dev-platform-auth/README.md @@ -2,6 +2,6 @@ `@shopify/dev-platform-auth` provides portable Shopify developer auth flows. -This package is currently a contract prototype. It defines the shared protocol types and `AuthProtocolError`; Identity device flow, `client_credentials`, and Store PKCE runtime modules will arrive in subsequent pull requests. +This package is currently a contract prototype. It defines separate Identity credential and application-access contracts plus `AuthProtocolError`; Identity device flow, `client_credentials`, and Store PKCE runtime modules will arrive in subsequent pull requests. The portability contract is ESM on Node.js >=20, with no Node built-ins in the `.` entry. Only `.` and `./testing` are exported. diff --git a/packages/dev-platform-auth/src/index.ts b/packages/dev-platform-auth/src/index.ts index a5afd6542d7..78b66e57082 100644 --- a/packages/dev-platform-auth/src/index.ts +++ b/packages/dev-platform-auth/src/index.ts @@ -17,22 +17,22 @@ export interface ApplicationToken { storeFqdn?: string } -export interface Session { - identity: IdentityToken - applications: Record -} - export type ShopifyApplication = 'admin' | 'partners' | 'storefront-renderer' | 'business-platform' | 'app-management' -export interface IdentityClientConfig { +export interface AuthClientConfig { identityOrigin: string clientId: string - applicationIds: Record fetch?: AuthFetch clock?: Clock logger?: AuthLogger } +export interface IdentityClientConfig extends AuthClientConfig {} + +export interface ApplicationAccessConfig extends AuthClientConfig { + applicationIds: Record +} + export interface AuthFetchResponse { ok: boolean status: number @@ -112,6 +112,9 @@ export interface IdentityClient { requestDeviceAuthorization(options: {scopes: string[]; signal?: AuthSignal}): Promise exchangeDeviceCode(options: {deviceCode: string; signal?: AuthSignal}): Promise refreshIdentityToken(options: {token: IdentityToken; signal?: AuthSignal}): Promise +} + +export interface ApplicationAccessClient { exchangeApplicationToken(request: ApplicationTokenRequest): Promise } From 62a7de59ad8e75f335ea456211ef221907e1d29d Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Thu, 13 Aug 2026 13:10:02 -0400 Subject: [PATCH 4/9] Add auth contract fixtures and transport harness Two layers. Layer 1 pins transport request shape plus cited response structure and status; fixture values are arbitrary fakes. Layer 2 holds outcome mapping as explicitly provisional, because the package error taxonomy and cancellation model are undecided. 12 cases, 28 harness self-tests. Assisted-By: devx/bac322b8-a005-4d5c-96f3-fbfeaf201e7f --- .../dev-platform-auth/src/testing/README.md | 38 +++ .../dev-platform-auth/src/testing/fixtures.ts | 302 ++++++++++++++++++ .../src/testing/harness.test.ts | 121 +++++++ .../dev-platform-auth/src/testing/harness.ts | 47 +++ .../dev-platform-auth/src/testing/index.ts | 10 + 5 files changed, 518 insertions(+) create mode 100644 packages/dev-platform-auth/src/testing/README.md create mode 100644 packages/dev-platform-auth/src/testing/fixtures.ts create mode 100644 packages/dev-platform-auth/src/testing/harness.test.ts create mode 100644 packages/dev-platform-auth/src/testing/harness.ts diff --git a/packages/dev-platform-auth/src/testing/README.md b/packages/dev-platform-auth/src/testing/README.md new file mode 100644 index 00000000000..42b1c39b628 --- /dev/null +++ b/packages/dev-platform-auth/src/testing/README.md @@ -0,0 +1,38 @@ +# Auth contract fixtures + +These fixtures are an independent transport contract for developer authentication. They have two layers: + +- **Layer 1 — pinned transport:** `request`, `responses`, and `transportCitation`. These pin the cited response structure, HTTP status, and content type. Response values are arbitrary, obviously fake fixture data unless the citation states otherwise; values such as `599` and `fixture-access-token` are not captured production facts. A citation is required for every fixture. The transport harness rejects calls after the declared response sequence, so polling count remains observable. Do not change Layer 1 cases to make a port pass. +- **Layer 2 — provisional outcomes:** `expected` and `provisionalOutcome`. These describe possible package conclusions and open questions. They are not a frozen error taxonomy. Layer 2 is intentionally open and changeable: propose a decision with characterization evidence and explicit review rather than silently changing it. + +Cancellation fields (`required`, `optional`, and `absent`) are Layer 2 design notes. No cli-kit auth function accepts a cancellation signal, so they do not define a current equivalence contract. Fixed expiry outcomes state the injected clock in their provisional note. Dynamic server fields such as `expires_in` are checked for presence and type, not pinned to an observed number. + +The fixtures use fake values only. `createFixtureFetch` has no HTTP or Node runtime dependency, so CLI and app SDK adapters can use the same data. + +## Resolving citations + +Citations name a file and line range but not a repository. Resolve them against these roots, or they will appear not to exist: + +| Citation looks like | Repository | Root to resolve against | +| --- | --- | --- | +| `packages/cli-kit/...` | `Shopify/cli` | repository root | +| `app/...`, `lib/rack/...`, `config/...`, `db/schema.rb` | `shopify/identity` | **`areas/platforms/identity/`**, not the repository root | +| `spec/...` | `shopify/identity` | `areas/platforms/identity/` | +| `*.test.ts` described as oracle | `Shopify/cli`, branch `donald/auth-characterization-tests` | repository root | + +Identity evidence was read at revision `b0a0a25efe32489cc4e24541c4a3bd653b262f97`; the characterization oracle at head `b95944ae09`. Line numbers drift, so confirm the quoted behavior rather than trusting the range. An Identity path checked from the repository root instead of `areas/platforms/identity/` will look fabricated when it is not. + +## Coverage gaps + +The 12 cases are not a complete oracle. The following Identity/cli-kit behavior is intentionally not covered: + +- Device authorization missing `verification_uri_complete` and its `BugError`. +- Malformed, empty, HTML, and other non-JSON responses. +- CI/noninteractive abort behavior. +- Unknown device errors mapping to `unknown_failure`. +- Device success expiry, scopes, and user ID from a valid JWT. +- The full refresh error family: `invalid_grant`, `invalid_request`, and `invalid_target`. +- All non-admin token-exchange audiences and their exact scopes. +- Client-credentials `app_not_installed` and non-JSON handling. + +Where server reality and cli-kit behavior differ, the fixture keeps the cli-kit equivalence behavior as the provisional expectation and records the divergence. In particular, Identity's `slow_down` response has no `interval`; cli-kit still applies a fixed +5-second client-policy increment. diff --git a/packages/dev-platform-auth/src/testing/fixtures.ts b/packages/dev-platform-auth/src/testing/fixtures.ts new file mode 100644 index 00000000000..24bea946518 --- /dev/null +++ b/packages/dev-platform-auth/src/testing/fixtures.ts @@ -0,0 +1,302 @@ +import type {AuthErrorCode, AuthFetchResponse, AuthSignal} from '../index.js' + +export const fixtureOrigin = 'https://identity.example.test' +export const fakeClientId = 'fixture-client-id' + +export type FixtureOperation = + | 'device_authorization' + | 'device_code_poll' + | 'refresh_token' + | 'token_exchange' + | 'client_credentials' + +export type FixtureExpected = + | {kind: 'result'; value: Record} + | {kind: 'error'; code: AuthErrorCode | 'authorization_pending' | 'slow_down' | 'unknown_failure'; status?: number} + +export interface AuthFixtureRequest { + method: 'POST' + url: string + headers: Record + body: string +} + +export interface AuthFixtureResponse { + status: number + body: string +} + +export interface AuthFixture { + readonly name: string + readonly operation: FixtureOperation + readonly request: AuthFixtureRequest + readonly responses: ReadonlyArray + /** Layer 2 only: the package conclusion is not a frozen contract. */ + readonly expected?: FixtureExpected + /** Layer 2 only: cancellation is not accepted by cli-kit auth functions. */ + readonly signalExpectation: 'required' | 'optional' | 'absent' + /** Layer 1 citation for the pinned request and raw response. */ + readonly transportCitation: string + /** Layer 2 rationale/open question. */ + readonly provisionalOutcome?: string + readonly redactedValues?: ReadonlyArray + readonly privateInputs?: ReadonlyArray<'device_code' | 'refresh_token' | 'client_secret' | 'subject_token'> +} + +const json = (body: unknown, status = 200): AuthFixtureResponse => ({status, body: JSON.stringify(body)}) +const formHeaders = {'Content-Type': 'application/x-www-form-urlencoded'} +const fakeIdentityResponse = { + access_token: 'fixture-access-token', + refresh_token: 'fixture-refresh-token', + token_type: 'Bearer', + scope: 'read_products write_products', + expires_in: 7200, + id_token: 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJmaXh0dXJlLXVzZXItMTIzNCJ9.Zml4dHVyZS1zaWduYXR1cmUtbm90LXZlcmlmaWVk', +} + +const deviceAuthorizationResponse = { + device_code: 'fixture-device-code', + user_code: 'fixture-user-code', + verification_uri: 'https://identity.example.test/verify', + verification_uri_complete: 'https://identity.example.test/verify?code=fixture-user-code', + expires_in: 599, + interval: 5, +} + +export const authFixtures: ReadonlyArray = [ + { + name: 'device authorization start with scopes', + operation: 'device_authorization', + request: { + method: 'POST', + url: `${fixtureOrigin}/oauth/device_authorization`, + headers: {'Content-type': formHeaders['Content-Type']}, + body: 'client_id=fixture-client-id&scope=read_products+write_products', + }, + signalExpectation: 'optional', + responses: [json(deviceAuthorizationResponse)], + expected: { + kind: 'result', + value: { + userCode: 'fixture-user-code', + verificationUri: 'https://identity.example.test/verify', + verificationUriComplete: 'https://identity.example.test/verify?code=fixture-user-code', + interval: 5, + }, + }, + transportCitation: + 'Identity app/operations/oauth/build_device_authorization_request_info.rb:20-27; live production capture', + provisionalOutcome: + 'Whether adapters expose all response fields is undecided; expires_in is dynamic and this fixture value is an arbitrary fake; signal handling is also provisional.', + redactedValues: ['fixture-device-code', 'fixture-user-code'], + privateInputs: ['device_code'], + }, + { + name: 'device authorization start omits empty scope', + operation: 'device_authorization', + request: { + method: 'POST', + url: `${fixtureOrigin}/oauth/device_authorization`, + headers: {'Content-type': formHeaders['Content-Type']}, + body: 'client_id=fixture-client-id', + }, + signalExpectation: 'absent', + responses: [json(deviceAuthorizationResponse)], + expected: { + kind: 'result', + value: { + userCode: 'fixture-user-code', + verificationUri: 'https://identity.example.test/verify', + verificationUriComplete: 'https://identity.example.test/verify?code=fixture-user-code', + interval: 5, + }, + }, + transportCitation: + 'Identity app/operations/oauth/build_device_authorization_request_info.rb:20-27; live production capture', + provisionalOutcome: + 'Empty-scope request and cancellation behavior require an adapter decision; expires_in is dynamic and this fixture value is an arbitrary fake.', + }, + { + name: 'device authorization malformed success', + operation: 'device_authorization', + request: { + method: 'POST', + url: `${fixtureOrigin}/oauth/device_authorization`, + headers: {'Content-type': formHeaders['Content-Type']}, + body: 'client_id=fixture-client-id', + }, + signalExpectation: 'absent', + responses: [json({user_code: 'fixture-user-code'})], + transportCitation: + 'cli-kit packages/cli-kit/src/private/node/session/device-authorization.ts:57-60; characterization oracle packages/cli-kit/src/private/node/session/device-authorization.test.ts:224-230', + provisionalOutcome: + 'Open question: cli-kit throws BugError for missing device fields; do not map this to AuthProtocolError.', + }, + { + name: 'device poll pending then success', + operation: 'device_code_poll', + request: { + method: 'POST', + url: `${fixtureOrigin}/oauth/token`, + headers: formHeaders, + body: 'grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code&device_code=fixture-device-code&client_id=fixture-client-id', + }, + signalExpectation: 'required', + responses: [ + json({error: 'authorization_pending', error_description: 'The authorization request is still pending.'}, 400), + json(fakeIdentityResponse), + ], + expected: {kind: 'result', value: {status: 'complete'}}, + transportCitation: + 'Identity lib/rack/oauth2/server/token/extension/device_code.rb:35-64; Identity app/lib/token_server.rb:69-100; live production capture', + provisionalOutcome: 'Polling and token mapping are provisional; cli-kit polls pending then decodes id_token.', + redactedValues: ['fixture-device-code'], + privateInputs: ['device_code'], + }, + { + name: 'device poll slow down', + operation: 'device_code_poll', + request: { + method: 'POST', + url: `${fixtureOrigin}/oauth/token`, + headers: formHeaders, + body: 'grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code&device_code=fixture-device-code&client_id=fixture-client-id', + }, + signalExpectation: 'absent', + responses: [json({error: 'slow_down', error_description: 'Polling too frequently.'}, 400)], + expected: {kind: 'error', code: 'slow_down', status: 400}, + transportCitation: 'Identity lib/rack/oauth2/server/token/extension/device_code.rb:35-64; live production capture', + provisionalOutcome: + 'The response has no interval. cli-kit applies a fixed client-policy +5 seconds (packages/cli-kit/src/private/node/session/device-authorization.ts:129-134); this is not a server instruction. Signal behavior is provisional.', + }, + { + name: 'device poll access denied (defensive only)', + operation: 'device_code_poll', + request: { + method: 'POST', + url: `${fixtureOrigin}/oauth/token`, + headers: formHeaders, + body: 'grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code&device_code=fixture-device-code&client_id=fixture-client-id', + }, + signalExpectation: 'absent', + responses: [json({error: 'access_denied', error_description: 'Access denied.'}, 400)], + expected: {kind: 'error', code: 'access_denied', status: 400}, + transportCitation: + 'Identity lib/rack/oauth2/server/token/extension/device_code.rb:38-63 defines authorization_pending, slow_down, and expired_token only', + provisionalOutcome: + 'No known device-flow path emits access_denied; this fixture retains it as a defensive cli-kit mapping. A generic access_denied from another token-endpoint layer is not ruled out.', + }, + { + name: 'device poll expired token', + operation: 'device_code_poll', + request: { + method: 'POST', + url: `${fixtureOrigin}/oauth/token`, + headers: formHeaders, + body: 'grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code&device_code=fixture-device-code&client_id=fixture-client-id', + }, + signalExpectation: 'absent', + responses: [json({error: 'expired_token', error_description: 'The device code has expired.'}, 400)], + expected: {kind: 'error', code: 'expired_token', status: 400}, + transportCitation: 'Identity lib/rack/oauth2/server/token/extension/device_code.rb:35-64; live production capture', + provisionalOutcome: 'Error taxonomy and cancellation behavior remain provisional.', + }, + { + name: 'refresh preserves identity metadata', + operation: 'refresh_token', + request: { + method: 'POST', + url: `${fixtureOrigin}/oauth/token`, + headers: formHeaders, + body: 'grant_type=refresh_token&access_token=fixture-current-access&refresh_token=fixture-current-refresh&client_id=fixture-client-id', + }, + signalExpectation: 'optional', + responses: [json(fakeIdentityResponse)], + expected: {kind: 'result', value: {expiresAt: 1700003600000, userId: 'fixture-user-id', alias: 'fixture-alias'}}, + transportCitation: 'Identity app/lib/token_server.rb:69-100; live production capture', + provisionalOutcome: 'Fixed expiry uses an injected clock of 1700000000000 ms. Metadata mapping is provisional.', + redactedValues: ['fixture-current-access', 'fixture-current-refresh'], + privateInputs: ['refresh_token'], + }, + { + name: 'refresh response omits refresh token', + operation: 'refresh_token', + request: { + method: 'POST', + url: `${fixtureOrigin}/oauth/token`, + headers: formHeaders, + body: 'grant_type=refresh_token&access_token=fixture-current-access&refresh_token=fixture-current-refresh&client_id=fixture-client-id', + }, + signalExpectation: 'absent', + responses: [json({access_token: 'fixture-new-access', scope: 'read_products', expires_in: 300})], + transportCitation: 'Identity app/lib/token_grants/refresh.rb:111-138 (refresh-token selection conditional)', + provisionalOutcome: + 'Open question: cli-kit does not validate refresh_token presence, and Identity may omit it. Tolerant handling is not a frozen error mapping.', + redactedValues: ['fixture-current-access', 'fixture-current-refresh'], + privateInputs: ['refresh_token'], + }, + { + name: 'admin token exchange uses destination and store-qualified key', + operation: 'token_exchange', + request: { + method: 'POST', + url: `${fixtureOrigin}/oauth/token`, + headers: formHeaders, + body: 'grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&client_id=fixture-client-id&audience=admin&scope=read_products&subject_token=fixture-identity-access&destination=https%3A%2F%2Fshop.example.test%2Fadmin&store=shop.example.test', + }, + signalExpectation: 'absent', + responses: [json({access_token: 'fixture-app-access', scope: 'read_products', expires_in: 300})], + expected: {kind: 'result', value: {key: 'shop.example.test-admin'}}, + transportCitation: + 'cli-kit packages/cli-kit/src/private/node/session/exchange.ts:182-197 (admin destination and store-qualified key); Identity app/lib/token_grants/token_exchange.rb:59-71,82-88; Identity lib/rack/oauth2/server/token/extension/token_exchange.rb:23-43', + provisionalOutcome: + 'Admin result shape is provisional; invalid_target is token-exchange-only, not a generic refresh error.', + redactedValues: ['fixture-identity-access'], + privateInputs: ['subject_token'], + }, + { + name: 'client credentials request', + operation: 'client_credentials', + request: { + method: 'POST', + url: 'https://shop.example.test/admin/oauth/access_token', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + client_id: 'fixture-app-id', + client_secret: 'fixture-app-secret', + grant_type: 'client_credentials', + }), + }, + signalExpectation: 'optional', + responses: [json({access_token: 'fixture-client-access'})], + expected: {kind: 'result', value: {expiresAt: 1700000300000}}, + transportCitation: 'cli-kit packages/cli-kit/src/public/node/session.ts:339-381', + provisionalOutcome: 'Fixed expiry uses an injected clock of 1700000000000 ms. Error handling remains provisional.', + redactedValues: ['fixture-app-secret'], + privateInputs: ['client_secret'], + }, + { + name: 'malformed OAuth error', + operation: 'token_exchange', + request: { + method: 'POST', + url: `${fixtureOrigin}/oauth/token`, + headers: formHeaders, + body: 'grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&subject_token=fixture-identity-access', + }, + signalExpectation: 'absent', + responses: [json({error_description: 'fixture failure'}, 400)], + transportCitation: + 'cli-kit packages/cli-kit/src/private/node/session/exchange.ts:262-267; characterization oracle packages/cli-kit/src/private/node/session/exchange.test.ts:412-425,465-471', + provisionalOutcome: + 'Open question: cli-kit normalizes missing error to unknown_error and exchange throws AbortError; do not freeze invalid_response.', + redactedValues: ['fixture-identity-access'], + privateInputs: ['subject_token'], + }, +] + +export function isAuthSignal(value: unknown): value is AuthSignal { + return typeof value === 'object' && value !== null && 'aborted' in value && 'addEventListener' in value +} + +export type FixtureResponse = AuthFetchResponse diff --git a/packages/dev-platform-auth/src/testing/harness.test.ts b/packages/dev-platform-auth/src/testing/harness.test.ts new file mode 100644 index 00000000000..cc9fab7d73b --- /dev/null +++ b/packages/dev-platform-auth/src/testing/harness.test.ts @@ -0,0 +1,121 @@ +import {authFixtures} from './fixtures.js' +import {createFixtureFetch} from './harness.js' +import {describe, expect, test} from 'vitest' +import type {AuthSignal} from '../index.js' +import type {AuthFixture} from './fixtures.js' + +const signal: AuthSignal = { + aborted: false, + addEventListener: () => undefined, + removeEventListener: () => undefined, +} + +async function requestFixture( + fixture: AuthFixture, + request: Omit & {method: string} = fixture.request, + withSignal = false, +) { + const fetch = createFixtureFetch(fixture) + return fetch(request.url, { + method: request.method, + headers: request.headers, + body: request.body, + ...(withSignal ? {signal} : {}), + }) +} + +describe('createFixtureFetch', () => { + test.each(authFixtures)('accepts the valid request for $name', async (fixture) => { + await expect( + requestFixture(fixture, fixture.request, fixture.signalExpectation === 'required'), + ).resolves.toMatchObject({ + status: fixture.responses[0]?.status, + }) + }) + + test('rejects a wrong method', async () => { + const fixture = authFixtures[0]! + const request = {...fixture.request, method: 'GET'} + await expect(requestFixture(fixture, request)).rejects.toThrow('method or URL') + }) + + test('rejects a wrong URL', async () => { + const fixture = authFixtures[0]! + const request: AuthFixture['request'] = {...fixture.request, url: `${fixture.request.url}/wrong`} + await expect(requestFixture(fixture, request)).rejects.toThrow('method or URL') + }) + + test.each([ + ['missing', {}], + ['extra', {'X-Fixture': 'unexpected'}], + ['misvalued', {'Content-type': 'text/plain'}], + ])('rejects a %s header', async (_description, headers) => { + const fixture = authFixtures[0]! + const request: AuthFixture['request'] = {...fixture.request, headers} + await expect(requestFixture(fixture, request)).rejects.toThrow('headers or body') + }) + + test('rejects a reordered body', async () => { + const fixture = authFixtures.find(({request}) => request.body.includes('&'))! + const [first, second] = fixture.request.body.split('&') + const reordered = `${second}&${first}` + const request: AuthFixture['request'] = {...fixture.request, body: reordered} + await expect(requestFixture(fixture, request)).rejects.toThrow('headers or body') + }) + + test('rejects an altered body value', async () => { + const fixture = authFixtures[0]! + const request: AuthFixture['request'] = { + ...fixture.request, + body: fixture.request.body.replace('fixture-client-id', 'other-client'), + } + await expect(requestFixture(fixture, request)).rejects.toThrow('headers or body') + }) + + test('requires a signal when the fixture requires one', async () => { + const fixture = authFixtures.find(({signalExpectation}) => signalExpectation === 'required')! + await expect(requestFixture(fixture)).rejects.toThrow('expected an abort signal') + }) + + test('rejects a signal when the fixture requires it to be absent', async () => { + const fixture = authFixtures.find(({signalExpectation}) => signalExpectation === 'absent')! + await expect(requestFixture(fixture, fixture.request, true)).rejects.toThrow('did not expect an abort signal') + }) + + test('permits either signal state for optional fixtures', async () => { + const fixture = authFixtures.find(({signalExpectation}) => signalExpectation === 'optional')! + await expect(requestFixture(fixture)).resolves.toBeDefined() + await expect(requestFixture(fixture, fixture.request, true)).resolves.toBeDefined() + }) + + test('returns responses in fixture order', async () => { + const fixture: AuthFixture = { + ...authFixtures[0]!, + name: 'ordered responses', + responses: [ + {status: 202, body: 'first'}, + {status: 200, body: 'second'}, + ], + } + const fetch = createFixtureFetch(fixture) + const init = {method: fixture.request.method, headers: fixture.request.headers, body: fixture.request.body} + const first = await fetch(fixture.request.url, init) + const second = await fetch(fixture.request.url, init) + expect(first.status).toBe(202) + expect(second.status).toBe(200) + }) + + test('rejects a fixture with no responses', async () => { + const fixture: AuthFixture = {...authFixtures[0]!, name: 'empty responses', responses: []} + const fetch = createFixtureFetch(fixture) + await expect(requestFixture(fixture)).rejects.toThrow('fixture has no response') + }) + + test('rejects a response call after the sequence is exhausted', async () => { + const fixture = authFixtures[0]! + const fetch = createFixtureFetch(fixture) + const init = {method: fixture.request.method, headers: fixture.request.headers, body: fixture.request.body} + await fetch(fixture.request.url, init) + await expect(fetch(fixture.request.url, init)).rejects.toThrow('response sequence exhausted') + }) +}) diff --git a/packages/dev-platform-auth/src/testing/harness.ts b/packages/dev-platform-auth/src/testing/harness.ts new file mode 100644 index 00000000000..d32d6a8d9ec --- /dev/null +++ b/packages/dev-platform-auth/src/testing/harness.ts @@ -0,0 +1,47 @@ +import type {AuthFetch, AuthFetchResponse, AuthSignal} from '../index.js' +import type {AuthFixture} from './fixtures.js' + +/** Creates a transport double from a fixture without requiring a runtime or HTTP library. */ +export function createFixtureFetch(fixture: AuthFixture): AuthFetch { + let responseIndex = 0 + return async (url, init): Promise => { + if (url !== fixture.request.url || init.method !== fixture.request.method) { + throw new Error(`${fixture.name}: request method or URL did not match the fixture`) + } + if (!sameRecord(init.headers, fixture.request.headers) || init.body !== fixture.request.body) { + throw new Error(`${fixture.name}: request headers or body did not match the fixture`) + } + const signalState = init.signal ? 'present' : 'absent' + if (fixture.signalExpectation === 'required' && signalState !== 'present') { + throw new Error(`${fixture.name}: expected an abort signal`) + } + if (fixture.signalExpectation === 'absent' && signalState !== 'absent') { + throw new Error(`${fixture.name}: did not expect an abort signal`) + } + if (fixture.responses.length === 0) throw new Error(`${fixture.name}: fixture has no response`) + if (responseIndex >= fixture.responses.length) { + throw new Error(`${fixture.name}: response sequence exhausted after ${fixture.responses.length} response(s)`) + } + const response = fixture.responses[responseIndex++] + if (!response) throw new Error(`${fixture.name}: fixture has no response`) + return { + ok: response.status >= 200 && response.status < 300, + status: response.status, + text: async () => response.body, + } + } +} + +/** Runs a fixture transport and returns the observed response; adapters map it to fixture.expected. */ +export async function runFixtureTransport( + fixture: AuthFixture, + request: (fetch: AuthFetch, signal?: AuthSignal) => Promise, +): Promise { + return request(createFixtureFetch(fixture)) +} + +function sameRecord(actual: Record, expected: Record): boolean { + const actualKeys = Object.keys(actual) + const expectedKeys = Object.keys(expected) + return actualKeys.length === expectedKeys.length && expectedKeys.every((key) => actual[key] === expected[key]) +} diff --git a/packages/dev-platform-auth/src/testing/index.ts b/packages/dev-platform-auth/src/testing/index.ts index ce50e735361..06a81e1b1a2 100644 --- a/packages/dev-platform-auth/src/testing/index.ts +++ b/packages/dev-platform-auth/src/testing/index.ts @@ -1 +1,11 @@ export const TESTING_PACKAGE_NAME = '@shopify/dev-platform-auth/testing' + +export {authFixtures, fakeClientId, fixtureOrigin} from './fixtures.js' +export type { + AuthFixture, + AuthFixtureRequest, + AuthFixtureResponse, + FixtureExpected, + FixtureOperation, +} from './fixtures.js' +export {createFixtureFetch, runFixtureTransport} from './harness.js' From d2f465d9302635d8fc0a1195b2718eb21e5ddf6f Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Thu, 13 Aug 2026 14:06:00 -0400 Subject: [PATCH 5/9] Port client credentials auth flow Assisted-By: devx/7a9ee065-6614-4654-8965-98d6e969645b --- packages/cli-kit/package.json | 1 + packages/cli-kit/src/public/node/session.ts | 68 +++++++------- .../src/client-credentials.ts | 88 +++++++++++++++++++ packages/dev-platform-auth/src/index.ts | 12 ++- .../dev-platform-auth/src/testing/fixtures.ts | 2 +- pnpm-lock.yaml | 28 ++++-- 6 files changed, 156 insertions(+), 43 deletions(-) create mode 100644 packages/dev-platform-auth/src/client-credentials.ts diff --git a/packages/cli-kit/package.json b/packages/cli-kit/package.json index b6ca7f3a4fa..8bcff7a8928 100644 --- a/packages/cli-kit/package.json +++ b/packages/cli-kit/package.json @@ -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", diff --git a/packages/cli-kit/src/public/node/session.ts b/packages/cli-kit/src/public/node/session.ts index 15be5d39cbf..ee2bd5bbb3d 100644 --- a/packages/cli-kit/src/public/node/session.ts +++ b/packages/cli-kit/src/public/node/session.ts @@ -2,6 +2,11 @@ import {shopifyFetch} from './http.js' import {nonRandomUUID} from './crypto.js' import {getAppAutomationToken} from './environment.js' import {AbortError, BugError} from './error.js' +import { + createClientCredentialsClient, + type AuthFetch, + type ClientCredentialsError, +} from '@shopify/dev-platform-auth' import {outputContent, outputToken, outputDebug} from './output.js' import * as sessionStore from '../../private/node/session/store.js' import { @@ -343,47 +348,42 @@ export async function ensureAuthenticatedAdminAsApp( clientId: string, clientSecret: string, ): Promise { - const bodyData = { - client_id: clientId, - client_secret: clientSecret, - grant_type: 'client_credentials', + let statusText = '' + const fetch: AuthFetch = async (url, init) => { + const response = await shopifyFetch(url, {...init}, 'slow-request') + statusText = response.statusText ?? '' + return { + ok: response.status >= 200 && response.status < 300, + 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({identityOrigin: '', clientId, fetch}).requestToken({ + storeFqdn, + clientId, + clientSecret, + }) - const body = await tokenResponse.text() + if ('accessToken' in result) return {token: result.accessToken, storeFqdn} + throw mapClientCredentialsError(result, storeFqdn, clientId, statusText) +} - if (tokenResponse.status === 400) { - if (body.includes('app_not_installed')) { - throw new AbortError( +function mapClientCredentialsError( + error: ClientCredentialsError, + storeFqdn: string, + clientId: string, + statusText: string, +): AbortError | BugError { + if ('serverCode' in error) { + if (error.serverCode === 'app_not_installed') { + return 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}`, - ) - } - 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 + return new AbortError(`Failed to get access token for app ${clientId} on store ${storeFqdn}: ${statusText}`) } + if (error.kind === 'malformed_response') return new BugError(`Received invalid response from admin authentication service (HTTP ${error.status}).`) + return new AbortError(`Failed to get access token for app ${clientId} on store ${storeFqdn}: ${statusText}`) } diff --git a/packages/dev-platform-auth/src/client-credentials.ts b/packages/dev-platform-auth/src/client-credentials.ts new file mode 100644 index 00000000000..91d2adf6506 --- /dev/null +++ b/packages/dev-platform-auth/src/client-credentials.ts @@ -0,0 +1,88 @@ +import type { + ApplicationToken, + AuthClientConfig, + ClientCredentialsClient, + ClientCredentialsError, + ClientCredentialsTokenRequest, +} from './index.js' + +const TOKEN_LIFETIME_MS = 300_000 + +export function createClientCredentialsClient(config: AuthClientConfig): ClientCredentialsClient { + return { + requestToken: async (request) => requestClientCredentialsToken(config, request), + } +} + +export async function requestClientCredentialsToken( + config: AuthClientConfig, + request: ClientCredentialsTokenRequest, +): Promise { + if (!config.fetch) { + return {kind: 'transport_failed', cause: new Error('Auth fetch is not configured')} + } + + let response + try { + response = await config.fetch(`https://${request.storeFqdn}/admin/oauth/access_token`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + client_id: request.clientId, + client_secret: request.clientSecret, + grant_type: 'client_credentials', + }), + }) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (cause) { + // The fetch port reports all request failures as typed transport errors. + return {kind: 'transport_failed', cause} + } + + let body: string + try { + body = await response.text() + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (cause) { + return {kind: 'transport_failed', cause} + } + + let parsed: unknown + try { + parsed = JSON.parse(body) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (cause) { + const serverCode = body.includes('app_not_installed') ? 'app_not_installed' : undefined + if (serverCode) return {serverCode, status: response.status} + return response.ok + ? {kind: 'malformed_response', status: response.status, cause} + : {kind: 'unexpected_status', status: response.status} + } + + if (!response.ok) { + const serverCode = readServerCode(parsed) + return serverCode ? {serverCode, status: response.status} : {kind: 'unexpected_status', status: response.status} + } + + if (!isRecord(parsed) || typeof parsed.access_token !== 'string' || parsed.access_token.length === 0) { + return {kind: 'malformed_response', status: response.status} + } + + return { + accessToken: parsed.access_token, + expiresAt: (config.clock?.now() ?? 0) + TOKEN_LIFETIME_MS, + scopes: [], + storeFqdn: request.storeFqdn, + } +} + +function readServerCode(value: unknown): string | undefined { + if (!isRecord(value)) return undefined + if ('error' in value && typeof value.error === 'string' && value.error.length > 0) return value.error + if ('error' in value) return 'unknown_error' + return 'unknown_error' +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} diff --git a/packages/dev-platform-auth/src/index.ts b/packages/dev-platform-auth/src/index.ts index 78b66e57082..af683d6e85a 100644 --- a/packages/dev-platform-auth/src/index.ts +++ b/packages/dev-platform-auth/src/index.ts @@ -125,8 +125,16 @@ export interface ClientCredentialsTokenRequest { signal?: AuthSignal } +export type ClientCredentialsError = + | {kind: 'transport_failed'; cause: unknown} + | {kind: 'malformed_response'; status: number; cause?: unknown} + | {kind: 'unexpected_status'; status: number} + | {serverCode: string; status: number} + +export type ClientCredentialsResult = ApplicationToken | ClientCredentialsError + export interface ClientCredentialsClient { - requestToken(options: ClientCredentialsTokenRequest): Promise + requestToken(options: ClientCredentialsTokenRequest): Promise } export type AuthErrorCode = @@ -143,6 +151,8 @@ export interface AuthProtocolErrorOptions { status?: number } +export {createClientCredentialsClient, requestClientCredentialsToken} from './client-credentials.js' + export class AuthProtocolError extends Error { readonly code: AuthErrorCode readonly status?: number diff --git a/packages/dev-platform-auth/src/testing/fixtures.ts b/packages/dev-platform-auth/src/testing/fixtures.ts index 24bea946518..2113e9e3a68 100644 --- a/packages/dev-platform-auth/src/testing/fixtures.ts +++ b/packages/dev-platform-auth/src/testing/fixtures.ts @@ -289,7 +289,7 @@ export const authFixtures: ReadonlyArray = [ transportCitation: 'cli-kit packages/cli-kit/src/private/node/session/exchange.ts:262-267; characterization oracle packages/cli-kit/src/private/node/session/exchange.test.ts:412-425,465-471', provisionalOutcome: - 'Open question: cli-kit normalizes missing error to unknown_error and exchange throws AbortError; do not freeze invalid_response.', + 'Open question: cli-kit normalizes missing error to unknown_error and exchange throws AbortError; use malformed_response only for a body that does not parse.', redactedValues: ['fixture-identity-access'], privateInputs: ['subject_token'], }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b4a199c139a..0c49f86402b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -334,6 +334,9 @@ importers: '@opentelemetry/sdk-metrics': specifier: 1.30.1 version: 1.30.1(@opentelemetry/api@1.9.1) + '@shopify/dev-platform-auth': + specifier: workspace:* + version: link:../dev-platform-auth '@shopify/polaris': specifier: 12.27.0 version: 12.27.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -581,7 +584,7 @@ importers: version: 8.56.1(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) '@vitest/eslint-plugin': specifier: 1.1.44 - version: 1.1.44(@typescript-eslint/utils@8.56.1(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-istanbul@3.2.7(vitest@4.1.10))(jsdom@28.1.0)(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@6.4.3(@types/node@26.1.1)(jiti@2.6.1)(sass@1.100.0)(tsx@4.23.1)(yaml@2.9.0))) + version: 1.1.44(@typescript-eslint/utils@8.56.1(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-istanbul@3.2.7)(jsdom@28.1.0)(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@6.4.3(@types/node@26.1.1)(jiti@2.6.1)(sass@1.100.0)(tsx@4.23.1)(yaml@2.9.0))) eslint: specifier: ^9.0.0 version: 9.39.5(jiti@2.6.1) @@ -3771,6 +3774,7 @@ packages: '@shopify/polaris@12.27.0': resolution: {integrity: sha512-Y8yus6iEjcfW2ZtEJtlqxbWeDJqTX3S/MOLH4GWRvU5gFYJQhlaHaETs0+OimbhEpO95mXbY8qB+KnIJaVBHwA==, tarball: https://registry.npmjs.org/@shopify/polaris/-/polaris-12.27.0.tgz} engines: {node: ^16.17.0 || >=18.12.0} + deprecated: 'Polaris React is deprecated and no longer maintained. For building Shopify admin experiences, use Polaris web components: https://shopify.dev/docs/api/polaris — archived docs for this package: https://shopify.github.io/polaris-react-archive/' peerDependencies: react: ^18.0.0 react-dom: ^18.0.0 @@ -4001,6 +4005,9 @@ packages: '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==, tarball: https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz} + '@types/node@18.19.130': + resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==, tarball: https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz} + '@types/node@22.20.1': resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==, tarball: https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz} @@ -8507,6 +8514,9 @@ packages: uncrypto@0.1.3: resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==, tarball: https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz} + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==, tarball: https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==, tarball: https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz} @@ -12858,6 +12868,10 @@ snapshots: '@types/node@12.20.55': {} + '@types/node@18.19.130': + dependencies: + undici-types: 5.26.5 + '@types/node@22.20.1': dependencies: undici-types: 6.21.0 @@ -13094,17 +13108,17 @@ snapshots: magicast: 0.3.5 test-exclude: 7.0.2 tinyrainbow: 2.0.0 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.20.1)(@vitest/coverage-istanbul@3.2.7)(jsdom@28.1.0)(msw@2.15.0(@types/node@22.20.1)(typescript@5.9.3))(vite@6.4.3(@types/node@22.20.1)(jiti@2.6.1)(sass@1.100.0)(tsx@4.23.1)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-istanbul@3.2.7)(jsdom@28.1.0)(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@6.4.3(@types/node@26.1.1)(jiti@2.6.1)(sass@1.100.0)(tsx@4.23.1)(yaml@2.9.0)) transitivePeerDependencies: - supports-color - '@vitest/eslint-plugin@1.1.44(@typescript-eslint/utils@8.56.1(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-istanbul@3.2.7(vitest@4.1.10))(jsdom@28.1.0)(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@6.4.3(@types/node@26.1.1)(jiti@2.6.1)(sass@1.100.0)(tsx@4.23.1)(yaml@2.9.0)))': + '@vitest/eslint-plugin@1.1.44(@typescript-eslint/utils@8.56.1(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-istanbul@3.2.7)(jsdom@28.1.0)(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@6.4.3(@types/node@26.1.1)(jiti@2.6.1)(sass@1.100.0)(tsx@4.23.1)(yaml@2.9.0)))': dependencies: '@typescript-eslint/utils': 8.56.1(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.5(jiti@2.6.1) optionalDependencies: typescript: 5.9.3 - vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-istanbul@3.2.7(vitest@4.1.10))(jsdom@28.1.0)(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@6.4.3(@types/node@26.1.1)(jiti@2.6.1)(sass@1.100.0)(tsx@4.23.1)(yaml@2.9.0)) + vitest: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-istanbul@3.2.7)(jsdom@28.1.0)(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@6.4.3(@types/node@26.1.1)(jiti@2.6.1)(sass@1.100.0)(tsx@4.23.1)(yaml@2.9.0)) '@vitest/eslint-plugin@1.1.44(@typescript-eslint/utils@8.56.1(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.5(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.10)': dependencies: @@ -13140,7 +13154,6 @@ snapshots: optionalDependencies: msw: 2.15.0(@types/node@26.1.1)(typescript@5.9.3) vite: 6.4.3(@types/node@26.1.1)(jiti@2.6.1)(sass@1.100.0)(tsx@4.23.1)(yaml@2.9.0) - optional: true '@vitest/pretty-format@4.1.10': dependencies: @@ -17946,6 +17959,8 @@ snapshots: uncrypto@0.1.3: {} + undici-types@5.26.5: {} + undici-types@6.21.0: {} undici-types@8.3.0: @@ -18114,7 +18129,7 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-istanbul@3.2.7(vitest@4.1.10))(jsdom@28.1.0)(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@6.4.3(@types/node@26.1.1)(jiti@2.6.1)(sass@1.100.0)(tsx@4.23.1)(yaml@2.9.0)): + vitest@4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.1)(@vitest/coverage-istanbul@3.2.7)(jsdom@28.1.0)(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@6.4.3(@types/node@26.1.1)(jiti@2.6.1)(sass@1.100.0)(tsx@4.23.1)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(msw@2.15.0(@types/node@26.1.1)(typescript@5.9.3))(vite@6.4.3(@types/node@26.1.1)(jiti@2.6.1)(sass@1.100.0)(tsx@4.23.1)(yaml@2.9.0)) @@ -18143,7 +18158,6 @@ snapshots: jsdom: 28.1.0 transitivePeerDependencies: - msw - optional: true vscode-css-languageservice@6.3.2: dependencies: From 8c9f53657d022102fe33cd46f5de29dcb4ee30d9 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Thu, 13 Aug 2026 16:40:30 -0400 Subject: [PATCH 6/9] Fix client credentials auth contract Assisted-By: devx/2371aeae-c1b9-4d35-9684-3974f733db3f --- packages/cli-kit/src/public/node/session.ts | 8 +- .../src/client-credentials.test.ts | 90 +++++++++++++++++++ .../src/client-credentials.ts | 31 +++---- packages/dev-platform-auth/src/index.ts | 11 ++- 4 files changed, 118 insertions(+), 22 deletions(-) create mode 100644 packages/dev-platform-auth/src/client-credentials.test.ts diff --git a/packages/cli-kit/src/public/node/session.ts b/packages/cli-kit/src/public/node/session.ts index ee2bd5bbb3d..010deb87834 100644 --- a/packages/cli-kit/src/public/node/session.ts +++ b/packages/cli-kit/src/public/node/session.ts @@ -358,7 +358,7 @@ export async function ensureAuthenticatedAdminAsApp( text: () => response.text(), } } - const result = await createClientCredentialsClient({identityOrigin: '', clientId, fetch}).requestToken({ + const result = await createClientCredentialsClient({fetch}).requestToken({ storeFqdn, clientId, clientSecret, @@ -384,6 +384,10 @@ function mapClientCredentialsError( } return new AbortError(`Failed to get access token for app ${clientId} on store ${storeFqdn}: ${statusText}`) } - if (error.kind === 'malformed_response') return new BugError(`Received invalid response from admin authentication service (HTTP ${error.status}).`) + if (error.kind === 'malformed_response') { + return error.status >= 200 && error.status < 300 + ? new BugError(`Received invalid response from admin authentication service (HTTP ${error.status}).`) + : new AbortError(`Failed to get access token for app ${clientId} on store ${storeFqdn}: ${statusText}`) + } return new AbortError(`Failed to get access token for app ${clientId} on store ${storeFqdn}: ${statusText}`) } diff --git a/packages/dev-platform-auth/src/client-credentials.test.ts b/packages/dev-platform-auth/src/client-credentials.test.ts new file mode 100644 index 00000000000..5f58cbcfb9e --- /dev/null +++ b/packages/dev-platform-auth/src/client-credentials.test.ts @@ -0,0 +1,90 @@ +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', +} + +function fetchResponse(status: number, body: string): AuthFetch { + return async () => ({ok: status >= 200 && status < 300, 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([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('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'}) + }) +}) diff --git a/packages/dev-platform-auth/src/client-credentials.ts b/packages/dev-platform-auth/src/client-credentials.ts index 91d2adf6506..6296bbcd305 100644 --- a/packages/dev-platform-auth/src/client-credentials.ts +++ b/packages/dev-platform-auth/src/client-credentials.ts @@ -1,23 +1,21 @@ import type { - ApplicationToken, - AuthClientConfig, + ClientCredentialsConfig, + ClientCredentialsToken, ClientCredentialsClient, ClientCredentialsError, ClientCredentialsTokenRequest, } from './index.js' -const TOKEN_LIFETIME_MS = 300_000 - -export function createClientCredentialsClient(config: AuthClientConfig): ClientCredentialsClient { +export function createClientCredentialsClient(config: ClientCredentialsConfig): ClientCredentialsClient { return { requestToken: async (request) => requestClientCredentialsToken(config, request), } } export async function requestClientCredentialsToken( - config: AuthClientConfig, + config: ClientCredentialsConfig, request: ClientCredentialsTokenRequest, -): Promise { +): Promise { if (!config.fetch) { return {kind: 'transport_failed', cause: new Error('Auth fetch is not configured')} } @@ -34,29 +32,27 @@ export async function requestClientCredentialsToken( }), }) // eslint-disable-next-line no-catch-all/no-catch-all - } catch (cause) { + } catch { // The fetch port reports all request failures as typed transport errors. - return {kind: 'transport_failed', cause} + return {kind: 'transport_failed', cause: new Error('Auth request failed')} } let body: string try { body = await response.text() // eslint-disable-next-line no-catch-all/no-catch-all - } catch (cause) { - return {kind: 'transport_failed', cause} + } catch { + return {kind: 'transport_failed', cause: new Error('Auth response could not be read')} } let parsed: unknown try { parsed = JSON.parse(body) // eslint-disable-next-line no-catch-all/no-catch-all - } catch (cause) { - const serverCode = body.includes('app_not_installed') ? 'app_not_installed' : undefined + } catch { + const serverCode = response.status === 400 && body.includes('app_not_installed') ? 'app_not_installed' : undefined if (serverCode) return {serverCode, status: response.status} - return response.ok - ? {kind: 'malformed_response', status: response.status, cause} - : {kind: 'unexpected_status', status: response.status} + return {kind: 'malformed_response', status: response.status, cause: new Error('Auth response is not valid JSON')} } if (!response.ok) { @@ -70,8 +66,6 @@ export async function requestClientCredentialsToken( return { accessToken: parsed.access_token, - expiresAt: (config.clock?.now() ?? 0) + TOKEN_LIFETIME_MS, - scopes: [], storeFqdn: request.storeFqdn, } } @@ -79,7 +73,6 @@ export async function requestClientCredentialsToken( function readServerCode(value: unknown): string | undefined { if (!isRecord(value)) return undefined if ('error' in value && typeof value.error === 'string' && value.error.length > 0) return value.error - if ('error' in value) return 'unknown_error' return 'unknown_error' } diff --git a/packages/dev-platform-auth/src/index.ts b/packages/dev-platform-auth/src/index.ts index af683d6e85a..360ec552360 100644 --- a/packages/dev-platform-auth/src/index.ts +++ b/packages/dev-platform-auth/src/index.ts @@ -17,6 +17,11 @@ export interface ApplicationToken { storeFqdn?: string } +export interface ClientCredentialsToken { + accessToken: string + storeFqdn: string +} + export type ShopifyApplication = 'admin' | 'partners' | 'storefront-renderer' | 'business-platform' | 'app-management' export interface AuthClientConfig { @@ -29,6 +34,10 @@ export interface AuthClientConfig { export interface IdentityClientConfig extends AuthClientConfig {} +export interface ClientCredentialsConfig { + fetch?: AuthFetch +} + export interface ApplicationAccessConfig extends AuthClientConfig { applicationIds: Record } @@ -131,7 +140,7 @@ export type ClientCredentialsError = | {kind: 'unexpected_status'; status: number} | {serverCode: string; status: number} -export type ClientCredentialsResult = ApplicationToken | ClientCredentialsError +export type ClientCredentialsResult = ClientCredentialsToken | ClientCredentialsError export interface ClientCredentialsClient { requestToken(options: ClientCredentialsTokenRequest): Promise From 9c84a2d6bc6b57e4b2a510b6fe9365ea276422f9 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Thu, 13 Aug 2026 16:44:59 -0400 Subject: [PATCH 7/9] Correct client credentials adapter contracts Assisted-By: devx/9eea9b31-7143-4bfb-b4f5-e31a4675e7ee --- packages/cli-kit/src/public/node/session.ts | 4 +- .../src/client-credentials.test.ts | 22 ++- .../src/client-credentials.ts | 3 +- packages/dev-platform-auth/src/index.ts | 130 ------------------ .../dev-platform-auth/src/testing/fixtures.ts | 17 ++- .../src/testing/harness.test.ts | 3 +- .../dev-platform-auth/src/testing/harness.ts | 6 +- 7 files changed, 44 insertions(+), 141 deletions(-) diff --git a/packages/cli-kit/src/public/node/session.ts b/packages/cli-kit/src/public/node/session.ts index 010deb87834..8b068f7e3f4 100644 --- a/packages/cli-kit/src/public/node/session.ts +++ b/packages/cli-kit/src/public/node/session.ts @@ -385,9 +385,7 @@ function mapClientCredentialsError( return new AbortError(`Failed to get access token for app ${clientId} on store ${storeFqdn}: ${statusText}`) } if (error.kind === 'malformed_response') { - return error.status >= 200 && error.status < 300 - ? new BugError(`Received invalid response from admin authentication service (HTTP ${error.status}).`) - : new AbortError(`Failed to get access token for app ${clientId} on store ${storeFqdn}: ${statusText}`) + return new AbortError(`Failed to get access token for app ${clientId} on store ${storeFqdn}: ${statusText}`) } return new AbortError(`Failed to get access token for app ${clientId} on store ${storeFqdn}: ${statusText}`) } diff --git a/packages/dev-platform-auth/src/client-credentials.test.ts b/packages/dev-platform-auth/src/client-credentials.test.ts index 5f58cbcfb9e..d22ba666ce9 100644 --- a/packages/dev-platform-auth/src/client-credentials.test.ts +++ b/packages/dev-platform-auth/src/client-credentials.test.ts @@ -8,6 +8,12 @@ const request: ClientCredentialsTokenRequest = { 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 () => ({ok: status >= 200 && status < 300, status, text: async () => body}) } @@ -37,7 +43,7 @@ describe('requestClientCredentialsToken', () => { }) }) - test.each([400, 500])('classifies malformed JSON as malformed_response for HTTP %s', async (status) => { + 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({ @@ -46,6 +52,20 @@ describe('requestClientCredentialsToken', () => { }) }) + 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', diff --git a/packages/dev-platform-auth/src/client-credentials.ts b/packages/dev-platform-auth/src/client-credentials.ts index 6296bbcd305..22483bc8605 100644 --- a/packages/dev-platform-auth/src/client-credentials.ts +++ b/packages/dev-platform-auth/src/client-credentials.ts @@ -55,7 +55,8 @@ export async function requestClientCredentialsToken( return {kind: 'malformed_response', status: response.status, cause: new Error('Auth response is not valid JSON')} } - if (!response.ok) { + const isSuccess = response.status >= 200 && response.status < 300 + if (!isSuccess) { const serverCode = readServerCode(parsed) return serverCode ? {serverCode, status: response.status} : {kind: 'unexpected_status', status: response.status} } diff --git a/packages/dev-platform-auth/src/index.ts b/packages/dev-platform-auth/src/index.ts index 360ec552360..1990a83767f 100644 --- a/packages/dev-platform-auth/src/index.ts +++ b/packages/dev-platform-auth/src/index.ts @@ -1,137 +1,33 @@ export const PACKAGE_NAME = '@shopify/dev-platform-auth' -/** Epoch-millisecond credentials shared by auth adapters. */ -export interface IdentityToken { - accessToken: string - refreshToken: string - expiresAt: number - scopes: string[] - userId: string - alias?: string -} - -export interface ApplicationToken { - accessToken: string - expiresAt: number - scopes: string[] - storeFqdn?: string -} - export interface ClientCredentialsToken { accessToken: string storeFqdn: string } -export type ShopifyApplication = 'admin' | 'partners' | 'storefront-renderer' | 'business-platform' | 'app-management' - -export interface AuthClientConfig { - identityOrigin: string - clientId: string - fetch?: AuthFetch - clock?: Clock - logger?: AuthLogger -} - -export interface IdentityClientConfig extends AuthClientConfig {} - export interface ClientCredentialsConfig { fetch?: AuthFetch } -export interface ApplicationAccessConfig extends AuthClientConfig { - applicationIds: Record -} - export interface AuthFetchResponse { ok: boolean status: number text(): Promise } -export interface AuthSignal { - readonly aborted: boolean - addEventListener(type: 'abort', listener: () => void, options?: {once?: boolean}): void - removeEventListener(type: 'abort', listener: () => void): void -} - export type AuthFetch = ( url: string, init: { method: string headers: Record body?: string - signal?: AuthSignal }, ) => Promise -export interface Clock { - now(): number - sleep?(milliseconds: number, signal?: AuthSignal): Promise -} - -export type AuthLogEvent = - | { - event: 'device_authorization_started' | 'device_authorization_completed' - application?: ShopifyApplication - } - | { - event: 'device_authorization_pending' | 'device_authorization_slow_down' - application?: ShopifyApplication - } - | { - event: 'auth_protocol_error' - code: AuthErrorCode - status?: number - } - -export interface AuthLogger { - debug(event: AuthLogEvent): void -} - -export interface DeviceAuthorization { - userCode: string - verificationUri: string - verificationUriComplete?: string - expiresIn: number - interval?: number -} - -export type DeviceCodeExchangeResult = - | {status: 'complete'; token: IdentityToken} - | {status: 'pending'} - | {status: 'slow_down'} - -export interface BaseApplicationTokenRequest { - identityToken: string - scopes: string[] - signal?: AuthSignal -} - -export type ApplicationTokenRequest = - | (BaseApplicationTokenRequest & { - application: 'admin' - storeFqdn: string - }) - | (BaseApplicationTokenRequest & { - application: Exclude - storeFqdn?: never - }) - -export interface IdentityClient { - requestDeviceAuthorization(options: {scopes: string[]; signal?: AuthSignal}): Promise - exchangeDeviceCode(options: {deviceCode: string; signal?: AuthSignal}): Promise - refreshIdentityToken(options: {token: IdentityToken; signal?: AuthSignal}): Promise -} - -export interface ApplicationAccessClient { - exchangeApplicationToken(request: ApplicationTokenRequest): Promise -} - export interface ClientCredentialsTokenRequest { storeFqdn: string clientId: string clientSecret: string - signal?: AuthSignal } export type ClientCredentialsError = @@ -146,30 +42,4 @@ export interface ClientCredentialsClient { requestToken(options: ClientCredentialsTokenRequest): Promise } -export type AuthErrorCode = - | 'invalid_grant' - | 'invalid_request' - | 'invalid_target' - | 'access_denied' - | 'expired_token' - | 'invalid_response' - | 'app_not_installed' - | 'unknown' - -export interface AuthProtocolErrorOptions { - status?: number -} - export {createClientCredentialsClient, requestClientCredentialsToken} from './client-credentials.js' - -export class AuthProtocolError extends Error { - readonly code: AuthErrorCode - readonly status?: number - - constructor(code: AuthErrorCode, options: AuthProtocolErrorOptions = {}) { - super(code) - this.name = 'AuthProtocolError' - this.code = code - this.status = options.status - } -} diff --git a/packages/dev-platform-auth/src/testing/fixtures.ts b/packages/dev-platform-auth/src/testing/fixtures.ts index 2113e9e3a68..c2856c71eb0 100644 --- a/packages/dev-platform-auth/src/testing/fixtures.ts +++ b/packages/dev-platform-auth/src/testing/fixtures.ts @@ -1,4 +1,19 @@ -import type {AuthErrorCode, AuthFetchResponse, AuthSignal} from '../index.js' +import type {AuthFetchResponse} from '../index.js' + +type AuthErrorCode = + | 'invalid_grant' + | 'invalid_request' + | 'invalid_target' + | 'access_denied' + | 'expired_token' + | 'invalid_response' + | 'app_not_installed' + | 'unknown' +export interface AuthSignal { + readonly aborted: boolean + addEventListener(type: 'abort', listener: () => void, options?: {once?: boolean}): void + removeEventListener(type: 'abort', listener: () => void): void +} export const fixtureOrigin = 'https://identity.example.test' export const fakeClientId = 'fixture-client-id' diff --git a/packages/dev-platform-auth/src/testing/harness.test.ts b/packages/dev-platform-auth/src/testing/harness.test.ts index cc9fab7d73b..871d395e196 100644 --- a/packages/dev-platform-auth/src/testing/harness.test.ts +++ b/packages/dev-platform-auth/src/testing/harness.test.ts @@ -1,8 +1,7 @@ import {authFixtures} from './fixtures.js' import {createFixtureFetch} from './harness.js' import {describe, expect, test} from 'vitest' -import type {AuthSignal} from '../index.js' -import type {AuthFixture} from './fixtures.js' +import type {AuthFixture, AuthSignal} from './fixtures.js' const signal: AuthSignal = { aborted: false, diff --git a/packages/dev-platform-auth/src/testing/harness.ts b/packages/dev-platform-auth/src/testing/harness.ts index d32d6a8d9ec..c8d8bb123ba 100644 --- a/packages/dev-platform-auth/src/testing/harness.ts +++ b/packages/dev-platform-auth/src/testing/harness.ts @@ -1,10 +1,10 @@ -import type {AuthFetch, AuthFetchResponse, AuthSignal} from '../index.js' -import type {AuthFixture} from './fixtures.js' +import type {AuthFetch, AuthFetchResponse} from '../index.js' +import type {AuthFixture, AuthSignal} from './fixtures.js' /** Creates a transport double from a fixture without requiring a runtime or HTTP library. */ export function createFixtureFetch(fixture: AuthFixture): AuthFetch { let responseIndex = 0 - return async (url, init): Promise => { + return async (url, init: Parameters[1] & {signal?: AuthSignal}): Promise => { if (url !== fixture.request.url || init.method !== fixture.request.method) { throw new Error(`${fixture.name}: request method or URL did not match the fixture`) } From 0cb7ae65cb20da40ceb6d00a8c296ab095afb410 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Thu, 13 Aug 2026 16:49:35 -0400 Subject: [PATCH 8/9] Harden client credentials auth errors Assisted-By: devx/e028700d-9b41-468f-aa4c-d283f0afaa61 --- .../node/session.auth-clientcreds.test.ts | 26 +++++++++++++++++++ packages/cli-kit/src/public/node/session.ts | 24 +++++++---------- packages/dev-platform-auth/README.md | 2 +- .../src/client-credentials.test.ts | 2 +- packages/dev-platform-auth/src/index.ts | 1 - .../dev-platform-auth/src/testing/harness.ts | 1 - 6 files changed, 38 insertions(+), 18 deletions(-) create mode 100644 packages/cli-kit/src/public/node/session.auth-clientcreds.test.ts diff --git a/packages/cli-kit/src/public/node/session.auth-clientcreds.test.ts b/packages/cli-kit/src/public/node/session.auth-clientcreds.test.ts new file mode 100644 index 00000000000..ca49dba7fe3 --- /dev/null +++ b/packages/cli-kit/src/public/node/session.auth-clientcreds.test.ts @@ -0,0 +1,26 @@ +import {describe, expect, test, vi} from 'vitest' + +import {ensureAuthenticatedAdminAsApp} from './session.js' +import {shopifyFetch} from './http.js' + +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>) + + 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') + }) +}) diff --git a/packages/cli-kit/src/public/node/session.ts b/packages/cli-kit/src/public/node/session.ts index 8b068f7e3f4..b1f41f74762 100644 --- a/packages/cli-kit/src/public/node/session.ts +++ b/packages/cli-kit/src/public/node/session.ts @@ -2,11 +2,7 @@ import {shopifyFetch} from './http.js' import {nonRandomUUID} from './crypto.js' import {getAppAutomationToken} from './environment.js' import {AbortError, BugError} from './error.js' -import { - createClientCredentialsClient, - type AuthFetch, - type ClientCredentialsError, -} from '@shopify/dev-platform-auth' +import {createClientCredentialsClient, type AuthFetch, type ClientCredentialsError} from '@shopify/dev-platform-auth' import {outputContent, outputToken, outputDebug} from './output.js' import * as sessionStore from '../../private/node/session/store.js' import { @@ -348,12 +344,9 @@ export async function ensureAuthenticatedAdminAsApp( clientId: string, clientSecret: string, ): Promise { - let statusText = '' const fetch: AuthFetch = async (url, init) => { const response = await shopifyFetch(url, {...init}, 'slow-request') - statusText = response.statusText ?? '' return { - ok: response.status >= 200 && response.status < 300, status: response.status, text: () => response.text(), } @@ -365,14 +358,13 @@ export async function ensureAuthenticatedAdminAsApp( }) if ('accessToken' in result) return {token: result.accessToken, storeFqdn} - throw mapClientCredentialsError(result, storeFqdn, clientId, statusText) + throw mapClientCredentialsError(result, storeFqdn, clientId) } function mapClientCredentialsError( error: ClientCredentialsError, storeFqdn: string, clientId: string, - statusText: string, ): AbortError | BugError { if ('serverCode' in error) { if (error.serverCode === 'app_not_installed') { @@ -382,10 +374,14 @@ function mapClientCredentialsError( )}. Try running ${outputToken.genericShellCommand(`shopify app dev`)} to connect your app to the shop.`, ) } - return new AbortError(`Failed to get access token for app ${clientId} on store ${storeFqdn}: ${statusText}`) + return new AbortError(clientCredentialsFailureMessage(error.status, storeFqdn, clientId)) } - if (error.kind === 'malformed_response') { - return new AbortError(`Failed to get access token for app ${clientId} on store ${storeFqdn}: ${statusText}`) + if (error.kind === 'malformed_response' || error.kind === 'unexpected_status') { + return new AbortError(clientCredentialsFailureMessage(error.status, storeFqdn, clientId)) } - return new AbortError(`Failed to get access token for app ${clientId} on store ${storeFqdn}: ${statusText}`) + return 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}` } diff --git a/packages/dev-platform-auth/README.md b/packages/dev-platform-auth/README.md index c4d5243fb63..7f710cab23b 100644 --- a/packages/dev-platform-auth/README.md +++ b/packages/dev-platform-auth/README.md @@ -2,6 +2,6 @@ `@shopify/dev-platform-auth` provides portable Shopify developer auth flows. -This package is currently a contract prototype. It defines separate Identity credential and application-access contracts plus `AuthProtocolError`; Identity device flow, `client_credentials`, and Store PKCE runtime modules will arrive in subsequent pull requests. +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. diff --git a/packages/dev-platform-auth/src/client-credentials.test.ts b/packages/dev-platform-auth/src/client-credentials.test.ts index d22ba666ce9..a1d23e43196 100644 --- a/packages/dev-platform-auth/src/client-credentials.test.ts +++ b/packages/dev-platform-auth/src/client-credentials.test.ts @@ -15,7 +15,7 @@ test('does not expose cancellation on the client-credentials request', () => { }) function fetchResponse(status: number, body: string): AuthFetch { - return async () => ({ok: status >= 200 && status < 300, status, text: async () => body}) + return async () => ({status, text: async () => body}) } describe('requestClientCredentialsToken', () => { diff --git a/packages/dev-platform-auth/src/index.ts b/packages/dev-platform-auth/src/index.ts index 1990a83767f..a82c551e380 100644 --- a/packages/dev-platform-auth/src/index.ts +++ b/packages/dev-platform-auth/src/index.ts @@ -10,7 +10,6 @@ export interface ClientCredentialsConfig { } export interface AuthFetchResponse { - ok: boolean status: number text(): Promise } diff --git a/packages/dev-platform-auth/src/testing/harness.ts b/packages/dev-platform-auth/src/testing/harness.ts index c8d8bb123ba..ce31f010dad 100644 --- a/packages/dev-platform-auth/src/testing/harness.ts +++ b/packages/dev-platform-auth/src/testing/harness.ts @@ -25,7 +25,6 @@ export function createFixtureFetch(fixture: AuthFixture): AuthFetch { const response = fixture.responses[responseIndex++] if (!response) throw new Error(`${fixture.name}: fixture has no response`) return { - ok: response.status >= 200 && response.status < 300, status: response.status, text: async () => response.body, } From fdc21e976e296aea91de73851bc40ee5aa3a3dd8 Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Thu, 13 Aug 2026 17:26:07 -0400 Subject: [PATCH 9/9] Fix client credentials CI checks Assisted-By: devx/e60b6dbb-cc8e-4183-a7cd-c957ae861a16 --- configurations/vite.config.ts | 1 + .../node/session.auth-clientcreds.test.ts | 3 +-- packages/cli-kit/src/public/node/session.ts | 23 +++++++------------ .../dev-platform-auth/src/testing/fixtures.ts | 4 ++-- 4 files changed, 12 insertions(+), 19 deletions(-) diff --git a/configurations/vite.config.ts b/configurations/vite.config.ts index 0a577cbfe58..a4e706c2104 100644 --- a/configurations/vite.config.ts +++ b/configurations/vite.config.ts @@ -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')}, ] } diff --git a/packages/cli-kit/src/public/node/session.auth-clientcreds.test.ts b/packages/cli-kit/src/public/node/session.auth-clientcreds.test.ts index ca49dba7fe3..59af4d4fd16 100644 --- a/packages/cli-kit/src/public/node/session.auth-clientcreds.test.ts +++ b/packages/cli-kit/src/public/node/session.auth-clientcreds.test.ts @@ -1,7 +1,6 @@ -import {describe, expect, test, vi} from 'vitest' - import {ensureAuthenticatedAdminAsApp} from './session.js' import {shopifyFetch} from './http.js' +import {describe, expect, test, vi} from 'vitest' vi.mock('./http.js') diff --git a/packages/cli-kit/src/public/node/session.ts b/packages/cli-kit/src/public/node/session.ts index b1f41f74762..429db072e47 100644 --- a/packages/cli-kit/src/public/node/session.ts +++ b/packages/cli-kit/src/public/node/session.ts @@ -2,7 +2,6 @@ import {shopifyFetch} from './http.js' import {nonRandomUUID} from './crypto.js' import {getAppAutomationToken} from './environment.js' import {AbortError, BugError} from './error.js' -import {createClientCredentialsClient, type AuthFetch, type ClientCredentialsError} from '@shopify/dev-platform-auth' import {outputContent, outputToken, outputDebug} from './output.js' import * as sessionStore from '../../private/node/session/store.js' import { @@ -23,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. @@ -358,28 +358,21 @@ export async function ensureAuthenticatedAdminAsApp( }) if ('accessToken' in result) return {token: result.accessToken, storeFqdn} - throw mapClientCredentialsError(result, storeFqdn, clientId) -} -function mapClientCredentialsError( - error: ClientCredentialsError, - storeFqdn: string, - clientId: string, -): AbortError | BugError { - if ('serverCode' in error) { - if (error.serverCode === 'app_not_installed') { - return new AbortError( + 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.`, ) } - return new AbortError(clientCredentialsFailureMessage(error.status, storeFqdn, clientId)) + throw new AbortError(clientCredentialsFailureMessage(result.status, storeFqdn, clientId)) } - if (error.kind === 'malformed_response' || error.kind === 'unexpected_status') { - return new AbortError(clientCredentialsFailureMessage(error.status, storeFqdn, clientId)) + if (result.kind === 'malformed_response' || result.kind === 'unexpected_status') { + throw new AbortError(clientCredentialsFailureMessage(result.status, storeFqdn, clientId)) } - return new AbortError(`Failed to get access token for app ${clientId} on store ${storeFqdn}: request failed`) + 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 { diff --git a/packages/dev-platform-auth/src/testing/fixtures.ts b/packages/dev-platform-auth/src/testing/fixtures.ts index c2856c71eb0..59c72ca04ce 100644 --- a/packages/dev-platform-auth/src/testing/fixtures.ts +++ b/packages/dev-platform-auth/src/testing/fixtures.ts @@ -310,8 +310,8 @@ export const authFixtures: ReadonlyArray = [ }, ] -export function isAuthSignal(value: unknown): value is AuthSignal { +function isAuthSignal(value: unknown): value is AuthSignal { return typeof value === 'object' && value !== null && 'aborted' in value && 'addEventListener' in value } -export type FixtureResponse = AuthFetchResponse +type FixtureResponse = AuthFetchResponse