diff --git a/.changeset/remove-cli-kit-jose.md b/.changeset/remove-cli-kit-jose.md new file mode 100644 index 0000000000..c93432ec8e --- /dev/null +++ b/.changeset/remove-cli-kit-jose.md @@ -0,0 +1,5 @@ +--- +'@shopify/cli-kit': patch +--- + +Removed the `jose` dependency from `@shopify/cli-kit` by inlining guarded JWT payload decoding for session user ID extraction. The session exchange path now validates token structure and payload shape before reading `sub`, including malformed-token handling in tests. diff --git a/packages/cli-kit/package.json b/packages/cli-kit/package.json index 1725431d06..de594be513 100644 --- a/packages/cli-kit/package.json +++ b/packages/cli-kit/package.json @@ -138,7 +138,6 @@ "ink": "6.8.0", "is-executable": "2.0.1", "is-wsl": "3.1.0", - "jose": "5.9.6", "latest-version": "7.0.0", "liquidjs": "10.26.0", "lodash": "4.17.23", diff --git a/packages/cli-kit/src/private/node/session/exchange.test.ts b/packages/cli-kit/src/private/node/session/exchange.test.ts index d0f2c95c97..a4d5a9885e 100644 --- a/packages/cli-kit/src/private/node/session/exchange.test.ts +++ b/packages/cli-kit/src/private/node/session/exchange.test.ts @@ -1,5 +1,6 @@ import { exchangeAccessForApplicationTokens, + exchangeDeviceCodeForAccessToken, exchangeCustomPartnerToken, exchangeAppAutomationTokenForAppManagementAccessToken, exchangeAppAutomationTokenForBusinessPlatformAccessToken, @@ -237,6 +238,58 @@ describe('refresh access tokens', () => { }) }) +describe('exchange device code for access token', () => { + test('extracts sub from id_token when existingUserId is absent', async () => { + vi.mocked(shopifyFetch).mockResolvedValue(new Response(JSON.stringify(data))) + + const result = await exchangeDeviceCodeForAccessToken('device-code') + + expect(result.isErr()).toBe(false) + expect(result.valueOrBug()).toEqual({ + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresAt: expiredDate, + scopes: data.scope.split(' '), + userId: '1234-5678', + alias: undefined, + }) + }) + + test.each([ + { + title: 'JWT does not have exactly 3 segments', + idToken: 'not-a-jwt', + expectedMessage: 'Invalid id_token: expected JWT with exactly 3 segments.', + }, + { + title: 'payload is not base64url-encoded JSON', + idToken: 'header.invalid-payload.signature', + expectedMessage: 'Invalid id_token: payload must be base64url-encoded JSON.', + }, + { + title: 'payload is a JSON string', + idToken: 'header.InN0cmluZyI.signature', + expectedMessage: 'Invalid id_token: payload must be a non-empty JSON object.', + }, + { + title: 'payload is an empty object', + idToken: 'header.e30.signature', + expectedMessage: 'Invalid id_token: payload must be a non-empty JSON object.', + }, + ])('throws when $title', async ({idToken, expectedMessage}) => { + vi.mocked(shopifyFetch).mockResolvedValue( + new Response( + JSON.stringify({ + ...data, + id_token: idToken, + }), + ), + ) + + await expect(exchangeDeviceCodeForAccessToken('device-code')).rejects.toThrow(expectedMessage) + }) +}) + const tokenExchangeMethods = [ { tokenExchangeMethod: exchangeCustomPartnerToken, diff --git a/packages/cli-kit/src/private/node/session/exchange.ts b/packages/cli-kit/src/private/node/session/exchange.ts index e5a60bac82..d6110b5d81 100644 --- a/packages/cli-kit/src/private/node/session/exchange.ts +++ b/packages/cli-kit/src/private/node/session/exchange.ts @@ -9,8 +9,6 @@ import {AbortError, BugError, ExtendableError} from '../../../public/node/error. import {setLastSeenAuthMethod, setLastSeenUserIdAfterAuth} from '../session.js' import {nonRandomUUID} from '../../../public/node/crypto.js' -import * as jose from 'jose' - export class InvalidGrantError extends ExtendableError {} export class InvalidRequestError extends ExtendableError {} class InvalidTargetError extends AbortError {} @@ -262,7 +260,7 @@ function buildIdentityToken( existingUserId?: string, existingAlias?: string, ): IdentityToken { - const userId = existingUserId ?? (result.id_token ? jose.decodeJwt(result.id_token).sub! : undefined) + const userId = existingUserId ?? (result.id_token ? getJwtSubject(result.id_token) : undefined) if (!userId) { throw new BugError('Error setting userId for session. No id_token or pre-existing user ID provided.') @@ -285,3 +283,30 @@ function buildApplicationToken(result: TokenRequestResult): ApplicationToken { scopes: result.scope.split(' '), } } + +function getJwtSubject(idToken: string): string | undefined { + const segments = idToken.split('.') + + if (segments.length !== 3) { + throw new BugError('Invalid id_token: expected JWT with exactly 3 segments.') + } + + const payload = segments[1] as string + + let parsedPayload: unknown + try { + parsedPayload = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) + } catch { + throw new BugError('Invalid id_token: payload must be base64url-encoded JSON.') + } + + if (!parsedPayload || typeof parsedPayload !== 'object' || Array.isArray(parsedPayload)) { + throw new BugError('Invalid id_token: payload must be a non-empty JSON object.') + } + + if (Object.keys(parsedPayload).length === 0) { + throw new BugError('Invalid id_token: payload must be a non-empty JSON object.') + } + + return (parsedPayload as {sub?: string}).sub +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6355cc3989..87eae7c709 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -405,9 +405,6 @@ importers: is-wsl: specifier: 3.1.0 version: 3.1.0 - jose: - specifier: 5.9.6 - version: 5.9.6 latest-version: specifier: 7.0.0 version: 7.0.0 @@ -6756,9 +6753,6 @@ packages: jju@1.4.0: resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} - jose@5.9.6: - resolution: {integrity: sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==} - js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -16596,8 +16590,6 @@ snapshots: jju@1.4.0: {} - jose@5.9.6: {} - js-tokens@4.0.0: {} js-tokens@9.0.1: {}