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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/remove-cli-kit-jose.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 0 additions & 1 deletion packages/cli-kit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
53 changes: 53 additions & 0 deletions packages/cli-kit/src/private/node/session/exchange.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
exchangeAccessForApplicationTokens,
exchangeDeviceCodeForAccessToken,
exchangeCustomPartnerToken,
exchangeAppAutomationTokenForAppManagementAccessToken,
exchangeAppAutomationTokenForBusinessPlatformAccessToken,
Expand Down Expand Up @@ -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,
Expand Down
31 changes: 28 additions & 3 deletions packages/cli-kit/src/private/node/session/exchange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down Expand Up @@ -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.')
Expand All @@ -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
}
8 changes: 0 additions & 8 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading